Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a871524d7 | ||
|
|
de0adb9207 | ||
|
|
ff50cbdb61 | ||
|
|
adb1d7f80e | ||
|
|
a57933a4c4 | ||
|
|
9d0822570c | ||
|
|
a5e88a8d97 | ||
|
|
eccf9e9a7d | ||
|
|
cc84bbe734 | ||
|
|
353296d3cf | ||
|
|
7bada88e4b | ||
|
|
cec20461f0 | ||
|
|
f4cae29d0d | ||
|
|
a96fb8f425 | ||
|
|
7fad4d9ac2 | ||
|
|
79e18343ca | ||
|
|
9e59016171 | ||
|
|
a221b82537 | ||
|
|
603f91b242 | ||
|
|
7984c8a3c8 | ||
|
|
5c61d1f1a5 | ||
|
|
5f36e4eba6 | ||
|
|
93f6a9567e | ||
|
|
ebf5d65a53 | ||
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
@@ -8,6 +8,7 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
|
||||
+104
-5
@@ -1,5 +1,5 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,9 +23,21 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
- branch: [main, next]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -103,6 +115,84 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
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);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
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)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
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")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -134,8 +224,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -146,7 +245,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -172,7 +271,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -30,6 +30,16 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -201,8 +211,21 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -230,6 +253,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
@@ -347,7 +371,9 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||
*
|
||||
* Covers MILESTONES.md M3 acceptance:
|
||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||
* - #7: max_rows_per_query caps list results.
|
||||
*
|
||||
* Strategy:
|
||||
* - Real PostgreSQL via @mosaicstack/db.
|
||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
and,
|
||||
createDb,
|
||||
eq,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
inArray,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
teams,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
users,
|
||||
} from '@mosaicstack/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||
import {
|
||||
makeMosaicIssuedCert,
|
||||
makeSelfSignedCert,
|
||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||
|
||||
interface TestIds {
|
||||
readonly subjectUserId: string;
|
||||
readonly otherUserId: string;
|
||||
readonly peerId: string;
|
||||
readonly revokedPeerId: string;
|
||||
readonly activeGrantId: string;
|
||||
readonly revokedGrantId: string;
|
||||
readonly subjectProjectId: string;
|
||||
readonly subjectMissionId: string;
|
||||
readonly otherProjectId: string;
|
||||
readonly teamId: string;
|
||||
readonly unauthorizedTeamId: string;
|
||||
readonly teamProjectId: string;
|
||||
readonly taskIds: readonly string[];
|
||||
readonly excludedTaskIds: readonly string[];
|
||||
readonly subjectNoteId: string;
|
||||
readonly otherUserNoteId: string;
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Buffer {
|
||||
return Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, ''),
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||
return {
|
||||
raw: {
|
||||
socket: {
|
||||
getPeerCertificate: () => ({
|
||||
raw: pemToDer(certPem),
|
||||
serialNumber: CERT_SERIAL_HEX,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function makeGuardContext(request: FastifyRequest): {
|
||||
readonly context: ExecutionContext;
|
||||
readonly sent: { statusCode?: number; payload?: unknown };
|
||||
} {
|
||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||
const reply = {
|
||||
status: (statusCode: number) => {
|
||||
sent.statusCode = statusCode;
|
||||
return {
|
||||
header: () => ({
|
||||
send: (payload: unknown) => {
|
||||
sent.payload = payload;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { context, sent };
|
||||
}
|
||||
|
||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
name: `${RUN_ID}-${label}`,
|
||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||
const subjectUserId = `${RUN_ID}-subject`;
|
||||
const otherUserId = `${RUN_ID}-other`;
|
||||
const peerId = crypto.randomUUID();
|
||||
const revokedPeerId = crypto.randomUUID();
|
||||
const activeGrantId = crypto.randomUUID();
|
||||
const revokedGrantId = crypto.randomUUID();
|
||||
const subjectProjectId = crypto.randomUUID();
|
||||
const subjectMissionId = crypto.randomUUID();
|
||||
const otherProjectId = crypto.randomUUID();
|
||||
const teamId = crypto.randomUUID();
|
||||
const unauthorizedTeamId = crypto.randomUUID();
|
||||
const teamProjectId = crypto.randomUUID();
|
||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const subjectNoteId = crypto.randomUUID();
|
||||
const otherUserNoteId = crypto.randomUUID();
|
||||
|
||||
await insertUser(db, subjectUserId, 'subject');
|
||||
await insertUser(db, otherUserId, 'other');
|
||||
|
||||
await db.insert(teams).values([
|
||||
{
|
||||
id: teamId,
|
||||
name: `${RUN_ID} allowed team`,
|
||||
slug: `${RUN_ID}-allowed-team`,
|
||||
ownerId: subjectUserId,
|
||||
managerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: unauthorizedTeamId,
|
||||
name: `${RUN_ID} unauthorized team`,
|
||||
slug: `${RUN_ID}-unauthorized-team`,
|
||||
ownerId: otherUserId,
|
||||
managerId: otherUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(teamMembers).values([
|
||||
{ teamId, userId: subjectUserId, role: 'member' },
|
||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: subjectProjectId,
|
||||
name: `${RUN_ID} subject personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
name: `${RUN_ID} other personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: otherUserId,
|
||||
},
|
||||
{
|
||||
id: teamProjectId,
|
||||
name: `${RUN_ID} unauthorized team project`,
|
||||
ownerType: 'team',
|
||||
teamId: unauthorizedTeamId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missions).values({
|
||||
id: subjectMissionId,
|
||||
name: `${RUN_ID} subject mission`,
|
||||
projectId: subjectProjectId,
|
||||
userId: subjectUserId,
|
||||
});
|
||||
|
||||
await db.insert(tasks).values([
|
||||
{
|
||||
id: taskIds[0],
|
||||
title: `${RUN_ID} visible task 1`,
|
||||
missionId: subjectMissionId,
|
||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[1],
|
||||
title: `${RUN_ID} visible task 2`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[2],
|
||||
title: `${RUN_ID} visible task 3`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[0],
|
||||
title: `${RUN_ID} other user task`,
|
||||
projectId: otherProjectId,
|
||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[1],
|
||||
title: `${RUN_ID} unauthorized team task`,
|
||||
projectId: teamProjectId,
|
||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missionTasks).values([
|
||||
{
|
||||
id: subjectNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: subjectUserId,
|
||||
notes: `${RUN_ID} subject visible note`,
|
||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: otherUserNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: otherUserId,
|
||||
notes: `${RUN_ID} other user note on subject mission`,
|
||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationPeers).values([
|
||||
{
|
||||
id: peerId,
|
||||
commonName: `${RUN_ID}-active-peer`,
|
||||
displayName: `${RUN_ID} Active Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
{
|
||||
id: revokedPeerId,
|
||||
commonName: `${RUN_ID}-revoked-peer`,
|
||||
displayName: `${RUN_ID} Revoked Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationGrants).values([
|
||||
{
|
||||
id: activeGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'active',
|
||||
scope: {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
filters: {
|
||||
tasks: { include_personal: true, include_teams: [] },
|
||||
notes: { include_personal: true, include_teams: [] },
|
||||
},
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: revokedGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||
scope: {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
subjectUserId,
|
||||
otherUserId,
|
||||
peerId,
|
||||
revokedPeerId,
|
||||
activeGrantId,
|
||||
revokedGrantId,
|
||||
subjectProjectId,
|
||||
subjectMissionId,
|
||||
otherProjectId,
|
||||
teamId,
|
||||
unauthorizedTeamId,
|
||||
teamProjectId,
|
||||
taskIds,
|
||||
excludedTaskIds,
|
||||
subjectNoteId,
|
||||
otherUserNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(missionTasks)
|
||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(missions)
|
||||
.where(eq(missions.id, ids.subjectMissionId))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(projects)
|
||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.userId, ids.subjectUserId),
|
||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teams)
|
||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(users)
|
||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let moduleRef: TestingModule;
|
||||
let guard: FederationAuthGuard;
|
||||
let listController: ListController;
|
||||
let ids: TestIds | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
ids = await seedFixtures(db);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [ListController],
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = moduleRef.get(FederationAuthGuard);
|
||||
listController = moduleRef.get(ListController);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
});
|
||||
|
||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||
const malformedOidCert = await makeSelfSignedCert();
|
||||
const request = makeFederationRequest(malformedOidCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(401);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: expect.stringContaining('missing required OID'),
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const revokedCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.revokedGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(revokedCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(403);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Federation access denied',
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('tasks', request, { limit: 100 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(response.items).toHaveLength(2);
|
||||
expect(response._truncated).toBe(true);
|
||||
expect(response.nextCursor).toEqual(expect.any(String));
|
||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||
for (const excludedId of ids!.excludedTaskIds) {
|
||||
expect(returnedIds).not.toContain(excludedId);
|
||||
}
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('notes', request, { limit: 10 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for unsupported list resources', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is not supported',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(): CommandExecutorService {
|
||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
mockRedis as never,
|
||||
redis as never,
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(ttl).toBe(300);
|
||||
});
|
||||
|
||||
it('/provider login remains available without Redis on the local tier', async () => {
|
||||
const localService = buildService(null);
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const result = await localService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).not.toContain('token=');
|
||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
|
||||
@@ -23,7 +23,10 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -443,14 +446,16 @@ export class CommandExecutorService {
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
if (this.redis) {
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection.
|
||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationGetQueryService } from '../get-query.service.js';
|
||||
|
||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'credentials',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationGetQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationGetQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-06 Team',
|
||||
slug: 'fed-m3-06-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-06 Unauthorized Team',
|
||||
slug: 'fed-m3-06-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-06 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-06 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-06 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-06 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-06 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-06 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('FederationGetQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation get access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||
const service = makeService();
|
||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||
(
|
||||
service as unknown as {
|
||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||
}
|
||||
).listSubjectTeamIds = listSubjectTeamIds;
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'tasks',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'credentials federation get is not implemented',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
...CREDENTIAL_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
id: 'row-1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'found',
|
||||
item: {
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
content: 'subject note on team mission',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { GetController } from '../get.controller.js';
|
||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationGetQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
status: 'found',
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new GetController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 1,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||
expect(response).toEqual({
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||
|
||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'not_found' } },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||
});
|
||||
|
||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Task is outside the federated scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
};
|
||||
const { controller } = makeController({
|
||||
scopeResult: allowedScope(unsupportedFilter),
|
||||
queryResult: {
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty ids before evaluating scope', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Federation get query layer (FED-M3-06).
|
||||
*
|
||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
or,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
|
||||
export interface FederationGetQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||
readonly status: 'found';
|
||||
readonly item: T;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryNotFoundResult {
|
||||
readonly status: 'not_found';
|
||||
}
|
||||
|
||||
export interface FederationGetQueryDeniedResult {
|
||||
readonly status: 'denied';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||
| FederationGetQueryFoundResult<T>
|
||||
| FederationGetQueryNotFoundResult
|
||||
| FederationGetQueryDeniedResult;
|
||||
|
||||
type RowObject = Record<string, unknown>;
|
||||
|
||||
function firstRow<T>(rows: T[]): T | undefined {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function rowBelongsToAccessibleProjectOrMission(
|
||||
row: { projectId?: string | null; missionId?: string | null },
|
||||
projectIds: readonly string[],
|
||||
missionIds: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async get<T extends RowObject = RowObject>(
|
||||
request: FederationGetQueryRequest,
|
||||
): Promise<FederationGetQueryResult<T>> {
|
||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||
}
|
||||
|
||||
private async getByResource(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.getTask(filter, id);
|
||||
case 'notes':
|
||||
return this.getNote(filter, id);
|
||||
case 'memory':
|
||||
return this.getMemory(filter, id);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||
default:
|
||||
return {
|
||||
status: 'denied',
|
||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||
// rows are authorized through projects.teamId, while ownerId remains the
|
||||
// user who created/bootstrapped the project.
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async getTask(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||
}
|
||||
|
||||
return { status: 'found', item: row as RowObject };
|
||||
}
|
||||
|
||||
private async getNote(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
userId: missionTasks.userId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(eq(missionTasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row || row.content === null || row.content === '') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Scope-visible missions must intersect with subject ownership; team scope
|
||||
// narrows mission IDs but never widens note reads to another user's rows.
|
||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||
}
|
||||
|
||||
const item = { ...row } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
|
||||
private async getMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const [insightRow, preferenceRow] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
userId: insights.userId,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(eq(insights.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(eq(preferences.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
]);
|
||||
|
||||
const candidates = [insightRow, preferenceRow].filter(
|
||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (!filter.includePersonal) {
|
||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||
}
|
||||
|
||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||
if (!accessible) {
|
||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||
}
|
||||
|
||||
const item = { ...accessible } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Federation get verb (FED-M3-06).
|
||||
*
|
||||
* POST /api/federation/v1/get/:resource/:id
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||
* request or response bodies.
|
||||
*/
|
||||
|
||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
type FederationGetResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationGetQueryService } from './get-query.service.js';
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function scopeDenyToHttpException(deny: {
|
||||
readonly statusCode: 400 | 403;
|
||||
readonly message: string;
|
||||
}): HttpException {
|
||||
const ErrorClass =
|
||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/get')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class GetController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource/:id')
|
||||
async get(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id') id: string,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<FederationGetResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (id.trim().length === 0) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit: 1,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
throw scopeDenyToHttpException(scopeResult.deny);
|
||||
}
|
||||
|
||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||
if (result.status === 'not_found') {
|
||||
throw new HttpException(
|
||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'denied') {
|
||||
throw new HttpException(
|
||||
new FederationScopeViolationError(result.reason, {
|
||||
resource,
|
||||
id,
|
||||
grantId: request.federationContext.grantId,
|
||||
peerId: request.federationContext.peerId,
|
||||
subjectUserId: request.federationContext.subjectUserId,
|
||||
}).toEnvelope(),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||
|
||||
const result = await localService.collect('local-session');
|
||||
|
||||
expect(result.sessionId).toBe('local-session');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||
'local-session',
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
||||
@Injectable()
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns an empty population on the Local tier where Redis is disabled.
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -47,12 +52,14 @@ export class SessionGCService {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||
if (!this.queueService.isEnabled()) {
|
||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { SystemOverrideService } from './system-override.service.js';
|
||||
|
||||
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||
|
||||
describe('SystemOverrideService local tier', () => {
|
||||
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||
const service = new SystemOverrideService(localConfig);
|
||||
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||
|
||||
await service.set('shared-session', 'first override', firstScope);
|
||||
await service.set('shared-session', 'second override', secondScope);
|
||||
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
|
||||
await service.clear('shared-session', firstScope);
|
||||
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
@@ -15,16 +17,45 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
const key = scopedSessionId(sessionId, scope);
|
||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(
|
||||
entry.fragments.map((fragment) => fragment.text),
|
||||
);
|
||||
this.localStore.set(key, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) return;
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
@@ -65,6 +100,11 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { QueueService } from './queue.service.js';
|
||||
|
||||
const localConfig = {
|
||||
queue: { type: 'local' },
|
||||
} as MosaicConfig;
|
||||
|
||||
describe('QueueService local tier', () => {
|
||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||
const service = new QueueService(null, localConfig);
|
||||
|
||||
expect(service.isEnabled()).toBe(false);
|
||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||
|
||||
await expect(
|
||||
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
||||
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
||||
await expect(service.listJobs()).resolves.toEqual([]);
|
||||
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.connection = getConnection();
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
this.connection = this.enabled
|
||||
? getConnection()
|
||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||
}
|
||||
|
||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
if (this.enabled) {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.closeAll();
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Get or create a BullMQ Queue for the given queue name.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
queue = new Queue<T>(name, { connection: this.connection });
|
||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Add a BullMQ repeatable job (cron-style).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueue<T>(queueName)!;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* safe retirement of previously registered system-wide jobs.
|
||||
*/
|
||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const queue = this.getQueue(queueName);
|
||||
if (!queue) return 0;
|
||||
const jobs = await queue.getRepeatableJobs();
|
||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
handler: JobHandler<T>,
|
||||
): Worker<T> | null {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const worker = new Worker<T>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* job IDs are not globally unique — they are scoped to their queue.
|
||||
* Returns an error on Local tier.
|
||||
*/
|
||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# npm `@next` prerelease lane
|
||||
|
||||
Status: **IMPLEMENTED**
|
||||
|
||||
## Current behavior
|
||||
|
||||
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
|
||||
|
||||
The lane is fast-by-default:
|
||||
|
||||
1. Install framework files from the `next` source archive.
|
||||
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
|
||||
|
||||
```bash
|
||||
npm view @mosaicstack/gateway@next version
|
||||
npm view @mosaicstack/mosaic@next version
|
||||
```
|
||||
|
||||
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
|
||||
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
|
||||
|
||||
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
|
||||
|
||||
## Published packages
|
||||
|
||||
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
|
||||
|
||||
```text
|
||||
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
```
|
||||
|
||||
Observed `next` dist-tags after enabling the pipeline:
|
||||
|
||||
```text
|
||||
@mosaicstack/mosaic@next -> 0.0.49-next.1633
|
||||
@mosaicstack/gateway@next -> 0.0.7-next.1633
|
||||
```
|
||||
|
||||
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
|
||||
|
||||
## Explicit source lanes
|
||||
|
||||
Source builds remain available and are still the authority for explicit ref validation:
|
||||
|
||||
- `--dev` always builds from source.
|
||||
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
|
||||
|
||||
## Pipeline shape
|
||||
|
||||
1. Trigger on `next` merges.
|
||||
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
|
||||
3. Build and publish non-private packages in CI.
|
||||
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
|
||||
5. Keep `latest` untouched; only main/release promotion can update `latest`.
|
||||
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- `@next` is mutable prerelease convenience, not a deployment pin.
|
||||
- Stable installs continue to use `@latest`.
|
||||
- Contributor validation remains available through `--dev --ref <branch>`.
|
||||
- Pipeline output traces every prerelease package back to the source commit on `next`.
|
||||
- The installer falls back to source rather than hard-failing on prerelease registry issues.
|
||||
@@ -195,6 +195,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
||||
|
||||
A pre-push hook enforces this mechanically.
|
||||
|
||||
### CI Publish Channels
|
||||
|
||||
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
|
||||
|
||||
| Source | npm packages | Gateway image |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
|
||||
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
|
||||
|
||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agent Tools
|
||||
|
||||
@@ -175,8 +175,18 @@ Or use the direct URL:
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||
non-interactive use:
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||
|
||||
Install lanes:
|
||||
|
||||
| Lane | Command | Source |
|
||||
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
|
||||
Flags for non-interactive use:
|
||||
|
||||
```bash
|
||||
--yes # Accept all defaults
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the merge-base anchor round
|
||||
|
||||
**Subject head (exact):** `fbb61912981abb250d289ec7aafd4316db6fdb11`
|
||||
**Supersedes:** the second NO-GO at `32b490a7` (D-45/D-46/D-47). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## What the orchestrator already reproduced (do not re-spend the review here)
|
||||
|
||||
- Derived boundary `f4fd5967` **equals** an independently computed `git merge-base HEAD origin/main`.
|
||||
- Emptying `criteria`/`gates`/`proseClaims`/`compatibilityScenarios` now fails with
|
||||
_"population must be non-empty and anchored before evaluation"_.
|
||||
- The both-directions bootstrap statement is present in `gate-history.mjs` with the Builds 1–2 residual.
|
||||
|
||||
## A — attack the ANCHOR (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | `targetRef` is the literal `refs/remotes/origin/main`. **Who controls that ref in the environment where the gate runs?** Can a PR author influence it — a `remote.origin.url` rewrite, a crafted `refs/remotes/origin/main` in their own clone, a push to a fork's `main`? If the gate trusts a locally-writable ref, the anchor moved from the manifest into git config. |
|
||||
| **A2** | **Absent/stale target:** if `refs/remotes/origin/main` is missing, stale, or the fetch is shallow, does it fail CLOSED (line 57 suggests it does) or silently derive a narrower range? |
|
||||
| **A3** | **Delayed introduction — the round-2 attack, re-run.** Commit a gate change BEFORE adding the manifest. Does it now stay in range and fail the own-tree read? |
|
||||
| **A4** | **Branch from an old main:** branch from a point far behind `origin/main`, so merge-base is old. Does the range widen correctly (safe) or include unrelated main commits that cannot carry manifests (a new false-red)? Over-inclusion is the natural failure mode of this fix. |
|
||||
| **A5** | Is the **bootstrap residual** recorded as a **tracked dependency on Builds 1–2**, not prose? (D-19's third mandatory move.) |
|
||||
|
||||
## P — the non-empty/anchored precondition
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **P1** | Does the precondition run **before** every quantified check, on **every** path — or only the ones the author enumerated? Find a quantified check that still runs before its precondition. |
|
||||
| **P2** | **Seven required gate IDs anchored to canonical sources** — can that anchor list itself be emptied, shrunk, or pointed elsewhere? An anchor list the author edits is D-45 again, one level in. |
|
||||
| **P3** | The author reports finding and REMOVING a **production CLI fixture-profile bypass** in their own pre-commit review. **Verify it is gone from the shipped CLI path**, that the remaining relaxation is test-support only and non-executable in production, and that the CLI rejection is tested. This was self-disclosed — confirm it independently. |
|
||||
|
||||
## Q — clauses quantified over the population (D-47)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Q1** | `gateRefs` **exactly spans** every registered gate. Can a gate be added WITHOUT a corresponding ref — i.e. does adding a gate to the population escape the clause? |
|
||||
| **Q2** | Deleting `gateRefs`, or shrinking gates and refs **together**, must fail. Shrink-together is the D-46 shape; verify it is a genuine control and not a regression guard mislabelled. |
|
||||
| **Q3** | Do the population controls **iterate all seven gates** and mutate evidence-subject and comparison-type **per gate**, or only a representative one? |
|
||||
| **Q4** | Honest labelling: author claims delayed-introduction, empty-populations, fixture-profile bypass and removable gateRefs as **genuine red-first**, and shrink-together/exact-span as **regression guards**. Verify each claim against pre-fix code (D-8). |
|
||||
|
||||
## C — head, CI, integrity
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `fbb61912` on git and provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `verify-terminal-green.py --expect-commit fbb61912981abb250d289ec7aafd4316db6fdb11`. |
|
||||
| **C3** | Nothing weakened: `32b490a7` → `fbb61912` removes/relaxes no existing case, test, or assertion. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `fbb61912981abb250d289ec7aafd4316db6fdb11`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**A1 and P2 are the ones I most want answered** — both ask whether the fix moved the author's control
|
||||
point again rather than removing it, which is now the mission's named first-class principle and has
|
||||
recurred three times. **Attack outside this set.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,67 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the four-blocker hardening pass
|
||||
|
||||
**Subject head (exact):** `32b490a712a5e8e77f13c40c60099b51feb38994`
|
||||
**Supersedes:** the NO-GO at `83d2ecb2` (D-44, four silent-defeat paths). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## Framing — attack the FIX, not the original bug
|
||||
|
||||
Every round on this mission, **the remediation introduced the next hole**: the commit-binding fix
|
||||
shipped a type confusion; the type-strict fix was clean but the registry around it had four defeats.
|
||||
So the highest-value checks here are **not** "was each blocker fixed" — the orchestrator already
|
||||
reproduced three of the four attacks as dead — but **"is the NEW mechanism itself defeatable?"**
|
||||
|
||||
Blocker 1's fix replaced an author-settable field with a **derivation**. A derivation has inputs. **The
|
||||
question is who controls them.**
|
||||
|
||||
## H — the new derivation (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **H1** | `deriveHistoryBoundary` = parent of the first first-parent commit introducing `gates/gates.manifest.json`. **Are its inputs author-controlled?** Can a PR author influence the derived value by adding, moving, renaming, deleting-and-recreating, or symlinking that path — or by adding a SECOND manifest earlier in history? If the derivation is gameable, blocker 1 is not fixed, only relocated. |
|
||||
| **H2** | **First-parent traversal:** what happens on a merge commit, an octopus merge, a squash, a rebase that reorders, or a branch where the introducing commit is not on the first-parent chain? Does the boundary silently move, or fail closed? |
|
||||
| **H3** | **Shallow/partial clone:** the branch previously needed an unshallow fix (`e8959975` "unshallow gate replay history"). If history is shallow, does the derivation fail CLOSED or silently derive a wrong/empty boundary? |
|
||||
| **H4** | **Path deletion:** if `gates/gates.manifest.json` is absent at HEAD, or was deleted and re-added, what is derived? Fail closed, or a boundary that excludes the deletion window? |
|
||||
| **H5** | Are the **three registered seam controls (HEAD, HEAD^, introduction) genuinely RED-FIRST** — do they fail against the PRE-fix code for their own stated reason? A control that was always green is a regression guard, not a must-fail control (D-8). |
|
||||
|
||||
## S — the recursive schema closure
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **S1** | **Is closure COMPLETE or enumerated?** The author lists the objects they closed. Find one they did **not** — any nested object anywhere in the manifest — and inject an unknown key there. If it is accepted, the fix covers instances, not the class. |
|
||||
| **S2** | **Wrong-type, not just unknown-key:** `outputPattern` as a number/array/object/null rather than misspelled. Does the closed schema type-check values, or only key names? |
|
||||
| **S3** | **Empty/degenerate values:** `outputPattern: ""` — does an empty regex match everything and silently neuter the assertion the same way a typo did? The author claims an empty-pattern control; verify it is bound. |
|
||||
| **S4** | Confirm the registered typo/wrong-type/empty-pattern controls are **genuine red-first** against pre-fix code, and that anything labelled a regression guard is honestly labelled as one. |
|
||||
|
||||
## E — provider evidence (blocker 4)
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **E1** | Global validation runs **before** per-commit filtering **on every path**, including the HEAD-only path the author calls out. Is there any code path that reaches per-commit assessment without it? |
|
||||
| **E2** | **Duplicate identity by another key:** the fix makes pipeline NUMBER globally unique. Can two records still collide on a different identity dimension — same commit + different number, same URL, same step-id — and certify the wrong subject? |
|
||||
| **E3** | "Exactly one gate-verify step per record" — what if a record has zero, or two with different outcomes? Fail closed? |
|
||||
|
||||
## C — clauses, integrity, CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | The three new criteria (`RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`) are **checked criteria with bidirectionally bound cases**, not prose. Orchestrator observed caseRefs 1 / 3 / 3 — verify the bindings actually run and can fail. |
|
||||
| **C2** | **B1/B2 satisfied in substance:** does `RM02-EVIDENCE-SUBJECT-BINDING` express D-38 generally ("does this gate bind its evidence to its subject?") or only the one pipeline-number instance? Same for D-40 vs the one typo instance. **A clause that only covers its originating instance is not a clause.** |
|
||||
| **C3** | **Nothing weakened:** diff `83d2ecb2` → `32b490a7` removes/relaxes no existing case, test, or assertion. |
|
||||
| **C4** | Exact-head CI terminal-green, full `-f json`, counts stated **including `clone`**; machine-checked with `verify-terminal-green.py --expect-commit 32b490a712a5e8e77f13c40c60099b51feb38994`. |
|
||||
| **C5** | `Closes #1029` still present (D-38c). |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `32b490a712a5e8e77f13c40c60099b51feb38994`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so** — never substitute a passing variant.
|
||||
|
||||
**H1 and S1 are the two I most want answered**, because they ask whether each fix closed a **class** or
|
||||
an **instance**. Attack outside this set: four of the last five blockers on this PR came from mutating
|
||||
something nobody had registered.
|
||||
|
||||
**Environment note:** `pnpm gate:verify` fails on `checkout-preflight` with outcome 42 (bubblewrap
|
||||
class) in at least one worktree. If you hit it, report it as an environment limitation — do not treat it
|
||||
as a finding, and do not let it mask one.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,59 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: honest narrowing + (d)-strict
|
||||
|
||||
**Subject head (exact):** `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`
|
||||
**Supersedes:** the third NO-GO at `fbb61912` (D-45/D-46/D-47) and the D-52 stop. Those are VOID.
|
||||
**Registered by:** `mos-remediation` before any reviewer read the diff. **Reviewer:** `rev-974`.
|
||||
|
||||
## Already reproduced by the orchestrator (do not re-spend here)
|
||||
|
||||
- The required narrowing sentence is **verbatim** in `gates/required-gates.baseline.json` and in
|
||||
`scripts/gate-inventory-claim-control.mjs`: _"Detects accidental and incompetent inventory drift
|
||||
within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier
|
||||
consistently — that guarantee requires RM-60's external boundary."_
|
||||
- **The overclaim control FIRES**: clean ⇒ exit 0; injecting "protected, independently anchored" into
|
||||
the baseline purpose ⇒ **exit 84**, `INVENTORY_CLAIM_OVERSTATED`.
|
||||
- CI #2201 at the exact head, 10/10 incl. `clone`; machine check bound, `exempted_steps 0`, no anomalies.
|
||||
|
||||
## N — the narrowing (D-52's ruling is the wording)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **N1** | ★ Is the narrowed claim **honest EVERYWHERE**, or only where the control looks? Find any place — docs, comments, observations, manifest prose, CLI output — that still implies the baseline is protected or independently anchored. **The control only checks the files it checks.** |
|
||||
| **N2** | ★ Can the **overclaim control itself** be defeated? It is PR-controlled like everything else. Weaken/skip/reword it so an overstatement passes. If it can be trivially neutered, D-52 is re-armed one level down — the exact recursion this PR keeps producing. |
|
||||
| **N3** | Does the control detect **semantic** overclaim or only the literal strings it greps? An artifact could assert protection in different words and pass. |
|
||||
| **N4** | Is the narrowed claim **TRUE**? Verify the baseline genuinely catches accidental/inconsistent drift — not just that it says so. A narrowed claim that is still overstated is D-48 again. |
|
||||
|
||||
## H — (d)-strict history removal
|
||||
|
||||
| id | check |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **H1** | Is the verifier **structurally incapable** of reporting history-provenance-verified, or merely not currently doing so? The manifest says "structurally incapable" — test the claim. |
|
||||
| **H2** | ★ **TRAP 1 re-check:** nothing emits the unverifiable state as an OBSERVATION (which exits 0). Confirm no path yields exit 0 while implying provenance was considered. |
|
||||
| **H3** | ★ **TRAP 2 re-check:** the exclusion is **enforced**, not merely declared in `coverageBoundary.excluded`. Re-enable history provenance without a trustworthy anchor — does a registered case go RED? |
|
||||
| **H4** | The residual is stated **correctly** — no local git state is trustworthy because PR code executes before the gate — **NOT** the D-48 wording ("compromised main"). |
|
||||
|
||||
## B3 / R — evidence-subject and renderer
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **B1** | Subject is on the **EVIDENCE side** and compared **at consumption**, not `gate.evidenceSubject === gate.id` metadata-vs-itself. Mutate the evidence-side subject **per gate**. |
|
||||
| **B2** | Can evidence still be moved or misbound while every gate satisfies its check? |
|
||||
| **R1** | Renderer now covers the **final success stdout/stderr** paths. |
|
||||
|
||||
## C — integrity + CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **C1** | Head `e910a45a` on git + provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, `-f json`, counts incl. `clone`; machine-checked with `--expect-commit e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`. |
|
||||
| **C3** | Nothing weakened `fbb61912` → `e910a45a`. Red-first labels honest (D-8). |
|
||||
| **C4** | **NO fourth local anchor was invented.** Blocker 1 and blocker 2's adversarial guarantee both track RM-60. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`, evidence enumerated, posted durably.
|
||||
Unrunnable ⇒ **say so**. **N1 and N2 are the sharp ones** — this PR's history is that every fix
|
||||
relocates the defect one level down, and N2 asks whether the control that enforces honesty is itself
|
||||
honest. **Attack outside the set: six for six so far.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,71 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED ACCEPTANCE CHECKS (post-rebase, keystone)
|
||||
|
||||
**Subject head (exact):** `83d2ecb2243f1b0987ef2bc14de47f444285a684`
|
||||
**Rebased onto:** `main` @ `f4fd5967fc5d4cbc72d680b199d88224aa855131` (post-RM-61)
|
||||
**Registered by:** `mos-remediation` **BEFORE the reviewer read the diff.** **Reviewer:** `rev-974`
|
||||
(author ≠ reviewer; branch commits are `f10-coder`'s and `coder-mos1`'s).
|
||||
**Prior reviews 63 / 65 are SUPERSEDED; this head carries NO live review of any kind.**
|
||||
|
||||
RM-02 is the **keystone**: it is the anti-inert-gate registry every downstream gate is measured against.
|
||||
A defect that survives here propagates into every gate this mission ships.
|
||||
|
||||
## ★ CRUX — the one non-mechanical change, made by the author, that turned a red green
|
||||
|
||||
The rebase was clean. One further commit was **not** purely mechanical and the author disclosed it
|
||||
unprompted (`83d2ecb2` — advance registry activation seam):
|
||||
|
||||
gates/gates.manifest.json
|
||||
- "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c"
|
||||
+ "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131"
|
||||
|
||||
Stated rationale: after the rebase, `pnpm gate:verify` correctly failed because the newly-merged
|
||||
pre-registry RM-61 commit was treated as _prospective_ and required a manifest that could not exist.
|
||||
|
||||
`activationCommit` sets the lower bound of `activationCommit..HEAD` — **the set of commits required to
|
||||
carry own-tree registry provenance** (`gate-history.mjs:314` → `listProspectiveCommits`). Advancing it
|
||||
**shrinks that set**.
|
||||
|
||||
**Determine, do not assume — and I have deliberately formed and stated no verdict (D-39):**
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | **Is the advance JUSTIFIED?** Can a pre-registry commit on `main` (e.g. `f4fd5967`) satisfy the provenance requirement at all, or is requiring it impossible-by-construction? If impossible, is advancing the seam the _correct_ remedy, or does a correct remedy exist that does not move an author-controlled field? |
|
||||
| **A2** | **Is the advance MINIMAL?** Was it advanced exactly to the new parent baseline, or beyond it? Does any commit that _should_ remain covered fall outside the new range? |
|
||||
| **A3** | **Are all 7 branch commits still PROSPECTIVE under the new seam?** Enumerate them and show each is still required to carry provenance. The author claims "own-tree provenance over every registry-era branch commit" — verify the claim, do not accept it. |
|
||||
| **A4** | ★★ **IS THE SEAM ITSELF CONSTRAINED?** The only validation found is `merge-base --is-ancestor activationCommit head` (`gate-history.mjs:288`). **A commit is its own ancestor.** Determine what `activationCommit = HEAD` does: is the prospective range empty, does the per-commit loop iterate zero times, and does the history check therefore PASS VACUOUSLY? If so, the anti-inert-gate registry contains an author-settable field that can inert its own history check. **Run it. Report the observed exit and whether any failure is raised.** |
|
||||
| **A5** | **If A4 shows vacuity is reachable, is there a registered MUST-FAIL negative control for it?** RM-02's own founding rule is that a gate with no proven failure path manufactures evidence. Does the registry hold a case that goes RED when the seam is over-advanced? If not, that is a hole in the registry's coverage of itself. |
|
||||
| **A6** | **Self-verification shape (charter / D-19 / D-39b):** the field was advanced by the change's own author to make the author's gate pass. Independent of whether the value is correct, determine whether the _mechanism_ permits an audited party to relax its own audit, and whether that needs a constraint, an owner, or an escalation. |
|
||||
|
||||
## Registry substance — the clauses this PR must now carry
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **B1** | **D-38 clause present and enforced:** the registry asks of every gate _"does this gate BIND its evidence to the subject under review?"_ Ruled in-scope for THIS PR by Mos. Verify it exists as a checked clause, not prose. |
|
||||
| **B2** | **D-40 clause present and enforced:** _"discriminator and comparison inputs must be TYPE-STRICT."_ Also ruled in-scope for THIS PR. `== 0` matching `False`/`0.0` is the banked instance; the clause is the general form. |
|
||||
| **B3** | **D-42's clause is CORRECTLY ABSENT** — provider-side negative control tracks separately (Mos: landing it now would give the registry a clause with nothing to satisfy it, going red on its own clause). Confirm its absence is deliberate and recorded, not forgotten. |
|
||||
| **B4** | **The four founding clauses still hold** (`MISSION.md`): every check proven **right** (red for its own stated reason), the set **covers**, no two criteria **conflict**, and criterion evolution **retains original text + restatement + reason**. |
|
||||
| **B5** | **Nothing was weakened, skipped, or deleted by the rebase.** Diff `f9746b23` → `83d2ecb2` and confirm no registered case, test, or assertion was removed or relaxed to make the rebase land. |
|
||||
| **B6** | **`coverageBoundary.excluded[]` is honest in BOTH directions** (charter principle 4): what it does NOT cover is stated beside what it DOES, and the gap is tracked (`trackedBy: RM-54`) rather than implied-fixed. |
|
||||
| **B7** | **The RM-02 execution boundary** (`gate-history.mjs`, DOES / DOES NOT, owner RM-60, xref RM-59) states its limits in both directions and names a tracked owner — not a documented gap with no owner. |
|
||||
|
||||
## Head + CI (re-run these; do not carry them forward from the author's report)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `83d2ecb2…` on **both** git and provider; `Closes #1029` present in the body (delivery-issue rule, D-38c). |
|
||||
| **C2** | Exact-head CI terminal-green by **full `-f json` scan**, counts stated **including `clone`**. Note the count changed 9 → **10** (this PR adds `gate-verify`) — confirm the new step is real, not a miscount. |
|
||||
| **C3** | **Machine-check it, do not assert it:** `verify-terminal-green.py --expect-commit 83d2ecb2243f1b0987ef2bc14de47f444285a684`. Report exit, `exempted_steps`, `commit == expected_commit`. |
|
||||
| **C4** | `exempted_steps=0` is expected here — `ci-postgres` succeeded, so the #1000 exemption was **not needed**. Confirm the exemption is present-but-unused rather than silently inapplicable. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `83d2ecb2243f1b0987ef2bc14de47f444285a684`, evidence enumerated per check, posted
|
||||
durably under your own identity. If a check is unrunnable, **say so** — never substitute a variant that
|
||||
passes. Scan CI from `-f json`, never default text (D-33); state your counts.
|
||||
|
||||
**A4 is the check I most want an answer to and the one I have least confidence about.** Attack outside
|
||||
this set as well: every blocker found on this mission so far came from mutating something nobody had
|
||||
registered a check for.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).** Observations may be
|
||||
relayed to you; verdicts may not. The ruling is yours.
|
||||
@@ -1,42 +0,0 @@
|
||||
# RM-03 / PR #1032 — PRE-REGISTERED RE-REVIEW (post-freshening)
|
||||
|
||||
**Subject head (exact):** `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`
|
||||
**Rebased onto:** `main` @ `f4fd5967` **Registered by:** `mos-remediation` before the reviewer read the
|
||||
diff. **Reviewer:** `rev-974`.
|
||||
|
||||
**Why this exists:** review 66 APPROVED and the merge-gate GO were bound to `78ec47cd`; `main` drifted
|
||||
under a long hold and the PR went `mergeable=false` (**D-50 — a GO has a shelf life**). Both verdicts
|
||||
are VOID at the new head. Jason's merge approval is **standing** and executes once this re-gates.
|
||||
|
||||
## What the orchestrator already verified (do not re-spend here)
|
||||
|
||||
- `git range-diff 78ec47cd...868b9b87`: commits **2–5 are `=` (patch-equivalent)**; only commit 1 differs.
|
||||
- That single difference is the `test:framework-shell` chain, resolved as a **UNION** — main's
|
||||
`test-terminal-green-contract.sh` **and** RM-03's `tristate`, `github-checks`, `merge-queue-branch`,
|
||||
`merge-head-pin`, plus the pre-existing `branch-absent`. **Nothing dropped from either side.**
|
||||
- CI #2199 at the exact head: 9 children, 9 success incl. `clone`; machine check exit 0, bound, no anomalies.
|
||||
- `mergeable` is now **true**.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **R1** | ★ **THE GUARD CAN FAIL.** RM-03 exists because the queue guard is ZERO-INFORMATION (D-23) — it returns pass for every possible input. Prove the rebased head's guard **actually fails** on asserted non-readiness, not merely that its tests pass. This is the whole deliverable. |
|
||||
| **R2** | ★ **NO QUEUE-GUARD SEMANTIC MOVED IN THE REBASE.** Confirm the range-diff equivalence independently. A queue-guard semantic resolved quietly re-breaks the thing this PR fixes. |
|
||||
| **R3** | **Union is complete in BOTH directions:** no RM-03 suite lost to main's version, no main suite (esp. `test-terminal-green-contract.sh`) lost to RM-03's. Enumeration count reconciles. |
|
||||
| **R4** | **Tri-state is real** (`verified` / `written-unverified` / `failed`) — P-WRAPPER-001. Verify an indeterminate result cannot present as a pass; that is the exact defect (`state=unknown exit 0`). |
|
||||
| **R5** | The guard's **exit-asserting non-null-case** tests are genuine — each fails for its own stated reason against pre-fix code, not merely present (D-8). |
|
||||
| **R6** | `test:framework-shell` is **runnable to completion in CI** (canonical env). Known: it exits 97 on some hosts via a Bash 5.2.15 `BASH_LINENO` assertion — if you hit that, report it as an environment limitation, do not treat it as a finding and do not let it mask one. |
|
||||
| **R7** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `--expect-commit 868b9b871a5118762aa5b8aafecd2f0592f7ab5c`. |
|
||||
| **R8** | `Closes #1019` present in the body (D-38c delivery-issue rule). |
|
||||
| **R9** | Nothing weakened by the rebase: no test, case, or assertion removed or relaxed to make it land. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**R1 is the deliverable and R2 is the risk.** Attack outside this set. **Do not cite the queue guard's
|
||||
own green as evidence of anything** — it remains inert until this very PR lands (D-23).
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,40 +0,0 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED RE-REVIEW ACCEPTANCE CHECKS
|
||||
|
||||
**Subject head (exact):** `033b2ffb46674b2c0bcc5197273c109b461f62d9`
|
||||
**Supersedes:** review 67 / comment 20403 @ `e7b29219` (NO GO). That verdict is VOID — the head moved.
|
||||
**Registered by:** `mos-remediation` (orchestrator). **Reviewer:** `rev-974` (author ≠ reviewer).
|
||||
**Registered BEFORE the reviewer read the diff.** Any head move after this file is committed voids the
|
||||
re-review and requires re-registration.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
The prior review passed AC1–AC8 and still found a blocker, because the registered set tested whether the
|
||||
signature DISCRIMINATES and never tested whether the evidence was BOUND TO ITS SUBJECT (Finding 3 /
|
||||
coverage-failure-mode-2, D-17 class). This set therefore carries the binding property as a first-class
|
||||
check, and **RR7 explicitly invites the reviewer to attack outside the set** — a registered set is
|
||||
protection against retrofitting only, never a ceiling on scrutiny.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| **RR1** | The full 12-case contract harness passes at the exact head: `bash test-terminal-green-contract.sh` | ⇒0, and states 12 cases |
|
||||
| **RR2** | ★CRUX — the ORIGINAL ATTACK IS DEAD. Take the real `#2188` record, mutate ONLY `commit` to an unrelated 40-hex value, verify against the true expected head | ⇒1, `exempted_steps == 0`, anomaly naming expected vs actual |
|
||||
| **RR3** | Missing `--expect-commit` cannot be defaulted, inferred, or skipped | ⇒2 (not 0, not 1) |
|
||||
| **RR4** | Malformed expected commit (short SHA, non-hex, empty) is rejected — no silent normalisation into a pass | ⇒2 |
|
||||
| **RR5** | A record with the `commit` key ABSENT (not merely different) is rejected — fail-closed on missing, not just on mismatch | ⇒1, `exempted_steps == 0` |
|
||||
| **RR6** | The genuine artifact still passes when correctly bound: real `#2188` + its true head | ⇒0, `exempted_steps == 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **RR7** | ★RED-FIRST, PROVED RETROACTIVELY. The four new cases must FAIL against the OLD verifier at `e7b29219` — otherwise they do not test what they claim (D-8 class). Run the new cases against the previous implementation | new cases ⇒≠0 under `e7b29219` |
|
||||
| **RR8** | AC2 OF THE PRIOR SET DID NOT REGRESS: both REAL controls stay terminal red — `#2189` (`ci-postgres` exit 1) and `#2191` (exit 137, `test` exit 61) | both ⇒1, `exempted_steps == 0` |
|
||||
| **RR9** | Still NO fetch / trigger / retry / re-roll / sleep / network of any kind in the verifier or harness. The coin flip must remain removed, not codified (D-21) | grep ⇒ no such call sites |
|
||||
| **RR10** | The doc/baseline changes REQUIRE the current provider PR head to be passed — they must not merely mention it. Check `merge-gate.md`, `CI-CD-PIPELINES.md`, `woodpecker/README.md` state it as a requirement a gate operator cannot satisfy by omission | reviewer judgement, quote the lines |
|
||||
| **RR11** | Exemption remains bound to #1000 and retires with it; signature conjunction unchanged and not widened by this fix | diff-scoped, ⇒ no widening |
|
||||
| **RR12** | Case-sensitivity: an uppercase-hex record commit against a lowercase expected head must NOT silently pass by accident of comparison. State which way it resolves and whether it fails closed | state the observed behaviour |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Report the verdict **bound to `033b2ffb46674b2c0bcc5197273c109b461f62d9`** and state each check's
|
||||
observed result, including counts read from `pipeline-status.sh -f json` (never default text — it omits
|
||||
`clone`, D-33). If any check is unrunnable, **say so** — never substitute a passing variant. Attack
|
||||
outside this set and report anything it finds; RR7 and RR12 exist because the last blocker was found
|
||||
exactly that way.
|
||||
@@ -1,60 +0,0 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED ACCEPTANCE CHECKS: `exit_code` TYPE-STRICTNESS (D-40)
|
||||
|
||||
**Registered by:** `mos-remediation` (orchestrator) — **BEFORE the fix was pushed and before any
|
||||
reviewer read a diff.** At registration time the fix existed only as an unpushed local commit
|
||||
(`6e7a336d`) on coder-mos1's machine which **I have not read**. There is therefore no diff for these
|
||||
checks to have been retrofitted to.
|
||||
|
||||
**Subject head:** TBD — binds to the NEW head once coder-mos1 pushes. The prior head
|
||||
`033b2ffb46674b2c0bcc5197273c109b461f62d9` and its review 69 / pipeline #2193 go VOID on that push;
|
||||
that cost was accepted deliberately by Mos's BLOCKING ruling on D-40.
|
||||
|
||||
**Ruling being enforced (Mos, 2026-08-01):** `exit_code` must be accepted ONLY as a real integer.
|
||||
`false`, `0.0`, `"0"`, and `null` must all fail to satisfy the exemption. Wrong-ACCEPT is the
|
||||
disqualifying direction; this hole sits inside the load-bearing discriminator.
|
||||
|
||||
## ⚠ Read this before writing the tests — RED-FIRST HERE IS NOT UNIFORM
|
||||
|
||||
Two of the four near-miss values **already block** at `033b2ffb`. Demanding "all four observed RED
|
||||
first" would be demanding an impossible red for two of them, and the predictable response to an
|
||||
impossible demand is a fudge — weakening something real to manufacture the red. So state it precisely:
|
||||
|
||||
| value | behaviour at `033b2ffb` (pre-fix) | what the new case is |
|
||||
| ------- | --------------------------------- | ------------------------- |
|
||||
| `false` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `0.0` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `"0"` | correctly blocks (exit 1) | **regression guard** only |
|
||||
| `null` | correctly blocks (exit 1) | **regression guard** only |
|
||||
|
||||
Claiming red-first for `"0"` or `null` would be a false claim about your own evidence. Say which is
|
||||
which. **Do not weaken anything to make a green case go red** (D-8).
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **TS1** | ★RED-FIRST. `exit_code: false` on the real #2188 record is **observed wrongly exempting at `033b2ffb`** (exit 0, `exempted_steps 1`), then blocks after the fix | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS2** | ★RED-FIRST. Same for `exit_code: 0.0` | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS3** | REGRESSION GUARD. `exit_code: "0"` blocked before AND after — state honestly that it was already green | both ⇒1, `exempted_steps 0` |
|
||||
| **TS4** | REGRESSION GUARD. `exit_code: null` blocked before AND after | both ⇒1, `exempted_steps 0` |
|
||||
| **TS5** | ★NOT OVER-TIGHTENED. The genuine artifact — real #2188, real integer `exit_code: 0`, correctly bound head — still passes | ⇒0, `exempted_steps 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **TS6** | The strictness sits on the value the EXEMPTION rests on, not on a cosmetic sibling. Show the guarded comparison is the one feeding `exemption_applies` | reviewer quotes the line |
|
||||
| **TS7** | `bool` is excluded EXPLICITLY, not incidentally. A bare `isinstance(x, int)` still admits `True`/`False` — verify the `not isinstance(x, bool)` clause exists | ⇒ clause present; `true` also blocks |
|
||||
| **TS8** | Negative control unaffected: a genuine failed step with a real integer non-zero exit still blocks | ⇒1, `exempted_steps 0` |
|
||||
| **TS9** | NO REGRESSION ON THE PRIOR ROUND'S BINDING WORK: mutated record commit ⇒1; `--expect-commit` omitted ⇒2; record `commit` absent ⇒1 | ⇒1 / ⇒2 / ⇒1 |
|
||||
| **TS10** | Signature conjunction NOT widened elsewhere by this fix — `POD_NOT_FOUND` / name / type / state untouched | diff-scoped ⇒ no widening |
|
||||
| **TS11** | Still no fetch / trigger / retry / re-roll / sleep / network | grep ⇒ no call sites |
|
||||
| **TS12** | Full harness passes at the new head; **state the case count** (12 previously, expected 16 — confirm the actual number rather than the expected one) | ⇒0, count stated |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to the NEW head, evidence enumerated per check, CI counts from `pipeline-status.sh -f json`
|
||||
(never default text — it omits `clone`, D-33). If a check is unrunnable, **say so**; never substitute a
|
||||
passing variant.
|
||||
|
||||
**Attack outside this set and report what you find.** Both blockers on this PR so far — the missing
|
||||
commit binding, and this type confusion — were found outside the registered set, by mutating a field
|
||||
nobody had registered a check for. That is now the expectation, not a bonus.
|
||||
|
||||
**Nobody dispatching this review may state a conclusion on an open check here (D-39).** If you are sent
|
||||
an observation, it is an observation; the ruling is yours.
|
||||
@@ -23,68 +23,3 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
|
||||
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
<!-- board-roll: Decisions-log narrative rolled from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays) -->
|
||||
|
||||
### Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
|
||||
All 44 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-43 + D-38c in `TASKS.md`) and every ruling with its
|
||||
rationale live there. **Not duplicated here** — a second copy is a second thing to go stale, which this
|
||||
board had done three times in one night (gate list, capability registry, DECISION-1 status), and three
|
||||
more times by the next rotation seam (RM-61 "building", "nothing implemented yet", DECISION-1/2/3
|
||||
"must be ruled"). The rulings a fresh seat needs are items 4–7 above; they are **not** repeated here,
|
||||
because that repetition is what went stale.
|
||||
|
||||
<!-- board-roll: Sequencing section rolled verbatim from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays). Canonical: MISSION.md + TASKS.md §5 -->
|
||||
|
||||
### Sequencing — see [`MISSION.md`](./MISSION.md)
|
||||
|
||||
Builds 1-5, the cross-cutting retirements, and DECISION-1's corrected wire-in target are stated once in
|
||||
the charter and `TASKS.md` §5. **Not repeated here** — the previous copy of DECISION-1's status on this
|
||||
board is one of the six stale restatements below.
|
||||
|
||||
<!-- board-roll: capability/seat-identity bullets rolled verbatim from BOARD.md 2026-08-01
|
||||
(D-43: roll out, never reword). Authoritative home: TASKS.md D-11 / D-11a / D-11b. -->
|
||||
|
||||
- Capability is **per-path** (D-11b → **superseded in part by D-13/D-15**): a token file is **necessary,
|
||||
not sufficient**. Three layers — token file (raw-API), `tea` login (tea paths), **repository permission**
|
||||
(writes). Before dispatch, assert `permissions.push == true` **as that seat**, not token existence and
|
||||
not a 200 on a read. Mos owns provisioning; escalate missing pairs.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
|
||||
<!-- board-roll: D-26 gate-restatement rationale rolled verbatim from BOARD.md 2026-08-01 -->
|
||||
|
||||
### Why the board must not restate the delivery gates (D-26)
|
||||
|
||||
Restating the gate from memory is how the merge-gate step went missing from mission setup twice,
|
||||
once inside the correction for it (**D-26**).
|
||||
|
||||
<!-- board-roll: Fleet seats rolled verbatim from BOARD.md 2026-08-01 (D-43: roll out, never
|
||||
reword). NOTE: all these seats are UNMANAGED per D-41; `mosaic fleet ps` is live truth. -->
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
+73
-65
@@ -1,85 +1,93 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — **RM-02 keystone is the front**.
|
||||
**Updated:** 2026-08-05 — successor seat live. D-56 state correction applied (lost round-5 dispatch,
|
||||
4 idle days); round-5 review then ran same day: verdict id 88 REQUEST_CHANGES @ `e910a45a`,
|
||||
remediation dispatched to `coder-mos2`. Review dispatches now always leave a durable PR comment.
|
||||
⚠ That was a **MANUAL pane respawn** (prior seat ~803k tokens): it validates the checkpoint+rehydration
|
||||
**design**, NOT a lifecycle **mechanism** — P-LIFECYCLE rotation does not exist yet (**D-41 / RM-62**).
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
|
||||
- Charter + 15 decisions + 4-build plan: `MISSION.md`. Backlog + all findings: `TASKS.md`.
|
||||
- Planning DONE (58 tasks, P0–P5). **DECISION-1/2/3 all RULED by Mos 2026-07-31** (`TASKS.md` §5) —
|
||||
nothing is waiting on a decision. D-2's availability _target_ is Jason-pending and non-blocking.
|
||||
- **Executing, not planning.** RM-01 is MERGED; three lanes are live (see In-flight).
|
||||
- Orchestrator seat `mos-remediation` LIVE, owns the mission, resumed across the rotation seam
|
||||
2026-08-01 and re-attested to Mos from the files. Residency attestation: PASS.
|
||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RM-01 checkout | — | **MERGED** `f58b3699` (#1027) |
|
||||
| RM-03 queue guard | Jason (re-sync) | ✅ **MERGED** `58b971ab` (#1032), #1019 closed. ⚠ **NOT DELIVERED**: installed guard still the broken one (291 lines / 0 `ASSERTED_NOT_READY` vs main 482 / 5). Re-sync via `mosaic upgrade`, then **prove it blocks a KNOWN-RED pipeline** — **D-51** |
|
||||
| RM-02 registry ★key | coder-mos2 | **ROUND-5 VERDICT id 88 REQUEST_CHANGES @ `e910a45a` (2026-08-05T22:37Z, live).** 2 blockers left: renderer accepts manifest-controlled `COMPATIBILITY `-prefixed provenance-success payloads; overclaim control is lexical-not-semantic vs docs claims. Shrink-together + evidence-subject CONFIRMED CLOSED — do not weaken. Remediation dispatched to `coder-mos2` 22:5xZ (PR comment + verified tmux); context guard: checkpoint at 85%. ⚠ **MERGE PRECONDITIONS (D-55/D-55f, measured):** poster=f10-coder (1 of 3 authors); (1) explicit `-m squash` (repo default `merge` discards ALL authorship); (2) **merger ≠ f10-coder** (self-merge suppresses the trailer generator → all 3 lost); (3) **the EXECUTING pr-merge.sh must contain `MergeMessageField` — merging #1066 is NOT sufficient** (deployed wrapper `08a65e85` = 0 refs, physically cannot emit; D-51's merged≠effective class inside the remedy; Mos-as-merger verifies wrapper sha256 before ANY trailer-dependent merge and refuses on `08a65e85`); (4) ✅ **CLOSED ON MEASUREMENT** — N=2 supplied non-poster trailers BOTH land, order preserved, poster line appended last (fixture prmerge-trailer-fixture#2 → `39db9d13`, #1030's exact shape; N=3+/poster-dup-dedup/Co-committed-by absence remain unmeasured but #1030 needs N=2). Merge decision resolved: all-land ⇒ merge as planned, three authors preserved. Trade in body; **branch MUST NOT be deleted**. ACs @ `dde38717` |
|
||||
| RM-61 CI exemption | — | ✅ **MERGED** `f4fd5967` (#1033). #1034 closed; **#1000 stays OPEN** (retirement trigger). Exemption is on `main` |
|
||||
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
|
||||
| #1023 queue attempt | Jason | SUPERSEDED-PENDING-JASON — live REQUEST_CHANGES, do **not** merge |
|
||||
|
||||
### For the incoming orchestrator — read this before acting
|
||||
|
||||
1. **Lane state lives in the In-flight table above — this item does NOT restate it.** It went stale
|
||||
three times in one session by duplicating that table (D-26's class). Read the table. ⚠ **And
|
||||
re-derive any board claim from the provider before load-bearing use (D-43)** — the board is
|
||||
sole-written and has no independent verifier.
|
||||
2. **`docs/remediation/TASKS.md` is authoritative**, not the newest voice in a chat. It holds 58 findings
|
||||
(D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in TASKS.md), every ruling with its rationale, and the
|
||||
requirements each finding placed on RM-02/RM-34/RM-50/RM-55.
|
||||
3. **`MISSION.md` carries the first-class principles** — read them there, they are not listed here.
|
||||
Two added 2026-08-01: **the anchor must live outside the audited party's authority** (D-19/D-25/D-45,
|
||||
third arrival) and **no universally-quantified check may pass over an empty set** (D-44/D-46).
|
||||
4. **Seat identity:** `export MOSAIC_GIT_IDENTITY=<seat>` is stripped by a context reset (**D-34**) —
|
||||
every dispatch/rehydration brief must re-export it, or the seat cannot use its credentials.
|
||||
5. **Scan CI from `-f json`, never default text** — text mode omits `clone` (**D-33**). State counts.
|
||||
6. **The queue guard is zero-information until RM-03 merges** (**D-23**) — never cite its green.
|
||||
7. **The bounded CI re-roll used on RM-02 was a one-time stopgap, NOT policy.** A per-PR free re-roll is
|
||||
D-21 normalisation. Do not repeat it; RM-61 is the fix.
|
||||
|
||||
## Delivery gates — REFERENCE, do not restate
|
||||
|
||||
Canonical: `~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) +
|
||||
`~/.config/mosaic/fleet/roles/validator.md`. Order and the freeze/zero-information rules: `MISSION.md`
|
||||
and `KICKSTART.md`. **Read them there** (why: **D-26**, in `BOARD-LEDGER.md`).
|
||||
| Task | Owner | State |
|
||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||
|
||||
## Fleet seats
|
||||
|
||||
Roster rolled verbatim to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md); live truth is `mosaic fleet ps`.
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
## Gate status
|
||||
|
||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- Git identity: orchestrator runs `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||
- Capability + seat identity (**D-11b / D-11a**, incl. the false-NEGATIVE twin): authoritative in
|
||||
`TASKS.md`. Short form — **assert the DIFFERENTIAL as that seat** (authenticated `push:true` vs
|
||||
unauthenticated `push:false`); a single endpoint can be true for anyone or 403 for an unrelated
|
||||
scope. Full text rolled to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
- ⚠ **LIVE HAZARD (D-37) — one shared `.git/config` re-identifies EVERY worktree at once.** Every seat,
|
||||
including `rev-974`'s review worktree, currently authors as **`coder-mos1`**; `MOSAIC_GIT_IDENTITY`
|
||||
does **not** override it. **STANDING ORDER: commit with explicit
|
||||
`git -c user.name=<seat> -c user.email=<seat>@…`, and NOBODY rewrites the shared config mid-flight.**
|
||||
Real fix authorised, Mos owns it, sequenced at a quiet seam. **#1024 implicated.** Detail: D-37.
|
||||
- Standing worker-brief doctrine (mandatory in EVERY brief): re-export `MOSAIC_GIT_IDENTITY` (**D-34**);
|
||||
commit early/WIP (**D-31**); don't weaken a RED test to pass; if a check is unrunnable SAY SO, never
|
||||
substitute; `agent-send -f` never `-m`; artifacts off shared `/tmp`; scan CI from `-f json` (**D-33**);
|
||||
**relay observations into an open review, NEVER your own conclusion on an open check (D-39)**; the
|
||||
**author never adjudicates their own PR's blocker status** — surface evidence, prepare the fix, hold.
|
||||
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||
`-m`; heavy artifacts off shared `/tmp`.
|
||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||
|
||||
## Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
## Sequencing (from MISSION.md)
|
||||
|
||||
All 58 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in `TASKS.md`) and every ruling with
|
||||
its rationale live there. **Not duplicated here.** The history of _why_ this board must not restate —
|
||||
six stale copies across two seams — is rolled verbatim into [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||
4. Hygiene + conformance harness
|
||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||
|
||||
## Dogfood evidence — live failure classes, not hypotheticals
|
||||
|
||||
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||
|
||||
@@ -25,18 +25,8 @@ mechanically until Build 3 (rotation) makes it automatic.
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates — REFERENCE the canonical files, never restate them:**
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
|
||||
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
|
||||
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
|
||||
diff is read) → remediation → **CI terminal-green at the exact head by full step scan** →
|
||||
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
|
||||
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
|
||||
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
|
||||
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
|
||||
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
|
||||
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
|
||||
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
+2
-143
@@ -91,99 +91,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||
|
||||
### First-class principle — query for refutation, never for confirmation
|
||||
|
||||
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
|
||||
>
|
||||
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
|
||||
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
|
||||
> and they return different answers to the same question. The first harvests agreement; only the second
|
||||
> can return **"you are wrong, and here is why."**
|
||||
>
|
||||
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
|
||||
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
|
||||
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
|
||||
> rigorous. **It has to be built into how the question is asked.**
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
|
||||
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
|
||||
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
|
||||
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
|
||||
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
|
||||
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
|
||||
>
|
||||
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
|
||||
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
|
||||
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
|
||||
> independent runs beat one confident report.
|
||||
|
||||
### First-class principle — the anchor must live outside the audited party's authority
|
||||
|
||||
> **You cannot fix "the author controls X" by deriving X from something the author ALSO controls.**
|
||||
> Deriving merely **moves** the control point; it does not remove it.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01) on the **THIRD INDEPENDENT ARRIVAL** of the same
|
||||
> conclusion, each reached while trying to ship something else, each from a different direction:
|
||||
>
|
||||
> | arrival | the audited party controls… | found as |
|
||||
> | ----------------- | ------------------------------------------------------------------- | -------- |
|
||||
> | manifest | the tree that certifies its own generated state (same-UID, CWE-345) | **D-19** |
|
||||
> | sandbox | the code that enters the sandbox, before the boundary exists | **D-25** |
|
||||
> | **registry seam** | **WHEN the tracked path is introduced, hence the derived boundary** | **D-45** |
|
||||
>
|
||||
> Round 1 the value was an author-settable **field**, so it was **derived**. Round 2 the derivation
|
||||
> depended on **when the author introduces the path**. Same authority, new costume. **Three
|
||||
> impossibility-derivations of one conclusion is not a coincidence to note — it is the strongest
|
||||
> architectural evidence this mission has produced**, and it is precisely what **forces Builds 1–2**
|
||||
> rather than making them a preference.
|
||||
>
|
||||
> **Operationally:** anchor to a reference the audited party cannot move. A git **merge-base against
|
||||
> `main`** is such a reference for a PR author (they own their branch; they do not own `main`).
|
||||
> **State the bootstrap in BOTH directions (D-19):** that anchor is sound against an author who cannot
|
||||
> rewrite `main` — the threat in scope — and **NOT** sound against an attacker who can. **That residual
|
||||
> is what Builds 1–2 close, and it must be recorded as a tracked dependency, never implied.**
|
||||
|
||||
### First-class principle — no universally-quantified check may pass over an empty set
|
||||
|
||||
> **"All registered cases ran" is VACUOUSLY TRUE when there are no registered cases.**
|
||||
> **Non-emptiness and anchoring are PRECONDITIONS asserted before the quantified check runs — not
|
||||
> properties hoped for after it.**
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) after the identical vacuity appeared **twice, at two different levels**:
|
||||
> `activationCommit = HEAD` emptied the **commit range** (D-44), and an emptied manifest — `criteria`,
|
||||
> `gates`, `proseClaims`, `compatibilityScenarios` all `[]` — emptied the **registry population**
|
||||
> (D-46), each yielding **exit 0**. The first was fixed **as an instance**; the principle was never
|
||||
> extracted, **so it returned one level up.**
|
||||
>
|
||||
> **Delete every gate and every criterion TOGETHER and no remaining reference complains — because every
|
||||
> reference went with them.** A check that quantifies over a population must actually **range** over it,
|
||||
> and that population must be **provably complete and non-empty**.
|
||||
>
|
||||
> **Corollary (same disease):** a clause written for the instance that produced it is not a clause.
|
||||
> **Do not relabel the originating instances as the general clauses** — quantify over the population.
|
||||
|
||||
### First-class principle — redundant observation on evidence-bearing steps
|
||||
|
||||
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
|
||||
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
|
||||
> mechanical and therefore skips.
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
|
||||
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
|
||||
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
|
||||
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
|
||||
> disagreement between two counts surfaced it.**
|
||||
>
|
||||
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
|
||||
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
|
||||
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
|
||||
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
|
||||
>
|
||||
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
|
||||
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
|
||||
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
|
||||
> state the counts observed so a divergence is detectable at all.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
@@ -202,31 +109,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## Why Builds 1–2 are necessary — two independent impossibility proofs
|
||||
|
||||
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
|
||||
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
|
||||
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
|
||||
party's control, which is precisely what Builds 1–2 provide.
|
||||
|
||||
| | the audited party controls… | so what fails | found as |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
|
||||
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
|
||||
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
|
||||
|
||||
Both reduce to one sentence:
|
||||
|
||||
> **Self-verification by the audited party is not verification.**
|
||||
|
||||
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
|
||||
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
|
||||
PR-controlled config and simultaneously prevent that config from using it.
|
||||
|
||||
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
|
||||
for.** Neither proof was sought — both arrived while trying to ship something else, from different
|
||||
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
|
||||
dependencies this creates, and they are the same dependency in different clothes.
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
@@ -279,29 +161,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
|
||||
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
|
||||
(verdict authority) and
|
||||
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
|
||||
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
|
||||
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
|
||||
|
||||
**Gate order** (the sequence only; the definitions are in the files above):
|
||||
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
|
||||
acceptance checks committed before the diff is read
|
||||
2. Remediation of findings
|
||||
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
|
||||
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
|
||||
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
|
||||
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
|
||||
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
|
||||
"GO — gates verified" is non-conforming.
|
||||
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
|
||||
|
||||
⚠ **The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
|
||||
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
|
||||
|
||||
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
|
||||
owns getting a PR _gate-ready_.
|
||||
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
|
||||
+12
-2090
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||
|
||||
**Tracking:** RM-61 / issue #1000
|
||||
|
||||
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||
**Owner:** `coder-mos1`
|
||||
|
||||
## Objective
|
||||
|
||||
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||
|
||||
## Pre-registered kill criterion
|
||||
|
||||
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||
|
||||
## Plan
|
||||
|
||||
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||
|
||||
## Initial evidence
|
||||
|
||||
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Requirements and kill criterion recorded before control implementation.
|
||||
- [x] Historical full-JSON records captured.
|
||||
- [x] Startup-failure control observed terminal.
|
||||
- [x] Post-readiness crash control observed terminal.
|
||||
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||
- [x] Conditional exemption implementation.
|
||||
|
||||
## Tests / evidence
|
||||
|
||||
### Control 1 — real startup failure
|
||||
|
||||
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||
- Pipeline: #2189, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||
|
||||
### Control 2 — real post-readiness crash
|
||||
|
||||
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||
- Pipeline: #2191, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=61`.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||
|
||||
### Discrimination verdict
|
||||
|
||||
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||
|
||||
### Unit red-first checkpoint
|
||||
|
||||
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||
|
||||
### Control 2 setup attempt — invalid, excluded from evidence
|
||||
|
||||
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||
- Pipeline: #2190, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||
|
||||
## Implementation evidence
|
||||
|
||||
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||
- Python compile: PASS.
|
||||
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||
- `pnpm lint`: PASS, 25/25 tasks.
|
||||
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||
|
||||
## Independent review
|
||||
|
||||
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||
- [x] Operator command documented in the Woodpecker tool README.
|
||||
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||
- [x] Tracking and retirement cite issue #1000.
|
||||
- [x] Both positive and negative guarantee boundaries are stated.
|
||||
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||
|
||||
## Risks
|
||||
|
||||
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Scratchpad — FED-M3-06 get verb
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
|
||||
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
|
||||
- Unit coverage for controller pipeline + query service RBAC guardrails
|
||||
- Register controller/service in `FederationModule`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service.
|
||||
2. Return one `_source: "local"` tagged item on success.
|
||||
3. Return federation error envelopes:
|
||||
- `404 not_found` when the resource id does not exist.
|
||||
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
|
||||
- `400 invalid_request` for malformed ids/scope requests.
|
||||
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
|
||||
- Review-change coverage added for comment 15971:
|
||||
- get note access now requires subject ownership AND authorized mission intersection.
|
||||
- missing federation context returns structured `401 unauthorized` envelope.
|
||||
- unsupported get resources fail closed with structured denial.
|
||||
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
|
||||
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
|
||||
- `pnpm build` — pass (23 successful tasks before review changes).
|
||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
|
||||
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
|
||||
- `pnpm format:check` — pass (re-run after review changes).
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
|
||||
@@ -0,0 +1,82 @@
|
||||
# B1 / @next Durable Publish Pipeline — Design
|
||||
|
||||
## Objective
|
||||
|
||||
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
|
||||
|
||||
Every merge to `next` publishes:
|
||||
|
||||
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
|
||||
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
|
||||
|
||||
The existing stable release behavior remains isolated to `main` / tags.
|
||||
|
||||
## Registry verification
|
||||
|
||||
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
|
||||
|
||||
Pre-implementation checks:
|
||||
|
||||
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
|
||||
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
|
||||
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
|
||||
|
||||
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
|
||||
|
||||
## Version scheme
|
||||
|
||||
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
|
||||
|
||||
For each non-private `@mosaicstack/*` package:
|
||||
|
||||
```text
|
||||
<target-stable>-next.<CI_PIPELINE_NUMBER>
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
|
||||
- `target-stable` is the package's current committed stable version with the patch component incremented.
|
||||
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
|
||||
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
|
||||
|
||||
Rationale:
|
||||
|
||||
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
|
||||
- The prerelease does not overtake the future stable `0.0.49`.
|
||||
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
|
||||
|
||||
## Branch and tag guardrails
|
||||
|
||||
| Pipeline path | Branch/event | Publishes | Forbidden |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
|
||||
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
|
||||
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
|
||||
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
|
||||
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
|
||||
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
|
||||
|
||||
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
|
||||
2. Keep existing `publish-npm` on `main` / tags only.
|
||||
3. Add `publish-next-npm` for `next` push/manual only:
|
||||
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
|
||||
- preflight registry dist-tag metadata;
|
||||
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
|
||||
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
|
||||
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
|
||||
4. Split image `when` anchors:
|
||||
- `image_build_when` includes `next` and is used by `build-gateway`;
|
||||
- `main_image_build_when` keeps appservice/web on main/tags only.
|
||||
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
|
||||
|
||||
## Risk controls
|
||||
|
||||
- Auth/registry failures are fatal.
|
||||
- No manual image build/push path is introduced.
|
||||
- No production `latest` tags are touched from `next`.
|
||||
- No `@latest` npm dist-tags are touched from `next`.
|
||||
- All changes live in CI config and docs; no runtime source behavior changes.
|
||||
@@ -0,0 +1,34 @@
|
||||
# B2 — Fresh-install skills sync path
|
||||
|
||||
## Problem
|
||||
|
||||
Greenfield wizard on `next` reported:
|
||||
|
||||
```text
|
||||
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
|
||||
Skills: install failed
|
||||
```
|
||||
|
||||
## Diagnosis
|
||||
|
||||
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/tools/_scripts/
|
||||
```
|
||||
|
||||
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
|
||||
|
||||
## Fix
|
||||
|
||||
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
|
||||
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
|
||||
- Point missing-script warnings at the current `tools/_scripts` layout.
|
||||
- Update the finalize skills test fixture to model the fresh framework layout.
|
||||
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
|
||||
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
|
||||
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
|
||||
@@ -0,0 +1,36 @@
|
||||
# B3 — Wizard completion ordering
|
||||
|
||||
## Problem
|
||||
|
||||
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
|
||||
|
||||
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
|
||||
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
|
||||
3. gateway config starts/waits for daemon health;
|
||||
4. gateway bootstrap runs.
|
||||
|
||||
The summary needed to be deferred until after the gateway readiness gates.
|
||||
|
||||
## Fix
|
||||
|
||||
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
|
||||
- Wizard/quick-start paths call finalize with `deferSummary: true`.
|
||||
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
|
||||
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
|
||||
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
|
||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
|
||||
- `pnpm format:check`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
- `pnpm build`
|
||||
- `pnpm test`
|
||||
- Codex code review: approve.
|
||||
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
|
||||
@@ -0,0 +1,36 @@
|
||||
# B4 — Wizard step deduplication
|
||||
|
||||
## Problem
|
||||
|
||||
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
|
||||
|
||||
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
|
||||
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
|
||||
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
|
||||
- provider step completed and user intentionally skipped the key, vs.
|
||||
- provider step was never run.
|
||||
|
||||
## Fix
|
||||
|
||||
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
|
||||
- Completed menu steps now log a skip message instead of re-running their stage.
|
||||
- Quick Start marks Providers and Skills complete after running them.
|
||||
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
|
||||
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
|
||||
|
||||
## Verification
|
||||
|
||||
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
|
||||
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
|
||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
|
||||
- `pnpm format:check`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
- `pnpm build`
|
||||
- `pnpm test`
|
||||
- Codex code review: approve.
|
||||
- Codex security review: no findings.
|
||||
@@ -0,0 +1,60 @@
|
||||
# FED-M3-10 — Federation M3 Integration Tests
|
||||
|
||||
## Objective
|
||||
|
||||
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
|
||||
|
||||
## Branch / base
|
||||
|
||||
- Branch: `feat/federation-m3-integration`
|
||||
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
|
||||
- PR base when unblocked: `next`
|
||||
|
||||
## Scope
|
||||
|
||||
- Real PostgreSQL via `@mosaicstack/db`.
|
||||
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
|
||||
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
|
||||
- Gated by `FEDERATED_INTEGRATION=1`.
|
||||
- No federation harness dependency.
|
||||
|
||||
## Fixture notes
|
||||
|
||||
Aligned with the B2 seed design vocabulary:
|
||||
|
||||
- `tasks` visibility uses personal `projects` + `missions` chain.
|
||||
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
|
||||
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
|
||||
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
|
||||
|
||||
## Current implementation
|
||||
|
||||
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
|
||||
|
||||
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
|
||||
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
|
||||
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
|
||||
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
|
||||
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/types build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/db build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/storage build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/brain build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/queue build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/config build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/auth build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
|
||||
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
|
||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
|
||||
|
||||
## Push / PR
|
||||
|
||||
- #683 landed in `next`; branch rebased onto `origin/next` before push.
|
||||
- CI is serialized; run queue guard before push.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Installer `--next` fast npm lane — 2026-06-25
|
||||
|
||||
## Scope
|
||||
|
||||
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
|
||||
|
||||
## Registry reality check
|
||||
|
||||
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
|
||||
|
||||
Verified before implementation:
|
||||
|
||||
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
|
||||
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
|
||||
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
|
||||
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
|
||||
|
||||
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `--next` with no explicit ref:
|
||||
1. framework archive from `next`;
|
||||
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
|
||||
3. require both resolved versions to share the same `next.<pipeline>` suffix;
|
||||
4. install the exact resolved package versions;
|
||||
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
|
||||
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
|
||||
- `--dev` remains pure source build.
|
||||
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
|
||||
|
||||
## Install detail
|
||||
|
||||
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
|
||||
|
||||
## Verification notes
|
||||
|
||||
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
|
||||
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
|
||||
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Scratchpad — installer `--next` lane
|
||||
|
||||
## Objective
|
||||
|
||||
Add a prerelease installer lane for the permanent `next` integration branch.
|
||||
|
||||
## Scope
|
||||
|
||||
- `tools/install.sh`
|
||||
- README/install documentation
|
||||
- Follow-up design note for future npm `@next` prerelease publishing
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
|
||||
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
|
||||
3. Update installer source display/help text.
|
||||
4. Document three lanes:
|
||||
- stable npm `@latest`
|
||||
- prerelease `--next`
|
||||
- contributor `--dev --ref X`
|
||||
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n tools/install.sh` — pass.
|
||||
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
|
||||
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
|
||||
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
|
||||
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
|
||||
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
|
||||
- `pnpm typecheck` — pass (41 successful tasks).
|
||||
- `pnpm lint` — pass (23 successful tasks).
|
||||
- `pnpm format:check` — pass.
|
||||
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
|
||||
@@ -0,0 +1,93 @@
|
||||
# W-B — Measure Pi's real tool registry
|
||||
|
||||
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
|
||||
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
|
||||
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
|
||||
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
|
||||
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
|
||||
|
||||
## Method
|
||||
|
||||
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
|
||||
|
||||
```text
|
||||
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
|
||||
--no-context-files --no-skills --no-prompt-templates --no-extensions \
|
||||
-e /tmp/measure-pi-tool-registry.ts \
|
||||
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
|
||||
```
|
||||
|
||||
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
|
||||
|
||||
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
|
||||
|
||||
## Exact distinct built-in set
|
||||
|
||||
The installed Pi built-in registry is exactly:
|
||||
|
||||
```text
|
||||
{bash, edit, find, grep, ls, read, write}
|
||||
```
|
||||
|
||||
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
|
||||
| --- | --- | --- | --- |
|
||||
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
|
||||
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
|
||||
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
|
||||
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
|
||||
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
|
||||
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
|
||||
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
|
||||
|
||||
The raw distinct `event.toolName` result was:
|
||||
|
||||
```json
|
||||
["bash", "edit", "find", "grep", "ls", "read", "write"]
|
||||
```
|
||||
|
||||
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
|
||||
|
||||
## Positive control
|
||||
|
||||
The known `read` tool was the control. The method surfaced it twice:
|
||||
|
||||
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
|
||||
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
|
||||
|
||||
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
|
||||
|
||||
## Carve-out comparison and collision result
|
||||
|
||||
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
|
||||
|
||||
- `read`: real built-in.
|
||||
- `grep`: real built-in.
|
||||
- `find`: real built-in.
|
||||
- `ls`: real built-in.
|
||||
|
||||
All four carve-out names are exact, case-sensitive Pi tool names.
|
||||
|
||||
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
|
||||
|
||||
```text
|
||||
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
|
||||
```
|
||||
|
||||
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
|
||||
|
||||
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- `pi --version` → `0.84.1`.
|
||||
- Isolated probe exit → `0`.
|
||||
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
|
||||
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
|
||||
- Hook counts → exactly one call for each of the seven names.
|
||||
- Mutation artifact after `write` then `edit` → exact content `after`.
|
||||
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
|
||||
|
||||
## Risks / limitations
|
||||
|
||||
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
|
||||
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
|
||||
@@ -0,0 +1,99 @@
|
||||
# PR merge squash message field
|
||||
|
||||
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Branch:** `fix/pr-merge-message-field`
|
||||
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||
|
||||
## Objective
|
||||
|
||||
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||
|
||||
## Binding requirements
|
||||
|
||||
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||
|
||||
## Derived interface decisions
|
||||
|
||||
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||
|
||||
## Canonical delivery plan
|
||||
|
||||
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||
|
||||
## Remediation and current review state
|
||||
|
||||
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||
|
||||
## Disposable provider fixture acceptance
|
||||
|
||||
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||
|
||||
## Fixture preflight
|
||||
|
||||
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||
|
||||
## Fixture result
|
||||
|
||||
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||
- Trailer-shaped lines, verbatim and in order:
|
||||
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||
|
||||
## Current hold point
|
||||
|
||||
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||
|
||||
## Security review 96 remediation
|
||||
|
||||
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||
|
||||
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||
|
||||
Security remediation:
|
||||
|
||||
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||
|
||||
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||
@@ -0,0 +1,58 @@
|
||||
# RM-01 — Reproducible checkout
|
||||
|
||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||
- Plan:
|
||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||
|
||||
## Checkpoint evidence (c45e5e19)
|
||||
|
||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||
|
||||
## Continuation evidence
|
||||
|
||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||
@@ -0,0 +1,120 @@
|
||||
# RM-03 — CI Queue Guard Repair
|
||||
|
||||
- **Task:** RM-03
|
||||
- **Issue:** #1019
|
||||
- **Branch:** `fix/rm-03-queue-guard`
|
||||
- **Owner:** coder-mos1
|
||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||
- **Started:** 2026-08-01
|
||||
|
||||
## Objective
|
||||
|
||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||
- No bypass flags or hook suppression.
|
||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||
- No merge: coordinator holds the merge hand pending Jason.
|
||||
|
||||
## Design
|
||||
|
||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Required outcome |
|
||||
| --- | --- |
|
||||
| success | exit 0; terminal-success |
|
||||
| pending | nonzero after bounded timeout |
|
||||
| failure | nonzero |
|
||||
| no-status | nonzero |
|
||||
| malformed | nonzero |
|
||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||
| audit unavailable | nonzero |
|
||||
| implicit push branch | provider URL uses checked-out feature branch |
|
||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||
|
||||
## RED-first evidence
|
||||
|
||||
Observed against the unmodified `origin/main` implementation before source edits:
|
||||
|
||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||
- Success payload was reported `state=unknown`.
|
||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||
- Audit-unavailable emitted no audit diagnostic.
|
||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||
|
||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||
- [x] Isolated worktree created and identity configured coherently.
|
||||
- [x] Mutant tests authored and observed red.
|
||||
- [x] Implementation green.
|
||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||
- [ ] Merge-gate verdict issued against frozen head.
|
||||
|
||||
## Scope disposition
|
||||
|
||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||
|
||||
## Review remediation
|
||||
|
||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||
|
||||
## Test evidence
|
||||
|
||||
Fresh after rescue checkpoint `b7175012`:
|
||||
|
||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||
- `bash -n` on the three production shell scripts passed.
|
||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||
- `pnpm format:check` passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Pending.
|
||||
+7
-3
@@ -6,11 +6,15 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "husky"
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -11,9 +11,37 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
import type { SelectOption } from '../../src/prompter/interface.js';
|
||||
import type { MenuSection, WizardState } from '../../src/types.js';
|
||||
|
||||
const gatewayConfigMock = vi.fn();
|
||||
const gatewayBootstrapMock = vi.fn();
|
||||
const providerSetupMock = vi.fn();
|
||||
const skillsSelectMock = vi.fn();
|
||||
|
||||
class SequencedMenuPrompter extends HeadlessPrompter {
|
||||
constructor(
|
||||
answers: Record<string, string | boolean | string[]>,
|
||||
private readonly menuChoices: string[],
|
||||
) {
|
||||
super(answers);
|
||||
}
|
||||
|
||||
override async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
if (opts.message === 'What would you like to configure?') {
|
||||
const next = this.menuChoices.shift();
|
||||
if (!next) throw new Error('No queued menu choice left');
|
||||
const match = opts.options.find((o) => String(o.value) === next);
|
||||
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
|
||||
return match.value;
|
||||
}
|
||||
return super.select(opts);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/provider-setup.js', () => ({
|
||||
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/skills-select.js', () => ({
|
||||
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
}
|
||||
gatewayConfigMock.mockReset();
|
||||
gatewayBootstrapMock.mockReset();
|
||||
providerSetupMock.mockReset();
|
||||
skillsSelectMock.mockReset();
|
||||
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.providerType = 'none';
|
||||
state.completedSections?.add('providers' satisfies MenuSection);
|
||||
});
|
||||
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.selectedSkills = [];
|
||||
state.completedSections?.add('skills' satisfies MenuSection);
|
||||
});
|
||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||
// branch does not call `process.exit(1)` during these tests.
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||
it('prints the success summary only after gateway health succeeds', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.log('Gateway is healthy.');
|
||||
return { ready: true, host: 'localhost', port: 14242 };
|
||||
});
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
|
||||
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
|
||||
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
|
||||
|
||||
expect(healthIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(summaryIndex).toBeGreaterThan(healthIndex);
|
||||
expect(readyIndex).toBeGreaterThan(summaryIndex);
|
||||
});
|
||||
|
||||
it('does not claim success when gateway health reports not ready', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
||||
return { ready: false };
|
||||
});
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-run completed provider or skills menu steps', async () => {
|
||||
const prompter = new SequencedMenuPrompter(
|
||||
{
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
},
|
||||
['providers', 'providers', 'skills', 'skills', 'finish'],
|
||||
);
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(providerSetupMock).toHaveBeenCalledTimes(1);
|
||||
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
|
||||
expect(prompter.getLogs()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Providers [done] is already complete; skipping.'),
|
||||
expect.stringContaining('Skills [done] is already complete; skipping.'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,16 @@ The installer:
|
||||
- Runs a health audit
|
||||
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
|
||||
|
||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
|
||||
|
||||
## First Run
|
||||
|
||||
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
||||
@@ -108,8 +118,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
||||
├── STANDARDS.md ← Machine-wide standards
|
||||
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
||||
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
|
||||
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
||||
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
|
||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||
@@ -174,7 +184,9 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
@@ -184,10 +196,11 @@ The installer rejects unrecognized flags or positional arguments before making c
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||
|
||||
```bash
|
||||
mosaic sync # Full canonical catalog sync
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
mosaic sync # Full canonical catalog sync
|
||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
```
|
||||
|
||||
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||
@@ -197,8 +210,8 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
||||
## Health Audit
|
||||
|
||||
```bash
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||
```
|
||||
|
||||
## MCP Registration
|
||||
@@ -209,8 +222,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
|
||||
To verify or re-register manually:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
||||
```
|
||||
|
||||
### Claude Code MCP Registration
|
||||
|
||||
@@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path.
|
||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
||||
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||
commit binding is a hard refusal. The verifier's sole interim
|
||||
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||
sanctioned merge path.
|
||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||
and `pr-ci-wait.sh`.
|
||||
|
||||
@@ -868,6 +868,38 @@ steps:
|
||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||
|
||||
## Terminal-Green Full-Step Contract
|
||||
|
||||
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||
|
||||
```bash
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||
--expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||
|
||||
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||
|
||||
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||
|
||||
Only this exact conjunction is exempted:
|
||||
|
||||
- pipeline and workflow state are `success`;
|
||||
- exactly one non-success child exists;
|
||||
- its name is `ci-postgres` and type is `service`;
|
||||
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||
|
||||
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||
|
||||
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||
|
||||
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||
|
||||
## Post-Merge CI Monitoring (Hard Rule)
|
||||
|
||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||
@@ -893,14 +925,16 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- Wait for terminal CI status:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge)
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Mosaic lease promotion was processed mechanically; no action is needed.
|
||||
@@ -32,6 +32,18 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -81,8 +93,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||
"timeout": 3
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -233,6 +233,14 @@ for runtime_file in \
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
done
|
||||
|
||||
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
|
||||
mkdir -p "$HOME/.claude/commands"
|
||||
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
|
||||
[[ -f "$command_file" ]] || continue
|
||||
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
|
||||
done
|
||||
fi
|
||||
|
||||
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
||||
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
||||
if [[ -f "$opencode_adapter" ]]; then
|
||||
|
||||
@@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
||||
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
||||
# commands/launch.js):
|
||||
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
||||
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
||||
# codex CODEX_HOME -> <home>/skills
|
||||
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
||||
link_targets=(
|
||||
"$MOSAIC_HOME/.claude/skills"
|
||||
"$MOSAIC_HOME/.codex/skills"
|
||||
"$MOSAIC_HOME/.opencode/opencode/skills"
|
||||
"$MOSAIC_HOME/.pi/skills"
|
||||
)
|
||||
|
||||
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
||||
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
||||
# they persist and make a "clean" base install look mosaic-managed.
|
||||
legacy_link_targets=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
@@ -245,13 +262,72 @@ prune_stale_links_in_target() {
|
||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||
# is unproven and the link must be preserved.
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
||||
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
|
||||
# matches every absolute path. Combined with the is_mosaic_skill_name skip
|
||||
# above, that inverts the function precisely — it would delete exactly the
|
||||
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
|
||||
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
}
|
||||
|
||||
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
||||
#
|
||||
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
||||
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
||||
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
||||
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
||||
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
||||
#
|
||||
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
||||
# missing, and an empty dir is the correct end state, not an absent one.
|
||||
cleanup_legacy_target() {
|
||||
local target_dir="$1"
|
||||
local removed=0 kept=0
|
||||
|
||||
[[ -d "$target_dir" ]] || return 0
|
||||
|
||||
while IFS= read -r -d '' link_path; do
|
||||
local resolved owned=0
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
|
||||
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
||||
# match every absolute path and delete foreign links.
|
||||
if [[ -n "$resolved" ]]; then
|
||||
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
owned=1
|
||||
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
||||
owned=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $owned -eq 1 ]]; then
|
||||
rm -f "$link_path"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
kept=$((kept + 1))
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
|
||||
if [[ $removed -gt 0 ]]; then
|
||||
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
||||
fi
|
||||
}
|
||||
|
||||
for legacy in "${legacy_link_targets[@]}"; do
|
||||
# Skip anything that is also a current target, so isolation can never
|
||||
# self-destruct if the two lists ever overlap.
|
||||
skip=0
|
||||
for target in "${link_targets[@]}"; do
|
||||
[[ "$legacy" == "$target" ]] && skip=1
|
||||
done
|
||||
[[ $skip -eq 1 ]] && continue
|
||||
cleanup_legacy_target "$legacy"
|
||||
done
|
||||
|
||||
for target in "${link_targets[@]}"; do
|
||||
mkdir -p "$target"
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH="main"
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -27,63 +31,65 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
python3 - <<'PY'
|
||||
# Python source comes from -c so the provider payload remains on stdin.
|
||||
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
'
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 - <<'PY'
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -104,17 +110,61 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
local work_root status_file checks_file
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||
mkdir -p "$work_root" || return 1
|
||||
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||
rm -f "$status_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$status_file" "$checks_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
status_pages = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
check_pages = json.load(handle)
|
||||
|
||||
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||
# Keep only the newest entry per context after flattening every page.
|
||||
combined = []
|
||||
seen_contexts = set()
|
||||
for page in status_pages:
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
for status in page:
|
||||
if not isinstance(status, dict):
|
||||
raise SystemExit(1)
|
||||
context = status.get("context")
|
||||
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||
continue
|
||||
seen_contexts.add(context)
|
||||
combined.append(status)
|
||||
|
||||
check_runs = []
|
||||
reported_total = 0
|
||||
for page in check_pages:
|
||||
if not isinstance(page, dict):
|
||||
raise SystemExit(1)
|
||||
page_runs = page.get("check_runs") or []
|
||||
total_count = page.get("total_count")
|
||||
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||
raise SystemExit(1)
|
||||
reported_total = max(reported_total, total_count)
|
||||
check_runs.extend(page_runs)
|
||||
if len(check_runs) < reported_total:
|
||||
raise SystemExit(1)
|
||||
|
||||
for run in check_runs:
|
||||
if not isinstance(run, dict):
|
||||
raise SystemExit(1)
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
value = "pending"
|
||||
elif conclusion == "success":
|
||||
value = "success"
|
||||
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||
value = "failure"
|
||||
else:
|
||||
value = "unknown"
|
||||
combined.append({
|
||||
"context": run.get("name") or "github-check",
|
||||
"status": value,
|
||||
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||
})
|
||||
|
||||
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||
PY
|
||||
local status=$?
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
else
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -271,21 +459,24 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
fi
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
exit 3
|
||||
;;
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,10 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,15 +27,18 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -53,15 +58,30 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--co-author-trailers)
|
||||
CO_AUTHOR_TRAILERS=true
|
||||
shift
|
||||
;;
|
||||
--escalate-to)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --escalate-to requires one principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
ESCALATE_TO="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -86,18 +106,49 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") 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" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$BASE_BRANCH" \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -106,79 +157,442 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
write_curl_auth_config() {
|
||||
local mode="$1" credential="$2"
|
||||
printf '%s' "$credential" | python3 -c '
|
||||
import sys
|
||||
mode = sys.argv[1]
|
||||
credential = sys.stdin.read()
|
||||
if not credential or any(char in credential for char in "\r\n"):
|
||||
raise SystemExit(1)
|
||||
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
if mode == "token":
|
||||
print(f"header = \"Authorization: token {escaped}\"")
|
||||
elif mode == "basic":
|
||||
print(f"user = \"{escaped}\"")
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
' "$mode"
|
||||
}
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
MERGE_TEMP_DIRS=()
|
||||
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
GITEA_CURL_BOUNDS=(
|
||||
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||
--max-time "$GITEA_CURL_MAX_TIME"
|
||||
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||
)
|
||||
|
||||
format_gitea_error_response() {
|
||||
local response_file="$1"
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
with open(sys.argv[1], "rb") as handle:
|
||||
raw = handle.read(65536)
|
||||
try:
|
||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
message = "non-JSON response omitted"
|
||||
else:
|
||||
if isinstance(response, dict):
|
||||
message = response.get("message") or response.get("error")
|
||||
if not message and response.get("errors") is not None:
|
||||
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||
else:
|
||||
message = None
|
||||
if not message:
|
||||
message = "JSON response contained no error message"
|
||||
message = str(message)
|
||||
if len(message) > 500:
|
||||
message = message[:500] + "..."
|
||||
print(ascii(message))
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload='{"Do":"squash"}'
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||
done
|
||||
}
|
||||
trap cleanup_merge_temp_dirs EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
fetch_gitea_pr_head() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local response_file raw_code api_url auth_config curl_rc
|
||||
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-u "$basic_auth" \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
if ! python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
pull = json.load(handle)
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
raise SystemExit(1)
|
||||
print(sha)
|
||||
PY
|
||||
then
|
||||
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$response_file"
|
||||
}
|
||||
|
||||
fetch_gitea_pr_commits() {
|
||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
||||
mkdir -p "$work_root"
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
return 1
|
||||
fi
|
||||
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
||||
printf '[]' > "$combined_file"
|
||||
|
||||
page=1
|
||||
while true; do
|
||||
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
||||
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
code, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
raw = handle.read(500)
|
||||
data = json.loads(raw) if raw else {}
|
||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||
except Exception:
|
||||
try:
|
||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
||||
except Exception:
|
||||
message = "unreadable response"
|
||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
page = json.load(handle)
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
rm -f "$body_file"
|
||||
); then
|
||||
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
|
||||
rm -f "$page_file" "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
|
||||
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
combined = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
page = json.load(handle)
|
||||
json.dump(combined + page, sys.stdout, separators=(",", ":"))
|
||||
PY
|
||||
then
|
||||
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
|
||||
rm -f "$page_file" "$combined_file" "$merged_file"
|
||||
return 1
|
||||
fi
|
||||
mv "$merged_file" "$combined_file"
|
||||
rm -f "$page_file"
|
||||
|
||||
if [[ "$page_count" -lt 50 ]]; then
|
||||
break
|
||||
fi
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
|
||||
rm -f "$combined_file"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
cat "$combined_file"
|
||||
rm -f "$combined_file"
|
||||
}
|
||||
|
||||
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
|
||||
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
|
||||
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
|
||||
build_coauthor_message_fields() {
|
||||
local commits_file="$1" context_file="$2" head_file="$3"
|
||||
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
commits_path, context_path, head_path = sys.argv[1:]
|
||||
with open(commits_path, encoding="utf-8") as handle:
|
||||
commits = json.load(handle)
|
||||
head_sha = open(head_path, encoding="utf-8").read().strip()
|
||||
context_parts = open(context_path, "rb").read().split(b"\0")
|
||||
if len(context_parts) != 4 or context_parts[-1] != b"":
|
||||
raise SystemExit(1)
|
||||
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
|
||||
|
||||
if not isinstance(commits, list) or not commits:
|
||||
print(
|
||||
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
|
||||
f"Refusing merge; escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not poster:
|
||||
print(
|
||||
f"BLOCK: PR poster login is empty; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
|
||||
print(
|
||||
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
seen = set()
|
||||
trailers = []
|
||||
head_seen = False
|
||||
for item in commits:
|
||||
if not isinstance(item, dict):
|
||||
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
|
||||
raise SystemExit(75)
|
||||
sha = str(item.get("sha") or "<unknown>")
|
||||
if sha == head_sha:
|
||||
head_seen = True
|
||||
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
|
||||
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||
email = str(commit_author.get("email") or "").strip()
|
||||
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
|
||||
login = str(provider_author.get("login") or "").strip()
|
||||
|
||||
if not login:
|
||||
diagnostic_email = email or "<missing>"
|
||||
print(
|
||||
f"BLOCK: commit {sha!r} has author.login=NULL while "
|
||||
f"commit.author.email={diagnostic_email!r}; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if (
|
||||
not email.isascii()
|
||||
or not email.isprintable()
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
|
||||
):
|
||||
print(
|
||||
f"BLOCK: commit {sha!r} has unusable linked identity "
|
||||
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if login == poster or login in seen:
|
||||
continue
|
||||
seen.add(login)
|
||||
trailers.append(f"Co-authored-by: {login} <{email}>")
|
||||
|
||||
if not head_seen:
|
||||
print(
|
||||
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not trailers:
|
||||
print("{}")
|
||||
raise SystemExit(0)
|
||||
if not title:
|
||||
print(
|
||||
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
|
||||
print(
|
||||
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
|
||||
f"escalate to named principal '{principal}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(75)
|
||||
|
||||
print(json.dumps({
|
||||
"MergeTitleField": title,
|
||||
"MergeMessageField": "\n".join(trailers),
|
||||
}, separators=(",", ":")))
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_api_attempt() {
|
||||
local host="$1" auth_mode="$2" credential="$3"
|
||||
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
mkdir -p "$work_root"
|
||||
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
||||
chmod 0700 "$attempt_dir"
|
||||
MERGE_TEMP_DIRS+=("$attempt_dir")
|
||||
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
|
||||
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
|
||||
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
|
||||
printf '{}' > "$fields_file"
|
||||
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
|
||||
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
|
||||
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
|
||||
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
|
||||
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
|
||||
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return 75
|
||||
fi
|
||||
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
|
||||
:
|
||||
else
|
||||
attempt_rc=$?
|
||||
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||
return "$attempt_rc"
|
||||
fi
|
||||
rm -f "$commits_file" "$context_file" "$head_file"
|
||||
fi
|
||||
|
||||
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
fields = json.load(handle)
|
||||
head_sha, delete_branch = sys.argv[2:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
payload.update(fields)
|
||||
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
|
||||
if payload.get("Do") != "squash" or set(payload) - allowed:
|
||||
raise SystemExit(1)
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
then
|
||||
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$fields_file"
|
||||
|
||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||
rm -f "$body_file" "$payload_file"
|
||||
return 1
|
||||
fi
|
||||
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
|
||||
-X POST -H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
|
||||
curl_rc=$?
|
||||
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||
if [[ "$curl_rc" -ne 0 ]]; then
|
||||
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||
rm -f "$body_file" "$payload_file"
|
||||
rm -rf -- "$attempt_dir"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
|
||||
fi
|
||||
rm -f "$body_file" "$payload_file"
|
||||
rm -rf -- "$attempt_dir"
|
||||
[[ "$raw_code" =~ ^2 ]]
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" token attempt_rc
|
||||
|
||||
if ! token=$(get_gitea_token "$host"); then
|
||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$token" ]]; then
|
||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||
return 0
|
||||
else
|
||||
attempt_rc=$?
|
||||
fi
|
||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||
return 75
|
||||
fi
|
||||
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -188,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||
fi
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||
@@ -202,7 +615,11 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -211,32 +628,9 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -207,6 +226,8 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
endpoint=""
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||
done
|
||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||
case "$endpoint" in
|
||||
repos/acme/widgets/branches/fix/github-checks)
|
||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||
printf '%s\n' '[[]]'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||
WATCHDOG_TIMEOUT_SEC=5
|
||||
WATCHDOG_EXIT=90
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
raise SystemExit(2)
|
||||
|
||||
timeout_seconds = float(sys.argv[1])
|
||||
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
print(
|
||||
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||
"before completing its intended path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(90)
|
||||
|
||||
if return_code < 0:
|
||||
raise SystemExit(128 - return_code)
|
||||
raise SystemExit(return_code)
|
||||
PY
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
has_write_out=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_write_out=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||
while :; do :; done
|
||||
fi
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_write_out" -eq 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/date" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||
echo "unexpected date invocation: $*" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1002\n'
|
||||
else
|
||||
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
printf '1000\n'
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$STUB_DIR/sleep" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
local audit_log="${2:-$AUDIT_LOG}"
|
||||
shift 2 || true
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||
export MOSAIC_STUB_STATUS_MODE=success
|
||||
else
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
# Provider observation is the synchronization event. The one-second
|
||||
# timeout is subject semantics under virtual time, never a wall wait.
|
||||
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||
# the subject's isolated process group. Neither operation can resolve
|
||||
# to the virtual date/sleep stubs at the front of PATH.
|
||||
local subject_rc
|
||||
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||
subject_rc=0
|
||||
else
|
||||
subject_rc=$?
|
||||
fi
|
||||
return "$subject_rc"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_provider_observed() {
|
||||
local name="$1" require_expiration="${2:-0}"
|
||||
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL $name: status provider was not observed" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$require_expiration" -eq 1 ]]; then
|
||||
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
shift 4
|
||||
set +e
|
||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
case "$expected_rc" in
|
||||
zero)
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
nonzero)
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
not126)
|
||||
if [[ "$rc" -eq 126 ]]; then
|
||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ "$output" != *"$required_text"* ]]; then
|
||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||
if [[ "$status_mode" == "pending" ]]; then
|
||||
assert_provider_observed "$name" 1
|
||||
else
|
||||
assert_provider_observed "$name"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||
# can reach the status provider. Only the independent real-clock watchdog may
|
||||
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||
set +e
|
||||
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||
watchdog_rc=$?
|
||||
set -e
|
||||
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||
printf '%s\n' "$watchdog_output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
set +e
|
||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||
merge_unreachable_rc=$?
|
||||
set -e
|
||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed merge-provider-unreachable
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||
set +e
|
||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||
unsupported_rc=$?
|
||||
set -e
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||
mkdir -p "$WORK_DIR/not-a-directory"
|
||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||
set +e
|
||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||
audit_failure_rc=$?
|
||||
set -e
|
||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
assert_provider_observed audit-unavailable
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$prev" == "-d" ]]; then
|
||||
if [[ "$prev" == "data" ]]; then
|
||||
post_data="$arg"
|
||||
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-o" ]]; then
|
||||
prev="-o"
|
||||
if [[ "$prev" == "config" ]]; then
|
||||
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-d" ]]; then
|
||||
prev="-d"
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-w" ]]; then
|
||||
write_code=true
|
||||
fi
|
||||
case "$arg" in
|
||||
-o) prev="-o" ;;
|
||||
-d|--data|--data-binary) prev="data" ;;
|
||||
-K|--config) prev="config" ;;
|
||||
-w) write_code=true ;;
|
||||
esac
|
||||
done
|
||||
emit_response() {
|
||||
local body="$1"
|
||||
@@ -79,15 +80,24 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,8 +117,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -127,38 +137,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -177,8 +155,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -215,7 +193,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -240,7 +218,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -260,4 +238,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||
SHA=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
make_fixture() {
|
||||
local name="$1" remote="$2"
|
||||
local root="$WORK_DIR/$name"
|
||||
local tools="$root/tools/git"
|
||||
mkdir -p "$tools" "$root/repo"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tools"/*.sh
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
out_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-d|--data|--data-binary)
|
||||
payload="$2"
|
||||
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
|
||||
shift 2
|
||||
;;
|
||||
-o)
|
||||
out_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-K|--config)
|
||||
[[ "$2" == "-" ]] && cat >/dev/null
|
||||
shift 2
|
||||
;;
|
||||
-w|-X|-H)
|
||||
shift 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/gitea.out" 2>&1
|
||||
gitea_rc=$?
|
||||
set -e
|
||||
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||
cat "$WORK_DIR/gitea.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||
assert payload.get("Do") == "squash", payload
|
||||
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||
PY
|
||||
|
||||
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||
stale_rc=$?
|
||||
set -e
|
||||
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 99
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||
bypass_rc=$?
|
||||
set -e
|
||||
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dry-run is the only path that may omit the guard because it exits before the
|
||||
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||
SH
|
||||
chmod +x "$WORK_DIR/github/gh"
|
||||
(
|
||||
cd "$WORK_DIR/github/repo"
|
||||
export PATH="$WORK_DIR/github:$PATH"
|
||||
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/github.out" 2>&1
|
||||
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||
cat "$WORK_DIR/github-call.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||
ORIG_PATH="$PATH"
|
||||
failures=0
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
fail() {
|
||||
echo "FAIL $1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
make_case() {
|
||||
local name="$1" case_dir
|
||||
case_dir="$WORK_DIR/$name"
|
||||
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||
chmod +x "$case_dir/pr-merge.sh"
|
||||
|
||||
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||
get_repo_owner() { printf 'acme\n'; }
|
||||
get_repo_name() { printf 'widgets\n'; }
|
||||
get_remote_host() { printf 'git.example.test\n'; }
|
||||
get_gitea_token() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
printf 'fixture-token\n'
|
||||
}
|
||||
get_gitea_basic_auth() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||
printf 'fixture-user:fixture-password\n'
|
||||
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
get_gitea_login_for_host() { return 1; }
|
||||
SH
|
||||
|
||||
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||
else
|
||||
title='Preserve both branch authors'
|
||||
fi
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/python3" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
url=""
|
||||
method="GET"
|
||||
out_file=""
|
||||
data=""
|
||||
config=""
|
||||
auth_mode="none"
|
||||
has_max_filesize=0
|
||||
has_max_time=0
|
||||
has_connect_timeout=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
out_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data|--data-binary)
|
||||
data="$2"
|
||||
if [[ "$data" == @* ]]; then
|
||||
data=$(<"${data#@}")
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-K|--config)
|
||||
if [[ "$2" == "-" ]]; then
|
||||
config=$(cat)
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--max-filesize)
|
||||
has_max_filesize=1
|
||||
shift 2
|
||||
;;
|
||||
--max-time)
|
||||
has_max_time=1
|
||||
shift 2
|
||||
;;
|
||||
--connect-timeout)
|
||||
has_connect_timeout=1
|
||||
shift 2
|
||||
;;
|
||||
-H|--header|-u|--user)
|
||||
if [[ "$2" == *"fixture-token"* ]]; then
|
||||
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||
fi
|
||||
if [[ "$2" == *"fixture-password"* ]]; then
|
||||
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||
auth_mode="token"
|
||||
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||
auth_mode="basic"
|
||||
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||
fi
|
||||
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/pulls/42)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||
head_sha=4444444444444444444444444444444444444444
|
||||
fi
|
||||
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/commits*)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified)
|
||||
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
else
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
fi
|
||||
;;
|
||||
null-login)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||
;;
|
||||
unsafe-identity)
|
||||
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
single)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
*)
|
||||
echo "unknown commits mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/merge)
|
||||
body='{}'
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||
code=409
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||
body='{"message":"permission denied"}'
|
||||
code=403
|
||||
else
|
||||
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||
fi
|
||||
;;
|
||||
*/users/*)
|
||||
body='{"message":"not found"}'
|
||||
code=404
|
||||
;;
|
||||
*)
|
||||
body='{"message":"unexpected URL"}'
|
||||
code=500
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$out_file" ]]; then
|
||||
printf '%s' "$body" > "$out_file"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
printf '%s' "$code"
|
||||
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||
oversize) exit 63 ;;
|
||||
stalled) exit 28 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||
printf '%s\n' "$case_dir"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local case_dir="$1" mode="$2"
|
||||
shift 2
|
||||
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||
}
|
||||
|
||||
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||
# linked author.login and that same commit's author email. No /users lookup.
|
||||
verified_dir=$(make_case verified)
|
||||
set +e
|
||||
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
verified_rc=$?
|
||||
set -e
|
||||
if [[ "$verified_rc" -ne 0 ]]; then
|
||||
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||
fail "verified multi-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||
"MergeTitleField": "Preserve both branch authors",
|
||||
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||
fail "verified path performed a forbidden second /users lookup"
|
||||
fi
|
||||
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||
fi
|
||||
|
||||
# A linked email containing a terminal escape must block before mutation.
|
||||
escape_email_dir=$(make_case escape-email)
|
||||
set +e
|
||||
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
escape_email_rc=$?
|
||||
set -e
|
||||
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||
|
||||
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||
for failure_mode in oversize stalled; do
|
||||
failure_dir=$(make_case "curl-$failure_mode")
|
||||
set +e
|
||||
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
failure_rc=$?
|
||||
set -e
|
||||
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||
done
|
||||
|
||||
# The authenticated head is re-read under the mutation credential but cannot
|
||||
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||
# or mutation even though the provider returned a valid new SHA.
|
||||
moved_dir=$(make_case moved-head)
|
||||
set +e
|
||||
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
moved_rc=$?
|
||||
set -e
|
||||
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||
|
||||
# Token resolution failure is not an authentication response. It must fail
|
||||
# closed instead of borrowing a Basic credential under a different principal.
|
||||
token_missing_dir=$(make_case token-missing)
|
||||
set +e
|
||||
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||
run_case "$token_missing_dir" single 2>&1)
|
||||
token_missing_rc=$?
|
||||
set -e
|
||||
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||
|
||||
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||
set +e
|
||||
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
basic_rc_rc=$?
|
||||
set -e
|
||||
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||
|
||||
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||
# resolving or attempting Basic Auth.
|
||||
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||
set +e
|
||||
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_inspect_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||
|
||||
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||
fallback_merge_dir=$(make_case fallback-merge)
|
||||
set +e
|
||||
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_merge_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||
|
||||
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||
null_dir=$(make_case null-login)
|
||||
set +e
|
||||
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
null_rc=$?
|
||||
set -e
|
||||
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||
|
||||
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||
# including an invalid non-head SHA that contains control characters.
|
||||
unsafe_dir=$(make_case unsafe-identity)
|
||||
set +e
|
||||
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
unsafe_rc=$?
|
||||
set -e
|
||||
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||
|
||||
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||
title_dir=$(make_case title-injection)
|
||||
set +e
|
||||
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
title_rc=$?
|
||||
set -e
|
||||
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||
|
||||
# Provider failures remain diagnosable after their temporary response file is
|
||||
# removed, but provider-controlled control characters stay log-safe.
|
||||
error_dir=$(make_case provider-error)
|
||||
set +e
|
||||
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||
run_case "$error_dir" single 2>&1)
|
||||
error_rc=$?
|
||||
set -e
|
||||
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# Authorization denials likewise fail closed instead of changing principals.
|
||||
forbidden_dir=$(make_case forbidden)
|
||||
set +e
|
||||
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||
run_case "$forbidden_dir" single 2>&1)
|
||||
forbidden_rc=$?
|
||||
set -e
|
||||
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||
# without a named principal is refused before any provider operation.
|
||||
principal_dir=$(make_case missing-principal)
|
||||
set +e
|
||||
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||
principal_rc=$?
|
||||
set -e
|
||||
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||
|
||||
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||
# set -u unbound-variable crash.
|
||||
value_dir=$(make_case missing-principal-value)
|
||||
set +e
|
||||
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||
value_rc=$?
|
||||
set -e
|
||||
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||
|
||||
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||
# compatible and hardcoded to squash, with no optional message fields.
|
||||
single_dir=$(make_case single)
|
||||
set +e
|
||||
single_output=$(run_case "$single_dir" single 2>&1)
|
||||
single_rc=$?
|
||||
set -e
|
||||
if [[ "$single_rc" -ne 0 ]]; then
|
||||
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||
fail "ordinary single-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||
|
||||
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||
method_dir=$(make_case method-refusal)
|
||||
set +e
|
||||
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||
method_rc=$?
|
||||
set -e
|
||||
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
|
||||
MAX_STATE: Final = 4 * 1024 * 1024
|
||||
MAX_PENDING_TOKENS: Final = 256
|
||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||
MAX_LEASE_TTL_SECONDS: Final = 3600
|
||||
STATE_VERSION: Final = 1
|
||||
READ_DEADLINE_SECONDS: Final = 1.0
|
||||
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
||||
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||
LEASE_VERIFIED: Final = "VERIFIED"
|
||||
READ_ONLY_TOOLS: Final = {
|
||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||
"claude": frozenset({"Read", "Grep", "Glob"}),
|
||||
"pi": frozenset({"read", "ls"}),
|
||||
}
|
||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
||||
return value
|
||||
|
||||
|
||||
def _self_starttime() -> str | None:
|
||||
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
||||
|
||||
Read past the comm field's parens, since a process name may contain them.
|
||||
"""
|
||||
try:
|
||||
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
||||
return raw.rsplit(")", 1)[1].split()[19]
|
||||
except (OSError, IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
||||
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
||||
|
||||
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
||||
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
||||
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
||||
asserts.
|
||||
|
||||
Never raises: a launch must not be denied over bookkeeping. But it also never
|
||||
fails silently — a missing record is exactly the kind of gap that made the
|
||||
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
||||
"""
|
||||
try:
|
||||
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
||||
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
framed = {
|
||||
"seq": time.time_ns() // 1_000_000,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
**record,
|
||||
}
|
||||
path = directory / "events.ndjson"
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
with os.fdopen(descriptor, "w") as handle:
|
||||
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
@@ -94,8 +138,9 @@ def main(
|
||||
# silent pass and never folded into the generic registration-failure
|
||||
# branch.
|
||||
try:
|
||||
activation_capability = probe_activation_capability(source_environment)
|
||||
assert_activation_capability_matches(
|
||||
probe_activation_capability(source_environment),
|
||||
activation_capability,
|
||||
expected_activation_capability,
|
||||
)
|
||||
except VersionCouplingError as version_error:
|
||||
@@ -128,6 +173,32 @@ def main(
|
||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
||||
# config/provenance it knows; only this process knows the broker session id
|
||||
# and the activation capability it just asserted. os.execvpe preserves the
|
||||
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
||||
# record. Never fatal — bookkeeping must not deny a launch — but never
|
||||
# silent either.
|
||||
_append_launch_record(
|
||||
source_environment,
|
||||
{
|
||||
"kind": "lease.register",
|
||||
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
||||
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
||||
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
||||
# preserves it), which is a separate and still-useful fact.
|
||||
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
||||
"pid": os.getpid(),
|
||||
"runtime": arguments.runtime,
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"generation_file": str(generation_file),
|
||||
"anchor_starttime": _self_starttime(),
|
||||
"activation_capability": activation_capability,
|
||||
"command": Path(command[0]).name,
|
||||
},
|
||||
)
|
||||
|
||||
environment = dict(source_environment)
|
||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lease promotion client — the half the enforcement toolkit never shipped.
|
||||
|
||||
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
|
||||
promotion half has no production caller anywhere in the package: as of 0.0.48,
|
||||
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
|
||||
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
|
||||
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
|
||||
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
|
||||
gate nothing can satisfy.
|
||||
|
||||
THE PROTOCOL (``daemon.py:578-754``)
|
||||
------------------------------------
|
||||
1. ``begin_verification`` — broker revokes, mints a challenge, and returns the
|
||||
exact ``receipt`` text the MODEL must emit
|
||||
2. *the model emits that text verbatim as its ENTIRE latest message*
|
||||
3. the runtime adapter ships that message to the daemon-owned observer socket
|
||||
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
|
||||
5. ``promote_lease`` -> ``VERIFIED``
|
||||
|
||||
THIS MODULE IMPLEMENTS 1, 4 AND 5 — NEVER 2
|
||||
-------------------------------------------
|
||||
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
|
||||
``hmac.compare_digest`` against the exact minted string — explicitly "not a
|
||||
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
|
||||
live model that received the challenge in its context and echoed it exactly.
|
||||
|
||||
``receipt-observer-client.py`` will post ANY string as the latest assistant
|
||||
message. A promotion client that posted its own receipt would satisfy the broker
|
||||
while proving nothing — a gate-disabler indistinguishable from a working fix
|
||||
unless someone looks for it. **This module never posts a receipt.** Emitting it
|
||||
belongs to the runtime adapter, where a real model turn happens.
|
||||
|
||||
The construction binds the exact normative source bytes. ``h_source`` /
|
||||
``h_payload`` are derived by the framework's own
|
||||
``normative_fragments.build_payload`` rather than reimplemented: the broker
|
||||
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
|
||||
There must be exactly one implementation.
|
||||
|
||||
WHAT THE BINDING DOES *NOT* PROVE
|
||||
---------------------------------
|
||||
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
|
||||
**It does not mean that**, and writing it down that way is how the belief spread.
|
||||
The broker holds no reference copy of any normative source and never opens one;
|
||||
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
|
||||
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
|
||||
Both sides of that comparison originate here, so it detects corruption in
|
||||
transit and nothing else. What the binding actually asserts is "the client
|
||||
claims these bytes, self-consistently".
|
||||
|
||||
Making it mean the stronger thing requires the broker to re-read the on-disk
|
||||
sources itself, against a manifest the agent cannot rewrite — i.e. broker code
|
||||
attestation under its own uid. Until then, do not cite a VERIFIED lease as
|
||||
evidence of law integrity.
|
||||
|
||||
Usage
|
||||
-----
|
||||
lease_promote.py --begin # prints the receipt the MODEL must emit
|
||||
lease_promote.py --complete <challenge> # after the adapter observed it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
# Isolated (`python -I`) adapter invocations must still import co-located
|
||||
# framework modules; never depend on the caller's PYTHONPATH.
|
||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||
if _MODULE_DIRECTORY not in sys.path:
|
||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||
|
||||
from normative_fragments import NormativeFragment, build_payload # noqa: E402
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 3.0
|
||||
SCHEMA_VERSION: Final = 1
|
||||
MANIFEST_VERSION: Final = 1
|
||||
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
|
||||
DEFAULT_TTL_SECONDS: Final = 3600
|
||||
|
||||
# Normative sources whose exact bytes bind the lease, in binding order. Order is
|
||||
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
|
||||
# the derivation. Never fabricate a source that is not on disk.
|
||||
FRAGMENT_SOURCES: Final = (
|
||||
"CONSTITUTION.md",
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"STANDARDS.md",
|
||||
"TOOLS.md",
|
||||
)
|
||||
|
||||
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
|
||||
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
|
||||
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
|
||||
# missing one of these is broken, not minimal, so their absence is refused rather
|
||||
# than silently dropped from the binding.
|
||||
#
|
||||
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
|
||||
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
|
||||
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
|
||||
# only. Absence of those three is reported, not fatal.
|
||||
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
|
||||
|
||||
|
||||
class IncompleteBinding(RuntimeError):
|
||||
"""A source that must bind this lease could not be read.
|
||||
|
||||
**Never downgrade this to a skip.** The broker recomputes the hashes from the
|
||||
fragments it is sent, so an omitted fragment is internally consistent and
|
||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like
|
||||
a complete one, and nothing downstream can tell the difference. Dropping an
|
||||
unreadable source therefore does not degrade the binding, it forges a smaller
|
||||
one. Fail here, where the omission is still visible.
|
||||
"""
|
||||
|
||||
|
||||
def mosaic_home() -> Path:
|
||||
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
|
||||
|
||||
|
||||
def broker_socket() -> Path:
|
||||
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
|
||||
if value:
|
||||
return Path(value)
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if runtime_dir:
|
||||
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
|
||||
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
|
||||
|
||||
|
||||
def session_identity() -> tuple[str, int, str]:
|
||||
"""Session id, CURRENT generation, runtime.
|
||||
|
||||
The generation file wins over the env var, matching ``lease_generation.py``.
|
||||
Sending a generation HIGHER than the broker's would revoke this session's own
|
||||
authority (``daemon.py:342-344``), so this never guesses.
|
||||
"""
|
||||
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
|
||||
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
|
||||
if state_file:
|
||||
try:
|
||||
return session_id, int(Path(state_file).read_text().strip()), runtime
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
|
||||
|
||||
|
||||
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
|
||||
"""Assemble the wire construction and derive its hashes with the sole builder."""
|
||||
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
|
||||
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
|
||||
required = REQUIRED_SOURCES | {runtime_contract}
|
||||
wire_fragments: list[dict[str, str]] = []
|
||||
objects: list[NormativeFragment] = []
|
||||
absent: list[str] = []
|
||||
|
||||
for source_id in sources:
|
||||
try:
|
||||
content = (mosaic_home() / source_id).read_bytes()
|
||||
except FileNotFoundError:
|
||||
# Genuinely not on disk. Legitimate only for operator-owned sources.
|
||||
if source_id in required:
|
||||
raise IncompleteBinding(
|
||||
f"required normative source is absent: {source_id}"
|
||||
) from None
|
||||
absent.append(source_id)
|
||||
continue
|
||||
except OSError as exc:
|
||||
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
|
||||
# That is an anomaly for EVERY source, optional ones included: an
|
||||
# unreadable file is not an un-configured one, and treating it as
|
||||
# absent is what lets a permission change quietly shrink the law.
|
||||
raise IncompleteBinding(
|
||||
f"normative source is present but unreadable: {source_id} "
|
||||
f"({type(exc).__name__})"
|
||||
) from exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
wire_fragments.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"expected_sha256": digest,
|
||||
}
|
||||
)
|
||||
objects.append(NormativeFragment(source_id, content, digest))
|
||||
|
||||
if not wire_fragments:
|
||||
raise IncompleteBinding("no normative sources found — refusing an empty binding")
|
||||
|
||||
# Absence is legitimate here but never invisible. The omission is already
|
||||
# baked into h_source (the framed source sequence differs), but nothing
|
||||
# compares h_source to an expected value, so this line is the only place a
|
||||
# human learns the binding was narrower than the full set.
|
||||
if absent:
|
||||
print(
|
||||
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
result = build_payload(
|
||||
manifest_version=MANIFEST_VERSION,
|
||||
generator_version=GENERATOR_VERSION,
|
||||
fragments=objects,
|
||||
)
|
||||
if result.injectionDecision != "ACCEPTED" or not result.promotion:
|
||||
raise RuntimeError(f"construction refused locally: {result.source_reason}")
|
||||
|
||||
return (
|
||||
{
|
||||
"manifest_version": MANIFEST_VERSION,
|
||||
"generator_version": GENERATOR_VERSION,
|
||||
"fragments": wire_fragments,
|
||||
},
|
||||
result,
|
||||
)
|
||||
|
||||
|
||||
def broker_request(payload: dict[str, object]) -> dict[str, object]:
|
||||
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError(
|
||||
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
|
||||
)
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(broker_socket()))
|
||||
connection.sendall(raw)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid broker reply")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid broker reply")
|
||||
return value
|
||||
|
||||
|
||||
def begin(
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
compaction_epoch: int = 0,
|
||||
request_epoch: int = 0,
|
||||
) -> dict[str, object]:
|
||||
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
|
||||
session_id, generation, runtime = session_identity()
|
||||
construction, derived = build_construction(runtime)
|
||||
return broker_request(
|
||||
{
|
||||
"action": "begin_verification",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"runtime": runtime,
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"binding": {
|
||||
"compaction_epoch": compaction_epoch,
|
||||
"request_epoch": request_epoch,
|
||||
"h_source": derived.h_source,
|
||||
"h_payload": derived.h_payload,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
},
|
||||
"construction": construction,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def complete(challenge: str) -> dict[str, object]:
|
||||
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
|
||||
shipped it to the observer socket."""
|
||||
session_id, generation, _ = session_identity()
|
||||
observed = broker_request(
|
||||
{
|
||||
"action": "observe_receipt",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"receipt_challenge": challenge,
|
||||
}
|
||||
)
|
||||
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
||||
return {"stage": "observe_receipt", **observed}
|
||||
promoted = broker_request(
|
||||
{
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"receipt_challenge": challenge,
|
||||
}
|
||||
)
|
||||
return {"stage": "promote_lease", **promoted}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--begin",
|
||||
action="store_true",
|
||||
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
|
||||
)
|
||||
group.add_argument(
|
||||
"--complete",
|
||||
metavar="CHALLENGE",
|
||||
help="observe the emitted receipt and promote the lease",
|
||||
)
|
||||
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if arguments.begin:
|
||||
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
|
||||
else:
|
||||
print(json.dumps(complete(arguments.complete), indent=2))
|
||||
except KeyError as exc:
|
||||
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
|
||||
return 2
|
||||
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final, TextIO
|
||||
|
||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||
if _MODULE_DIRECTORY not in sys.path:
|
||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||
|
||||
from receipt_challenge import receipt_for # noqa: E402
|
||||
|
||||
_observer_spec = importlib.util.spec_from_file_location(
|
||||
"mosaic_receipt_observer_client", Path(__file__).resolve().with_name("receipt-observer-client.py")
|
||||
)
|
||||
if _observer_spec is None or _observer_spec.loader is None:
|
||||
raise RuntimeError("unable to load receipt observer client")
|
||||
_observer_module = importlib.util.module_from_spec(_observer_spec)
|
||||
_observer_spec.loader.exec_module(_observer_module)
|
||||
observer_request = _observer_module.observer_request
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
|
||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||
PROMOTION_PROMPT: Final = "/mosaic-promote"
|
||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||
AUTHORIZATION_DIRECTORY: Final = "authorizations"
|
||||
AUTHORIZATION_TTL_SECONDS: Final = 60
|
||||
LEASE_TTL_SECONDS: Final = 60 * 60
|
||||
LOCK_FILE: Final = "promotion.lock"
|
||||
RESULT_FILE: Final = "last-result.json"
|
||||
EXPECTED_BEGIN_KEYS: Final = frozenset(
|
||||
{"ok", "state", "receipt_challenge", "receipt", "binding"}
|
||||
)
|
||||
EXPECTED_BINDING_KEYS: Final = frozenset(
|
||||
{
|
||||
"compaction_epoch",
|
||||
"request_epoch",
|
||||
"h_source",
|
||||
"h_payload",
|
||||
"runtime_generation",
|
||||
"schema_version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PromotionAlreadyInProgress(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate promoter JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def read_hook_input(stream: object) -> dict[str, object]:
|
||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||
raise ValueError("invalid UserPromptSubmit input")
|
||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid UserPromptSubmit input")
|
||||
return value
|
||||
|
||||
|
||||
def emit_context(stream: TextIO, message: str) -> None:
|
||||
json.dump(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": message,
|
||||
}
|
||||
},
|
||||
stream,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
if not runtime_dir.is_absolute():
|
||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||
raise ValueError("invalid lease session id")
|
||||
return runtime_dir, f"pending-{session_id}"
|
||||
|
||||
|
||||
def open_pending_directory(runtime_dir: Path) -> int:
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||
try:
|
||||
runtime_metadata = os.fstat(runtime_descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||
or runtime_metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||
):
|
||||
raise ValueError("unsafe XDG runtime directory")
|
||||
try:
|
||||
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
|
||||
except FileExistsError:
|
||||
pass
|
||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||
finally:
|
||||
os.close(runtime_descriptor)
|
||||
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion pending directory")
|
||||
return descriptor
|
||||
|
||||
|
||||
def acquire_lock(directory_descriptor: int) -> int:
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion lock file")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
os.close(descriptor)
|
||||
raise PromotionAlreadyInProgress() from error
|
||||
return descriptor
|
||||
|
||||
|
||||
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
|
||||
cutoff = current_time - PENDING_MAX_AGE_SECONDS
|
||||
removed = False
|
||||
with os.scandir(directory_descriptor) as entries:
|
||||
for candidate in entries:
|
||||
if not (
|
||||
candidate.name.startswith("pending-")
|
||||
or candidate.name.startswith(".pending-")
|
||||
):
|
||||
continue
|
||||
try:
|
||||
metadata = candidate.stat(follow_symlinks=False)
|
||||
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
|
||||
os.unlink(candidate.name, dir_fd=directory_descriptor)
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if removed:
|
||||
os.fsync(directory_descriptor)
|
||||
|
||||
|
||||
def consume_authorization(directory_descriptor: int, session_id: str, wall_clock: float) -> str | None:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
authorization_descriptor = os.open(AUTHORIZATION_DIRECTORY, flags, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
metadata = os.fstat(authorization_descriptor)
|
||||
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
||||
raise ValueError("unsafe promotion authorization directory")
|
||||
name = f"{session_id}.auth"
|
||||
try:
|
||||
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=authorization_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
token_metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(token_metadata.st_mode) or token_metadata.st_uid != os.getuid() or stat.S_IMODE(token_metadata.st_mode) != 0o600 or token_metadata.st_size <= 0 or token_metadata.st_size > MAX_FRAME:
|
||||
raise ValueError("unsafe promotion authorization")
|
||||
raw = os.read(descriptor, MAX_FRAME + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.unlink(name, dir_fd=authorization_descriptor)
|
||||
os.fsync(authorization_descriptor)
|
||||
token = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||
if not isinstance(token, dict) or set(token) != {"nonce", "seat", "session_id", "expires_at", "ts"}:
|
||||
return None
|
||||
nonce = token.get("nonce")
|
||||
expires_at = token.get("expires_at")
|
||||
issued_at = token.get("ts")
|
||||
if token.get("session_id") != session_id or not isinstance(token.get("seat"), str) or not isinstance(nonce, str) or len(nonce) != 64 or any(char not in "0123456789abcdef" for char in nonce) or type(expires_at) not in (int, float) or type(issued_at) not in (int, float) or expires_at <= wall_clock or expires_at > issued_at + AUTHORIZATION_TTL_SECONDS:
|
||||
return None
|
||||
return nonce
|
||||
finally:
|
||||
os.close(authorization_descriptor)
|
||||
|
||||
|
||||
def write_result(directory_descriptor: int, attempt_id: str, verified: bool, reason: str | None, session_id: str, wall_clock: float) -> None:
|
||||
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_descriptor)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||
json.dump({"attempt_id": attempt_id, "expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None, "reason": reason, "session_id": session_id, "ts": wall_clock, "verified": verified}, stream, separators=(",", ":"), sort_keys=True)
|
||||
stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(temporary, RESULT_FILE, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor)
|
||||
os.fsync(directory_descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
|
||||
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
|
||||
flags = (
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||
stream.write(challenge)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(
|
||||
temporary,
|
||||
name,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
os.fsync(directory_descriptor)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def parse_begin_reply(
|
||||
completed: subprocess.CompletedProcess[str],
|
||||
) -> tuple[str, dict[str, object] | None]:
|
||||
if completed.returncode != 0:
|
||||
return f"PROMOTER_EXIT_{completed.returncode}", None
|
||||
try:
|
||||
value = json.loads(
|
||||
completed.stdout,
|
||||
object_pairs_hook=reject_duplicate_json_keys,
|
||||
)
|
||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not isinstance(value, dict):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if value.get("ok") is False and set(value) == {"ok", "code"}:
|
||||
code = value.get("code")
|
||||
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
|
||||
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if value.get("state") != "PENDING_VERIFICATION":
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
challenge = value.get("receipt_challenge")
|
||||
receipt = value.get("receipt")
|
||||
binding = value.get("binding")
|
||||
if (
|
||||
not isinstance(challenge, str)
|
||||
or len(challenge) != 64
|
||||
or any(character not in "0123456789abcdef" for character in challenge)
|
||||
or not isinstance(receipt, str)
|
||||
or not isinstance(binding, dict)
|
||||
or set(binding) != EXPECTED_BINDING_KEYS
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
integer_fields = (
|
||||
"compaction_epoch",
|
||||
"request_epoch",
|
||||
"runtime_generation",
|
||||
"schema_version",
|
||||
)
|
||||
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not all(
|
||||
isinstance(binding.get(field), str)
|
||||
and len(binding[field]) == 64
|
||||
and all(character in "0123456789abcdef" for character in binding[field])
|
||||
for field in ("h_source", "h_payload")
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not secrets.compare_digest(
|
||||
receipt.encode("utf-8"),
|
||||
receipt_for(challenge, binding).encode("utf-8"),
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
return "", value
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stdin: object | None = None,
|
||||
stdout: TextIO | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
now: Callable[[], float] = time.time,
|
||||
) -> int:
|
||||
source_environment = os.environ if environ is None else environ
|
||||
input_stream = sys.stdin if stdin is None else stdin
|
||||
output_stream = sys.stdout if stdout is None else stdout
|
||||
error_stream = sys.stderr if stderr is None else stderr
|
||||
|
||||
try:
|
||||
hook_input = read_hook_input(input_stream)
|
||||
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
|
||||
return 0
|
||||
if hook_input.get("prompt") != PROMOTION_PROMPT:
|
||||
return 0
|
||||
|
||||
directory_descriptor: int | None = None
|
||||
lock_descriptor: int | None = None
|
||||
try:
|
||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||
directory_descriptor = open_pending_directory(runtime_dir)
|
||||
lock_descriptor = acquire_lock(directory_descriptor)
|
||||
wall_clock = now()
|
||||
nonce = consume_authorization(directory_descriptor, session_id, wall_clock)
|
||||
if nonce is None:
|
||||
write_result(directory_descriptor, "0" * 64, False, "NOT_AUTHORIZED", session_id, wall_clock)
|
||||
print("Mosaic promotion denied: NOT_AUTHORIZED.", file=error_stream)
|
||||
return 0
|
||||
sweep_stale_pending(directory_descriptor, wall_clock)
|
||||
completed = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
||||
code, reply = parse_begin_reply(completed)
|
||||
if code or reply is None:
|
||||
write_result(directory_descriptor, nonce, False, code or "PROMOTION_BEGIN_FAILED", session_id, now())
|
||||
return 0
|
||||
challenge = str(reply["receipt_challenge"])
|
||||
observation = observer_request(
|
||||
Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]),
|
||||
{"action": "record_runtime_observation", "session_id": session_id, "runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]), "runtime": "claude", "latest_assistant_message": reply["receipt"]},
|
||||
)
|
||||
if set(observation) != {"ok"} or observation.get("ok") is not True:
|
||||
write_result(directory_descriptor, challenge, False, "OBSERVATION_REJECTED", session_id, now())
|
||||
return 0
|
||||
completion = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--complete", challenge], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
||||
try:
|
||||
outcome = json.loads(completion.stdout, object_pairs_hook=reject_duplicate_json_keys)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
outcome = None
|
||||
if completion.returncode == 0 and isinstance(outcome, dict) and outcome.get("stage") == "promote_lease" and outcome.get("ok") is True and outcome.get("state") == "VERIFIED":
|
||||
write_result(directory_descriptor, challenge, True, None, session_id, now())
|
||||
else:
|
||||
reason = outcome.get("code") if isinstance(outcome, dict) and isinstance(outcome.get("code"), str) else "PROMOTION_INCOMPLETE"
|
||||
write_result(directory_descriptor, challenge, False, reason, session_id, now())
|
||||
except PromotionAlreadyInProgress:
|
||||
print("Mosaic promotion denied: PROMOTION_ALREADY_IN_PROGRESS.", file=error_stream)
|
||||
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
||||
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
||||
finally:
|
||||
if lock_descriptor is not None:
|
||||
os.close(lock_descriptor)
|
||||
if directory_descriptor is not None:
|
||||
os.close(directory_descriptor)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Stop hook that completes a pending operator-triggered promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final, NamedTuple, TextIO
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||
LEASE_TTL_SECONDS: Final = 60 * 60
|
||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||
LOCK_FILE: Final = "promotion.lock"
|
||||
RESULT_FILE: Final = "last-result.json"
|
||||
TERMINAL_FAILURE_CODES: Final = frozenset(
|
||||
{
|
||||
"RECEIPT_REPLAY",
|
||||
"RECEIPT_MISMATCH",
|
||||
"INVALID_LEASE_TRANSITION",
|
||||
"PROMOTION_TOKEN_INVALID",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PendingChallenge(NamedTuple):
|
||||
value: str
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate promoter JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
if not runtime_dir.is_absolute():
|
||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||
raise ValueError("invalid lease session id")
|
||||
return runtime_dir, f"pending-{session_id}"
|
||||
|
||||
|
||||
def open_pending_directory(runtime_dir: Path) -> int | None:
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
try:
|
||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
runtime_metadata = os.fstat(runtime_descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||
or runtime_metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||
):
|
||||
raise ValueError("unsafe XDG runtime directory")
|
||||
try:
|
||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
finally:
|
||||
os.close(runtime_descriptor)
|
||||
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion pending directory")
|
||||
return descriptor
|
||||
|
||||
|
||||
def acquire_lock(directory_descriptor: int) -> int:
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion lock file")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
return descriptor
|
||||
|
||||
|
||||
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
or metadata.st_size <= 0
|
||||
or metadata.st_size > MAX_FRAME
|
||||
):
|
||||
raise ValueError("unsafe promotion pending file")
|
||||
raw = os.read(descriptor, MAX_FRAME + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError("oversized promotion challenge")
|
||||
challenge = raw.decode("utf-8")
|
||||
if (
|
||||
len(challenge) != 64
|
||||
or any(character not in "0123456789abcdef" for character in challenge)
|
||||
):
|
||||
raise ValueError("invalid promotion challenge")
|
||||
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
|
||||
|
||||
|
||||
def write_result(
|
||||
directory_descriptor: int,
|
||||
attempt_id: str,
|
||||
verified: bool,
|
||||
reason: str | None,
|
||||
session_id: str,
|
||||
wall_clock: float,
|
||||
) -> None:
|
||||
result = {
|
||||
"attempt_id": attempt_id,
|
||||
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
|
||||
"reason": reason,
|
||||
"session_id": session_id,
|
||||
"ts": wall_clock,
|
||||
"verified": verified,
|
||||
}
|
||||
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
||||
flags = (
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(
|
||||
temporary,
|
||||
RESULT_FILE,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
os.fsync(directory_descriptor)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def delete_pending_if_unchanged(
|
||||
directory_descriptor: int,
|
||||
name: str,
|
||||
pending: PendingChallenge,
|
||||
error_stream: TextIO,
|
||||
) -> None:
|
||||
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
|
||||
try:
|
||||
os.rename(
|
||||
name,
|
||||
quarantine,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
|
||||
return
|
||||
|
||||
try:
|
||||
moved = os.stat(
|
||||
quarantine,
|
||||
dir_fd=directory_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
|
||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||
os.fsync(directory_descriptor)
|
||||
return
|
||||
|
||||
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
|
||||
try:
|
||||
os.link(
|
||||
quarantine,
|
||||
name,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileExistsError:
|
||||
print(
|
||||
f"Mosaic promotion preserved replacement as {quarantine}.",
|
||||
file=error_stream,
|
||||
)
|
||||
else:
|
||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||
os.fsync(directory_descriptor)
|
||||
except OSError as error:
|
||||
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
|
||||
|
||||
|
||||
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(
|
||||
completed.stdout,
|
||||
object_pairs_hook=reject_duplicate_json_keys,
|
||||
)
|
||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if set(value) == {"stage", "ok", "state"}:
|
||||
if (
|
||||
value.get("stage") == "promote_lease"
|
||||
and value.get("ok") is True
|
||||
and value.get("state") == "VERIFIED"
|
||||
):
|
||||
return value
|
||||
return None
|
||||
if set(value) == {"stage", "ok", "code"}:
|
||||
if (
|
||||
value.get("stage") in {"observe_receipt", "promote_lease"}
|
||||
and value.get("ok") is False
|
||||
and isinstance(value.get("code"), str)
|
||||
and value.get("code")
|
||||
):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
now: Callable[[], float] = time.time,
|
||||
) -> int:
|
||||
source_environment = os.environ if environ is None else environ
|
||||
error_stream = sys.stderr if stderr is None else stderr
|
||||
directory_descriptor: int | None = None
|
||||
lock_descriptor: int | None = None
|
||||
|
||||
try:
|
||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||
directory_descriptor = open_pending_directory(runtime_dir)
|
||||
if directory_descriptor is None:
|
||||
return 0
|
||||
try:
|
||||
lock_descriptor = acquire_lock(directory_descriptor)
|
||||
except (BlockingIOError, FileNotFoundError):
|
||||
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
|
||||
return 0
|
||||
pending = read_pending(directory_descriptor, pending_name)
|
||||
if pending is None:
|
||||
return 0
|
||||
completed = run(
|
||||
[
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-S",
|
||||
"-B",
|
||||
str(PROMOTER),
|
||||
"--complete",
|
||||
pending.value,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(source_environment),
|
||||
timeout=PROMOTER_TIMEOUT_SECONDS,
|
||||
)
|
||||
reply = parse_reply(completed)
|
||||
if reply is not None and reply.get("ok") is True:
|
||||
write_result(directory_descriptor, pending.value, True, None, session_id, now())
|
||||
delete_pending_if_unchanged(
|
||||
directory_descriptor,
|
||||
pending_name,
|
||||
pending,
|
||||
error_stream,
|
||||
)
|
||||
print("Mosaic lease promotion completed.", file=error_stream)
|
||||
return 0
|
||||
|
||||
if reply is not None:
|
||||
code = str(reply["code"])
|
||||
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
|
||||
if code in TERMINAL_FAILURE_CODES:
|
||||
write_result(directory_descriptor, pending.value, False, code, session_id, now())
|
||||
delete_pending_if_unchanged(
|
||||
directory_descriptor,
|
||||
pending_name,
|
||||
pending,
|
||||
error_stream,
|
||||
)
|
||||
else:
|
||||
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
|
||||
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
|
||||
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
|
||||
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
|
||||
finally:
|
||||
if lock_descriptor is not None:
|
||||
os.close(lock_descriptor)
|
||||
if directory_descriptor is not None:
|
||||
os.close(directory_descriptor)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -22,13 +22,23 @@ from typing import Final
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
||||
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate observer JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def read_json(stream: object) -> dict[str, object]:
|
||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||
raise ValueError("invalid observer input")
|
||||
value = json.loads(raw)
|
||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid observer input")
|
||||
return value
|
||||
@@ -100,9 +110,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid observer reply")
|
||||
value = json.loads(response)
|
||||
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid observer reply")
|
||||
return value
|
||||
@@ -119,7 +129,12 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
||||
if arguments.runtime == "claude":
|
||||
if not arguments.latest_entry:
|
||||
raise ValueError("Claude observer requires --latest-entry")
|
||||
message = claude_latest_entry(source)
|
||||
if "last_assistant_message" in source:
|
||||
message = source["last_assistant_message"]
|
||||
if not isinstance(message, str):
|
||||
raise ValueError("invalid Claude observer input")
|
||||
else:
|
||||
message = claude_latest_entry(source)
|
||||
else:
|
||||
if arguments.latest_entry:
|
||||
raise ValueError("Pi observer is message_end only")
|
||||
@@ -133,10 +148,18 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
||||
"runtime": arguments.runtime,
|
||||
"latest_assistant_message": message,
|
||||
})
|
||||
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||
return 2
|
||||
return 0 if reply == {"ok": True} else 2
|
||||
if set(reply) == {"ok"} and reply.get("ok") is True:
|
||||
return 0
|
||||
if (
|
||||
set(reply) == {"ok", "code"}
|
||||
and reply.get("ok") is False
|
||||
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
|
||||
):
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
|
||||
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
||||
|
||||
expected = receipt_for(challenge, binding)
|
||||
return hmac.compare_digest(message, expected)
|
||||
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
|
||||
|
||||
|
||||
def latest_assistant_digest(message: str) -> str:
|
||||
|
||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
PATH="$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||
|
||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
@@ -26,12 +26,13 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
| Script | Purpose |
|
||||
| -------------------------- | -------------------------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||
|
||||
## Common Options
|
||||
|
||||
@@ -59,4 +60,9 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
# Block until one or more pipelines finish (event-driven CI wait)
|
||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||
|
||||
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first contract harness for RM-61 / #1000.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
write_fixture() {
|
||||
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||
import json, sys
|
||||
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||
steps = [
|
||||
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||
]
|
||||
json.dump({
|
||||
"number": 9999,
|
||||
"status": pipeline_status,
|
||||
"commit": "a" * 40,
|
||||
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||
}, open(path, "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
expect_exit() {
|
||||
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||
set +e
|
||||
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||
actual=$?
|
||||
set -e
|
||||
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'PASS %s\n' "$label"
|
||||
printf '%s' "$output"
|
||||
}
|
||||
|
||||
# Ordinary terminal green.
|
||||
write_fixture "$TMP/green.json" success success 0 '' success
|
||||
out=$(expect_exit 0 green "$TMP/green.json")
|
||||
grep -q '"total_steps": 3' <<<"$out"
|
||||
grep -q '"exempted_steps": 0' <<<"$out"
|
||||
|
||||
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||
grep -q '"exempted_steps": 1' <<<"$out"
|
||||
|
||||
# Negative controls: both real PostgreSQL failures must remain red.
|
||||
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||
|
||||
# The exemption is signature-scoped, not step-scoped.
|
||||
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||
|
||||
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||
import json, os, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||
changed = json.loads(json.dumps(record))
|
||||
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||
PY
|
||||
for label in false true float string null; do
|
||||
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||
done
|
||||
|
||||
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||
|
||||
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||
set +e
|
||||
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||
missing_rc=$?
|
||||
set -e
|
||||
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||
|
||||
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||
import json, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
record.pop("commit")
|
||||
json.dump(record, open(sys.argv[2], "w"))
|
||||
PY
|
||||
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||
|
||||
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
|
||||
|
||||
RM-61 permits one named, signature-scoped exception for issue #1000. The
|
||||
exception retires when #1000 is fixed; all other non-success states block.
|
||||
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
|
||||
It does not fetch, retry, or re-trigger pipelines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
|
||||
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
|
||||
POD_NOT_FOUND = re.compile(
|
||||
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
|
||||
)
|
||||
|
||||
|
||||
def fail_usage(message: str) -> int:
|
||||
print(f"terminal-green contract input error: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def load_record(argument: str | None) -> dict[str, Any]:
|
||||
if argument in (None, "-"):
|
||||
value = json.load(sys.stdin)
|
||||
else:
|
||||
with Path(argument).open(encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("pipeline record must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
|
||||
error = step.get("error")
|
||||
exit_code = step.get("exit_code")
|
||||
return (
|
||||
step.get("name") == "ci-postgres"
|
||||
and step.get("type") == "service"
|
||||
and step.get("state") == "failure"
|
||||
and type(exit_code) is int
|
||||
and not isinstance(exit_code, bool)
|
||||
and exit_code == 0
|
||||
and isinstance(error, str)
|
||||
and POD_NOT_FOUND.fullmatch(error) is not None
|
||||
)
|
||||
|
||||
|
||||
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
|
||||
anomalies: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
steps: list[dict[str, Any]] = []
|
||||
|
||||
pipeline_status = record.get("status")
|
||||
actual_commit = record.get("commit")
|
||||
if actual_commit != expected_commit:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline commit does not equal the expected PR head",
|
||||
"expected_commit": expected_commit,
|
||||
"actual_commit": actual_commit,
|
||||
}
|
||||
)
|
||||
if pipeline_status != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline status is not success",
|
||||
}
|
||||
)
|
||||
|
||||
workflows = record.get("workflows")
|
||||
if not isinstance(workflows, list) or not workflows:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "workflows are missing or empty",
|
||||
}
|
||||
)
|
||||
workflows = []
|
||||
|
||||
for workflow_index, workflow in enumerate(workflows):
|
||||
if not isinstance(workflow, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": str(workflow_index),
|
||||
"state": None,
|
||||
"reason": "workflow is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
workflow_name = str(workflow.get("name", workflow_index))
|
||||
if workflow.get("state") != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "workflow state is not success",
|
||||
}
|
||||
)
|
||||
children = workflow.get("children")
|
||||
if not isinstance(children, list) or not children:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "child-step list is missing or empty",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for child_index, child in enumerate(children):
|
||||
if not isinstance(child, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": f"{workflow_name}[{child_index}]",
|
||||
"state": None,
|
||||
"reason": "step is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
steps.append(child)
|
||||
if child.get("state") == "success":
|
||||
continue
|
||||
if is_issue_1000_artifact(child):
|
||||
candidates.append(child)
|
||||
continue
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": child.get("name"),
|
||||
"type": child.get("type"),
|
||||
"state": child.get("state"),
|
||||
"exit_code": child.get("exit_code"),
|
||||
"error": child.get("error"),
|
||||
"reason": "non-success step does not match the #1000 teardown signature",
|
||||
}
|
||||
)
|
||||
|
||||
if len(candidates) > 1:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "exemption",
|
||||
"name": EXEMPTION_ID,
|
||||
"state": "invalid",
|
||||
"reason": "the #1000 exemption may apply to exactly one step",
|
||||
}
|
||||
)
|
||||
|
||||
exemption_applies = len(candidates) == 1 and not anomalies
|
||||
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": "mosaic-terminal-green/v1",
|
||||
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
|
||||
"pipeline_number": record.get("number"),
|
||||
"commit": actual_commit,
|
||||
"expected_commit": expected_commit,
|
||||
"pipeline_status": pipeline_status,
|
||||
"total_steps": len(steps),
|
||||
"state_counts": dict(sorted(state_counts.items())),
|
||||
"exempted_steps": 1 if exemption_applies else 0,
|
||||
"anomalies": anomalies,
|
||||
}
|
||||
if exemption_applies:
|
||||
candidate = candidates[0]
|
||||
result["exemptions"] = [
|
||||
{
|
||||
"exemption_id": EXEMPTION_ID,
|
||||
"step": candidate.get("name"),
|
||||
"signature": candidate.get("error"),
|
||||
"tracking_issue": EXEMPTION_ISSUE,
|
||||
"retires_when": "issue #1000 is fixed",
|
||||
}
|
||||
]
|
||||
else:
|
||||
result["exemptions"] = []
|
||||
|
||||
return (0 if not anomalies else 1), result
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="verify the full Woodpecker terminal-green child-step contract"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expect-commit",
|
||||
required=True,
|
||||
metavar="FULL_SHA",
|
||||
help="full 40-hex PR-head commit that the pipeline record must match",
|
||||
)
|
||||
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
|
||||
arguments = parser.parse_args()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
|
||||
parser.error("--expect-commit must be a full 40-hex commit")
|
||||
arguments.expect_commit = arguments.expect_commit.lower()
|
||||
return arguments
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_arguments()
|
||||
try:
|
||||
record = load_record(arguments.record)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
return fail_usage(str(error))
|
||||
code, result = verify(record, arguments.expect_commit)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.48",
|
||||
"version": "0.0.49",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
@@ -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 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_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/framework_skill_portability_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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.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"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.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/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-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-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/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user