Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
__pycache__/
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# 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/
|
@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),
|
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||||
# instead of repopulating a fresh one.
|
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||||
store-dir=/root/.local/share/pnpm/store
|
store-dir=${HOME}/.local/share/pnpm/store
|
||||||
|
|||||||
+104
-5
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# 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:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,9 +23,21 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.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:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, next]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -103,6 +115,84 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- 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
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -134,8 +224,17 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
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"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -146,7 +245,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -172,7 +271,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@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:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -201,8 +211,21 @@ git clone [email protected]:mosaicstack/stack.git
|
|||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
# 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
|
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.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
docker compose up -d valkey
|
docker compose up -d valkey
|
||||||
|
|
||||||
@@ -230,6 +253,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
|||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm preflight # Checkout/dependency/generated-state validation
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (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 --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
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 --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
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 { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
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 start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(): CommandExecutorService {
|
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
redis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
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
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@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,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -443,6 +446,7 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
|
if (this.redis) {
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// 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.
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
@@ -451,6 +455,7 @@ export class CommandExecutorService {
|
|||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
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 { 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 { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
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();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
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> {
|
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 { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.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 { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/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';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [
|
||||||
|
EnrollmentController,
|
||||||
|
FederationController,
|
||||||
|
CapabilitiesController,
|
||||||
|
ListController,
|
||||||
|
GetController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
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 { 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 { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
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();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
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> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle.close().catch(() => {});
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).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 () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('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 { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
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,
|
@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).
|
* 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
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* 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[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.redis) return [];
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -47,13 +52,15 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys
|
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||||
|
if (this.redis) {
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
if (valkeyKeys.length > 0) {
|
if (valkeyKeys.length > 0) {
|
||||||
await this.redis.del(...valkeyKeys);
|
await this.redis.del(...valkeyKeys);
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
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 summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
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 () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(summarizationWorker);
|
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// 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 { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -15,16 +17,45 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
interface LocalOverrideEntry {
|
||||||
export class SystemOverrideService {
|
condensed: string;
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
fragments: OverrideFragment[];
|
||||||
private readonly handle: QueueHandle;
|
}
|
||||||
|
|
||||||
constructor() {
|
@Injectable()
|
||||||
this.handle = createQueue();
|
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> {
|
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
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
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));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) return;
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_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> {
|
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(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_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';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
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';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -108,22 +110,43 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
|
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
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 {
|
onModuleInit(): void {
|
||||||
|
if (this.enabled) {
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
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> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.enabled) {
|
||||||
await this.closeAll();
|
await this.closeAll();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Queue helpers
|
// Queue helpers
|
||||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* 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;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
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).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
|
* No-op on Local tier.
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
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);
|
const queue = this.getQueue(queueName);
|
||||||
|
if (!queue) return 0;
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
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
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* 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>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
|
* Returns an empty healthy result on Local tier.
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return { queues: {}, healthy: true };
|
||||||
|
}
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
|
* Returns empty array on Local tier.
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
|
if (!this.enabled) return [];
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [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").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* 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 }> {
|
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('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
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.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
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);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
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);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"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.
|
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
|
## 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)
|
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
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||||
non-interactive use:
|
|
||||||
|
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
|
```bash
|
||||||
--yes # Accept all defaults
|
--yes # Accept all defaults
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ Correct for the CI container (runs as root), fatal for EVERY non-root local chec
|
|||||||
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||||
|
|
||||||
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||||
|
|
||||||
|
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||||
|
|
||||||
|
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||||
|
|
||||||
|
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||||
|
|
||||||
|
### **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.
|
||||||
|
|||||||
+15
-16
@@ -1,6 +1,6 @@
|
|||||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||||
|
|
||||||
**Phase:** EXECUTING — first PR merged; RM-01 in flight; all 3 decisions ruled.
|
**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`).
|
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||||
|
|
||||||
## Head
|
## Head
|
||||||
@@ -17,13 +17,12 @@
|
|||||||
## In-flight
|
## In-flight
|
||||||
|
|
||||||
| Task | Owner | State |
|
| Task | Owner | State |
|
||||||
| ------------------------------------------------- | --------------- | ------------------------------------------------------------------ |
|
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||||
| PR #1026 docs (mission record + backlog) | mos-remediation | OPEN, retargeted to main, rebased; diff verified docs-only |
|
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||||
| PR #1025 hygiene | — | **MERGED** 52414605; rev-974 APPROVE + CI #2158 8/8 terminal-green |
|
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||||
| DECISION-1 wire-in point (charter change) | Mos / Jason | ESCALATED — both planners reject the charter's target |
|
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||||
| DECISION-2 rollback artifact + availability trade | Jason | ESCALATED |
|
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||||
| DECISION-3 RM-03 vs parked PR #1023 ownership | Mos | ESCALATED |
|
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||||
| RM-01 reproducible checkout (unblocks everything) | unassigned | READY TO DISPATCH |
|
|
||||||
|
|
||||||
## Fleet seats
|
## Fleet seats
|
||||||
|
|
||||||
@@ -39,6 +38,14 @@
|
|||||||
- Freeze: LIFTED for this workstream only.
|
- Freeze: LIFTED for this workstream only.
|
||||||
- Git identity: `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.
|
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||||
|
- 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**
|
- 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.
|
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||||
|
|
||||||
@@ -72,14 +79,6 @@ PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
|
||||||
|
|
||||||
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
|
||||||
|
|
||||||
### **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:END -->
|
<!-- BOARD-ROLL:END -->
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ mechanically until Build 3 (rotation) makes it automatic.
|
|||||||
|
|
||||||
## On resume (do in order, before any orchestration action)
|
## On resume (do in order, before any orchestration action)
|
||||||
|
|
||||||
1. `cd /src/mosaic-stack` and confirm you are on the remediation working branch.
|
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
|
||||||
|
⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
|
||||||
|
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
|
||||||
|
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
|
||||||
|
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
|
||||||
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||||
3. Read `docs/remediation/BOARD.md` — the LIVE state: current phase, in-flight tasks, fleet seat assignments,
|
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
||||||
gate status. This is your single source of in-flight truth (kept small).
|
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
|
||||||
|
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
|
||||||
4. Read the discussion checkpoint for full rationale if needed:
|
4. Read the discussion checkpoint for full rationale if needed:
|
||||||
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||||
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Mosaic Stack Remediation — Mission Charter
|
# Mosaic Stack Remediation — Mission Charter
|
||||||
|
|
||||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** PLANNING (task decomposition).
|
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
|
||||||
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
@@ -10,19 +10,100 @@ Convert the 15 accepted postmortem remediation proposals into a working, **dogfo
|
|||||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||||
gate/program; the LLM handles only genuine judgment.
|
gate/program; the LLM handles only genuine judgment.
|
||||||
|
|
||||||
|
### First-class principle — observe the property, not the exit code
|
||||||
|
|
||||||
|
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||||
|
>
|
||||||
|
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||||
|
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
|
||||||
|
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
|
||||||
|
> behaviour of a competent operator, not a lapse.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
|
||||||
|
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
|
||||||
|
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
|
||||||
|
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
|
||||||
|
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
|
||||||
|
>
|
||||||
|
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
|
||||||
|
> flags, commit authorship, file installs, and message delivery alike.
|
||||||
|
|
||||||
|
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||||
|
|
||||||
|
> **A pre-registered check set can fail in three distinct ways:**
|
||||||
|
>
|
||||||
|
> | mode | the set is… | found as |
|
||||||
|
> | --------------------------- | ------------------------------------------------- | -------- |
|
||||||
|
> | **WRONG** | a check does not test what it claims | D-8 |
|
||||||
|
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
|
||||||
|
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
|
||||||
|
>
|
||||||
|
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
|
||||||
|
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
|
||||||
|
> pre-registered the checks" has been treated as though it settled the question — it settles one of
|
||||||
|
> three.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
|
||||||
|
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
|
||||||
|
>
|
||||||
|
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
|
||||||
|
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** —
|
||||||
|
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** —
|
||||||
|
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
|
||||||
|
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
|
||||||
|
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
|
||||||
|
> however it reads in the manifest.
|
||||||
|
|
||||||
|
### Corollary — never ship an integrity claim dressed as a property
|
||||||
|
|
||||||
|
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||||
|
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||||
|
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||||
|
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
|
||||||
|
> a _claim_, not a _property_.
|
||||||
|
>
|
||||||
|
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
|
||||||
|
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
|
||||||
|
> available and always preferable.
|
||||||
|
|
||||||
|
### First-class principle — when a property cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||||
|
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||||
|
> there are exactly three honest moves, and all three are mandatory:
|
||||||
|
>
|
||||||
|
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
|
||||||
|
> born from is worth having.
|
||||||
|
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
|
||||||
|
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
|
||||||
|
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
|
||||||
|
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
|
||||||
|
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
|
||||||
|
> reads as intentional._
|
||||||
|
>
|
||||||
|
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
|
||||||
|
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
|
||||||
|
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
|
||||||
|
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
|
||||||
|
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
|
||||||
|
> 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.
|
||||||
|
|
||||||
## Decision record (authoritative, immutable)
|
## Decision record (authoritative, immutable)
|
||||||
|
|
||||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||||
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||||
- MACP wiring scout (verdict c=STRANDED): `/tmp/macp-wiring-investigation.md` (copy into this dir — see TODO).
|
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
|
||||||
|
|
||||||
## The plan — 15 proposals collapse to 4 builds + hygiene
|
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||||
|
|
||||||
| Build | Absorbs | What it is |
|
| Build | Absorbs | What it is |
|
||||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** in at `mosaic_orchestrator.py::run_single_task` — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. |
|
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
|
||||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||||
| **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. |
|
| **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. |
|
||||||
@@ -37,6 +118,7 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
|||||||
## Sequencing (skeleton — adversarial decomposition refines this)
|
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||||
|
|
||||||
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||||
|
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
|
||||||
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||||
@@ -50,6 +132,11 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
|||||||
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||||
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||||
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||||
|
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
|
||||||
|
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
|
||||||
|
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
|
||||||
|
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
|
||||||
|
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
|
||||||
|
|
||||||
## The 15 decisions (one-line; full rationale in the checkpoint)
|
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||||
|
|
||||||
|
|||||||
+516
-1
@@ -93,6 +93,520 @@ and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
|||||||
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||||
is the class confirmed, not anecdote.
|
is the class confirmed, not anecdote.
|
||||||
|
|
||||||
|
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||||
|
|
||||||
|
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||||
|
D-18's entry, written by the orchestrator.
|
||||||
|
|
||||||
|
Two faults, both mine:
|
||||||
|
|
||||||
|
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
|
||||||
|
("within an accidental/independent-mutation threat model").
|
||||||
|
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
|
||||||
|
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
|
||||||
|
at this layer, and was never revised when D-19 landed.
|
||||||
|
|
||||||
|
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
|
||||||
|
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
|
||||||
|
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
|
||||||
|
the only place still overclaiming.**
|
||||||
|
|
||||||
|
**This is two banked findings firing on the orchestrator at once:**
|
||||||
|
|
||||||
|
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
|
||||||
|
_property_, in the very document that defines the rule against doing so.
|
||||||
|
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
|
||||||
|
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
|
||||||
|
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
|
||||||
|
propagate forwards, and I did not audit for that direction.
|
||||||
|
|
||||||
|
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
|
||||||
|
than silently rewritten — the same standard demanded of any restated criterion.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
|
||||||
|
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
|
||||||
|
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
|
||||||
|
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
|
||||||
|
document least of all: it is the artifact most likely to be quoted as authority long after the code has
|
||||||
|
moved on.
|
||||||
|
|
||||||
|
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
|
||||||
|
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
|
||||||
|
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
|
||||||
|
to settle it. That is the review standard this mission is trying to make ordinary.
|
||||||
|
|
||||||
|
### D-19 — an integrity property that cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
|
||||||
|
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
|
||||||
|
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
|
||||||
|
local cryptographic construction fixes self-authentication** without a key outside that actor's
|
||||||
|
authority; relocating the marker changes the path, not the authority.
|
||||||
|
|
||||||
|
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
|
||||||
|
failure mode the charter corollary demands. That is the corollary working, on its first real test.
|
||||||
|
|
||||||
|
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
|
||||||
|
Rationale, recorded so it can be challenged:
|
||||||
|
|
||||||
|
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
|
||||||
|
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
|
||||||
|
local checkout is trustworthy — hardening the manifest buys no real security while **implying
|
||||||
|
protection that does not exist**, which is worse than the gap.
|
||||||
|
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
|
||||||
|
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
|
||||||
|
foreign residue — and against that class the design demonstrably works.
|
||||||
|
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
|
||||||
|
outside the actor's authority; for a fleet running as one user that means a separate service —
|
||||||
|
precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's
|
||||||
|
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
|
||||||
|
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
|
||||||
|
|
||||||
|
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
|
||||||
|
Conditions (last two added/sharpened by Mos):
|
||||||
|
|
||||||
|
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
|
||||||
|
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
|
||||||
|
RED-first including manifest-only tamper.
|
||||||
|
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
|
||||||
|
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
|
||||||
|
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
|
||||||
|
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
|
||||||
|
sees only the positive over-trusts it. Both together is the honest artifact._
|
||||||
|
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
|
||||||
|
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
|
||||||
|
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
|
||||||
|
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
|
||||||
|
as intentional.**
|
||||||
|
|
||||||
|
**The generalizable rule.** When a required property **cannot exist at the layer where it was
|
||||||
|
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
|
||||||
|
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
|
||||||
|
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
|
||||||
|
artifact that verifies nothing — with a green to prove it.
|
||||||
|
|
||||||
|
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
|
||||||
|
|
||||||
|
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
|
||||||
|
|
||||||
|
- **AC2** (as written) — reject symlinked generated state.
|
||||||
|
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
|
||||||
|
|
||||||
|
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
|
||||||
|
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
|
||||||
|
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
|
||||||
|
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
|
||||||
|
configuration**, and nothing short of building the tree would have revealed it.
|
||||||
|
|
||||||
|
**Third distinct failure mode of a pre-registered check set**, completing the chain:
|
||||||
|
|
||||||
|
| finding | a pre-registered check set can be… |
|
||||||
|
| ------- | ------------------------------------------------------------------ |
|
||||||
|
| D-8 | **wrong** — a check that does not test what it claims |
|
||||||
|
| D-17 | **incomplete** — green while a criterion's requirement is untested |
|
||||||
|
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
|
||||||
|
|
||||||
|
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
|
||||||
|
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
|
||||||
|
exists so that changes of meaning are auditable rather than absorbed.
|
||||||
|
|
||||||
|
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
|
||||||
|
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
|
||||||
|
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
|
||||||
|
symlink, which blanket rejection cannot distinguish from a legitimate one.
|
||||||
|
|
||||||
|
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
|
||||||
|
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
|
||||||
|
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
|
||||||
|
independent-mutation threat model.**_
|
||||||
|
|
||||||
|
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
|
||||||
|
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
|
||||||
|
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
|
||||||
|
> established.
|
||||||
|
|
||||||
|
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
|
||||||
|
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
|
||||||
|
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
|
||||||
|
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
|
||||||
|
**manifest-only-tampered**, plus a positive control that the canonical build passes.
|
||||||
|
|
||||||
|
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
|
||||||
|
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
|
||||||
|
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
|
||||||
|
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
|
||||||
|
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
|
||||||
|
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
|
||||||
|
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
|
||||||
|
> specified layer.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
|
||||||
|
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
|
||||||
|
defect discoverable by construction — and when a criterion is restated, the registry must retain the
|
||||||
|
original text, the restatement, and the reason, so the evolution stays auditable.
|
||||||
|
|
||||||
|
### D-17 — a pre-registered criterion passed a green suite without being satisfied
|
||||||
|
|
||||||
|
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
|
||||||
|
instance of the session's theme because it occurred **inside our own verification machinery**.
|
||||||
|
|
||||||
|
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
|
||||||
|
generated state be rejected**. The implementation does not do it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /etc/hosts apps/web/.next/reviewer-symlink
|
||||||
|
pnpm preflight # → "checkout preflight passed", exit 0
|
||||||
|
# → required: generated-state exit 43
|
||||||
|
```
|
||||||
|
|
||||||
|
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
|
||||||
|
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
|
||||||
|
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
|
||||||
|
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
|
||||||
|
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
|
||||||
|
exists anywhere.
|
||||||
|
|
||||||
|
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
|
||||||
|
is _invisible from a green_, which is the entire problem.
|
||||||
|
|
||||||
|
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
|
||||||
|
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
|
||||||
|
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
|
||||||
|
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
|
||||||
|
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
|
||||||
|
_actually exercise_ what it claims.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
|
||||||
|
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
|
||||||
|
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
|
||||||
|
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
|
||||||
|
merely at the gate.
|
||||||
|
|
||||||
|
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
|
||||||
|
forward with no runnable command rather than silently substituting a different boundary test. That is
|
||||||
|
the D-8 clause working a second time, in the same review that produced D-17.
|
||||||
|
|
||||||
|
### D-16 — the local test gate and the CI test gate disagree by environment
|
||||||
|
|
||||||
|
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
|
||||||
|
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
|
||||||
|
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
|
||||||
|
|
||||||
|
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
|
||||||
|
exercised. Yet:
|
||||||
|
|
||||||
|
| environment | result |
|
||||||
|
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| CI container | `test` step **green** (#2158, #2167) |
|
||||||
|
| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
|
||||||
|
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
|
||||||
|
|
||||||
|
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
|
||||||
|
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
|
||||||
|
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
|
||||||
|
`main` is equally affected on this host.
|
||||||
|
|
||||||
|
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
|
||||||
|
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
|
||||||
|
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
|
||||||
|
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
|
||||||
|
already live as #1007 (PR #1024).
|
||||||
|
|
||||||
|
**Second, independent defect found while establishing the above.** In the main checkout the same
|
||||||
|
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
|
||||||
|
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
|
||||||
|
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
|
||||||
|
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
|
||||||
|
hygiene) — one untracked foreign tree silently breaking two independent gates.
|
||||||
|
|
||||||
|
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
|
||||||
|
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
|
||||||
|
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
|
||||||
|
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
|
||||||
|
|
||||||
|
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
|
||||||
|
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
|
||||||
|
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
|
||||||
|
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
|
||||||
|
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
|
||||||
|
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
|
||||||
|
|
||||||
|
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
|
||||||
|
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
|
||||||
|
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
|
||||||
|
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
|
||||||
|
exact subject, one meta-level up.
|
||||||
|
|
||||||
|
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
|
||||||
|
is a known host-specific guard abort, irrelevant to the merge decision.
|
||||||
|
|
||||||
|
### D-15 — token scope is not repository permission (a THIRD capability layer)
|
||||||
|
|
||||||
|
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
|
||||||
|
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
|
||||||
|
|
||||||
|
```
|
||||||
|
remote: error: User permission denied for writing.
|
||||||
|
remote: error: pre-receive hook declined
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified objectively rather than inferred (per the charter principle):
|
||||||
|
|
||||||
|
| probe | result |
|
||||||
|
| ------------------------------------------------------ | ------------------------------------------- |
|
||||||
|
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
|
||||||
|
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
|
||||||
|
|
||||||
|
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
|
||||||
|
|
||||||
|
1. **Token file exists** → raw-API authentication works (D-11b).
|
||||||
|
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
|
||||||
|
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
|
||||||
|
|
||||||
|
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
|
||||||
|
may attempt; repository permission decides what the user may do.** They are different systems.
|
||||||
|
|
||||||
|
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
|
||||||
|
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
|
||||||
|
was required, which was **write**. Both the provisioner and I accepted it — the same
|
||||||
|
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
|
||||||
|
verification.
|
||||||
|
|
||||||
|
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
|
||||||
|
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
|
||||||
|
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
|
||||||
|
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
|
||||||
|
itself an inert gate.
|
||||||
|
|
||||||
|
### D-14 — a ruled decision did not propagate to the authoritative record
|
||||||
|
|
||||||
|
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
|
||||||
|
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
|
||||||
|
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
|
||||||
|
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
|
||||||
|
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
|
||||||
|
to prevent.
|
||||||
|
|
||||||
|
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
|
||||||
|
|
||||||
|
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
|
||||||
|
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
|
||||||
|
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
|
||||||
|
difference is exactly what this mission is about.
|
||||||
|
|
||||||
|
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
|
||||||
|
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
|
||||||
|
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
|
||||||
|
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
|
||||||
|
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
|
||||||
|
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
|
||||||
|
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
|
||||||
|
> the same defect as forward; neither of us audited that direction until it bit.
|
||||||
|
|
||||||
|
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
|
||||||
|
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
|
||||||
|
mission state is DB-backed (RM-53 / the P-MISSION cutover):
|
||||||
|
|
||||||
|
- a decision is a **record**, not prose duplicated across documents;
|
||||||
|
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
|
||||||
|
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
|
||||||
|
document agree — with a must-fail control proving divergence is detected.
|
||||||
|
|
||||||
|
Until then, the interim rule: **the same commit that records a ruling updates every document that
|
||||||
|
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
|
||||||
|
|
||||||
|
**The rule found a second instance within minutes of being written.** Auditing the charter against all
|
||||||
|
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
|
||||||
|
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
|
||||||
|
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
|
||||||
|
rollback artifact required). A seat reading the charter would have designed toward an availability
|
||||||
|
posture the coordinator had explicitly rejected — and would have found the superseded
|
||||||
|
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
|
||||||
|
|
||||||
|
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
|
||||||
|
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
|
||||||
|
for making this a requirement rather than a discipline.
|
||||||
|
|
||||||
|
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
|
||||||
|
|
||||||
|
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
|
||||||
|
registries**, and capability in one does not imply capability in the other:
|
||||||
|
|
||||||
|
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
|
||||||
|
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
|
||||||
|
|
||||||
|
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
|
||||||
|
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
|
||||||
|
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
|
||||||
|
|
||||||
|
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
|
||||||
|
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
|
||||||
|
registry and still lose functionality with no error — only a warning, and only on the degraded path.
|
||||||
|
|
||||||
|
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
|
||||||
|
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
|
||||||
|
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
|
||||||
|
used**, not merely token-file presence.
|
||||||
|
|
||||||
|
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
|
||||||
|
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
|
||||||
|
tea-only features — `--draft`, `--labels`, `--milestone`.
|
||||||
|
|
||||||
|
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
|
||||||
|
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
|
||||||
|
|
||||||
|
| state | seats |
|
||||||
|
| ------------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
|
||||||
|
| token file present **and** tea login present | `rev-974` (only) |
|
||||||
|
|
||||||
|
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
|
||||||
|
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
|
||||||
|
one seat would clear a symptom and leave the class live.
|
||||||
|
|
||||||
|
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
|
||||||
|
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
|
||||||
|
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
|
||||||
|
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
|
||||||
|
|
||||||
|
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
|
||||||
|
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
|
||||||
|
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
|
||||||
|
|
||||||
|
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
|
||||||
|
|
||||||
|
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
|
||||||
|
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
|
||||||
|
and emitted:
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
|
||||||
|
```
|
||||||
|
|
||||||
|
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
|
||||||
|
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
|
||||||
|
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
|
||||||
|
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
|
||||||
|
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
|
||||||
|
prefix (Gitea's draft mechanism); `draft: True` verified after.
|
||||||
|
|
||||||
|
**Three distinct failures, and the third is mine:**
|
||||||
|
|
||||||
|
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
|
||||||
|
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
|
||||||
|
nuisance; degrading `--draft` publishes unproven work as ready to merge.
|
||||||
|
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
|
||||||
|
warning nobody acts on is indistinguishable from no warning.
|
||||||
|
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
|
||||||
|
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
|
||||||
|
over an observed state**, on exactly the class of tool this mission exists to distrust.
|
||||||
|
|
||||||
|
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
|
||||||
|
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
|
||||||
|
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` —
|
||||||
|
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
|
||||||
|
|
||||||
|
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
|
||||||
|
|
||||||
|
Two defects, one dispatch (RM-01 → `f10-coder`):
|
||||||
|
|
||||||
|
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
|
||||||
|
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
|
||||||
|
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
|
||||||
|
|
||||||
|
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
|
||||||
|
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
|
||||||
|
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
|
||||||
|
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
|
||||||
|
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
|
||||||
|
> seat did exactly what it was told and the commits were still mis-attributed.**
|
||||||
|
>
|
||||||
|
> **The requirement is coherence: token and authorship must agree.** A seat acting with
|
||||||
|
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
|
||||||
|
> Either half alone is identity drift — one produces the right credential with the wrong author, the
|
||||||
|
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
|
||||||
|
> instructions a seat may follow correctly and still end up wrong.
|
||||||
|
|
||||||
|
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
|
||||||
|
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
|
||||||
|
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
|
||||||
|
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
|
||||||
|
to check it could act on the target repo — and there was no way to check.
|
||||||
|
|
||||||
|
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
|
||||||
|
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
|
||||||
|
information_ did not exist.
|
||||||
|
|
||||||
|
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
|
||||||
|
should declare what it can actually do — which providers, which repos, which credentials — and that
|
||||||
|
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
|
||||||
|
|
||||||
|
**Requirements.**
|
||||||
|
|
||||||
|
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
|
||||||
|
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
|
||||||
|
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
|
||||||
|
in a brief.
|
||||||
|
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
|
||||||
|
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
|
||||||
|
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
|
||||||
|
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
|
||||||
|
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
|
||||||
|
|
||||||
|
### D-10 — the queue guard's failure modes are exactly backwards
|
||||||
|
|
||||||
|
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
|
||||||
|
these:
|
||||||
|
|
||||||
|
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
|
||||||
|
real merges. It also evaluates `branch=main` rather than the branch being acted on.
|
||||||
|
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
|
||||||
|
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
|
||||||
|
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
|
||||||
|
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
|
||||||
|
process environment.
|
||||||
|
|
||||||
|
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
|
||||||
|
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
|
||||||
|
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
|
||||||
|
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
|
||||||
|
block.
|
||||||
|
|
||||||
|
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
|
||||||
|
work needed to recover from it.
|
||||||
|
|
||||||
|
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
|
||||||
|
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
|
||||||
|
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
|
||||||
|
registered R-002 cases with must-fail controls; neither may exit 0 silently.
|
||||||
|
|
||||||
|
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
|
||||||
|
|
||||||
|
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
|
||||||
|
backticked text as command substitution. The recipient received a mangled body plus a
|
||||||
|
`No such file or directory` error; the intended sentence never arrived. The message was reported as
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
|
||||||
|
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
|
||||||
|
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
|
||||||
|
send reported success while silently transmitting something other than what was written. Silent
|
||||||
|
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
|
||||||
|
|
||||||
|
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
|
||||||
|
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
|
||||||
|
adapter. Concretely: **file/stdin transport, never argv interpolation.**
|
||||||
|
|
||||||
|
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
|
||||||
|
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
|
||||||
|
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
|
||||||
|
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
|
||||||
|
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
|
||||||
|
proving the assertion can detect corruption.
|
||||||
|
|
||||||
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
||||||
|
|
||||||
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
||||||
@@ -298,7 +812,7 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
|||||||
### P5 — Retirements, hygiene, conformance
|
### P5 — Retirements, hygiene, conformance
|
||||||
|
|
||||||
| id | task | src | depends_on | est (S/O) | tier |
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------- | ---------------- | ------ |
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||||
@@ -307,6 +821,7 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
|||||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||||
|
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||||
|
|
||||||
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||||
|
|||||||
@@ -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",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"preflight": "node scripts/preflight.mjs",
|
||||||
"test": "turbo run test",
|
"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": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{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": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
|||||||
@@ -11,9 +11,37 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||||
import { createConfigService } from '../../src/config/config-service.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 gatewayConfigMock = vi.fn();
|
||||||
const gatewayBootstrapMock = 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', () => ({
|
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||||
@@ -23,6 +51,14 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
|||||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
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 AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||||
import { runWizard } from '../../src/wizard.js';
|
import { runWizard } from '../../src/wizard.js';
|
||||||
|
|
||||||
@@ -44,6 +80,16 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
}
|
}
|
||||||
gatewayConfigMock.mockReset();
|
gatewayConfigMock.mockReset();
|
||||||
gatewayBootstrapMock.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
|
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||||
// branch does not call `process.exit(1)` during these tests.
|
// branch does not call `process.exit(1)` during these tests.
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
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 });
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
it('prints the success summary only after gateway health succeeds', async () => {
|
||||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
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({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
@@ -118,6 +168,43 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
skipGatewayNpmInstall: true,
|
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(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -143,4 +230,34 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||||
expect(gatewayBootstrapMock).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
|
- Runs a health audit
|
||||||
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
- 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
|
## First Run
|
||||||
|
|
||||||
After install, open a new terminal (or `source ~/.bashrc`) and 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)
|
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
||||||
├── STANDARDS.md ← Machine-wide standards
|
├── STANDARDS.md ← Machine-wide standards
|
||||||
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
├── 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.
|
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
||||||
|
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
|
||||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
│ ├── 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 --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
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.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
@@ -185,6 +197,7 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic sync # Full canonical catalog sync
|
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 list # Show registered, missing, dangling, and foreign entries
|
||||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
@@ -198,7 +211,7 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic doctor # Standard audit
|
mosaic doctor # Standard audit
|
||||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP Registration
|
## MCP Registration
|
||||||
@@ -209,8 +222,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
|
|||||||
To verify or re-register manually:
|
To verify or re-register manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
||||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code MCP Registration
|
### 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
|
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
|
**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
|
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
|
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`
|
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||||
and `pr-ci-wait.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`
|
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
|
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)
|
## Post-Merge CI Monitoring (Hard Rule)
|
||||||
|
|
||||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
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:
|
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
~/.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>
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
- 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
|
## Gitea as Unified Platform
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- PR target for delivery is `main`.
|
- PR target for delivery is `main`.
|
||||||
- Direct pushes to `main` are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Merge to `main` MUST be squash-only.
|
- 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
|
## 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`.
|
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.
|
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.
|
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).
|
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).
|
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).
|
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
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
> without asking.
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
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`
|
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>`
|
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)
|
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.
|
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.
|
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||||
- Before merging, run queue guard:
|
- 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):
|
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||||
- Merge via wrapper:
|
- 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:
|
- Wait for terminal CI status:
|
||||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- Close linked issue after merge + green CI:
|
- 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
|
**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.
|
(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
|
## 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/issue-view.sh -i {N}`
|
||||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
- `~/.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`
|
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
- 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/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
- `~/.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
|
# Milestones
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
~/.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
|
~/.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)
|
### Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.
|
||||||
@@ -32,6 +32,18 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "^/mosaic-promote$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
@@ -81,8 +93,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
"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": 3
|
"timeout": 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
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.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
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`).
|
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.
|
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.
|
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.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
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)
|
## 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.
|
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.
|
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.
|
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`).
|
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.
|
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`).
|
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.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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=(
|
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/.claude/skills"
|
||||||
"$HOME/.codex/skills"
|
"$HOME/.codex/skills"
|
||||||
"$HOME/.config/opencode/skills"
|
"$HOME/.config/opencode/skills"
|
||||||
@@ -245,13 +262,72 @@ prune_stale_links_in_target() {
|
|||||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||||
# is unproven and the link must be preserved.
|
# is unproven and the link must be preserved.
|
||||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
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"
|
rm -f "$link_path"
|
||||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||||
fi
|
fi
|
||||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
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
|
for target in "${link_targets[@]}"; do
|
||||||
mkdir -p "$target"
|
mkdir -p "$target"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
BRANCH="main"
|
BRANCH=""
|
||||||
|
TARGET_REPO=""
|
||||||
|
HEAD_SHA=""
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
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:
|
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)
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
--purpose VALUE Log context: push|merge (default: merge)
|
--purpose VALUE Log context: push|merge (default: merge)
|
||||||
@@ -27,63 +31,65 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
$(basename "$0") --purpose push -t 600 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
get_state_from_status_json() {
|
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 json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("status payload is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
statuses = payload.get("statuses") or []
|
raw_statuses = payload.get("statuses", [])
|
||||||
state = (payload.get("state") or "").lower()
|
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"}
|
pending_values = {"pending", "queued", "running", "waiting"}
|
||||||
failure_values = {"failure", "error", "failed"}
|
failure_values = {"failure", "error", "failed"}
|
||||||
success_values = {"success"}
|
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 = []
|
values = []
|
||||||
for item in statuses:
|
for item in statuses:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
print("malformed")
|
||||||
value = (item.get("status") or item.get("state") or "").lower()
|
raise SystemExit(0)
|
||||||
if value:
|
raw_value = item.get("status") or item.get("state")
|
||||||
values.append(value)
|
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:
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("no-status")
|
|
||||||
elif any(v in pending_values for v in values):
|
|
||||||
print("pending")
|
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")
|
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")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 - <<'PY'
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -104,17 +110,61 @@ for item in statuses:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("context") or item.get("name") or "unknown-context"
|
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 ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
if target:
|
suffix = f" ({target})" if target else ""
|
||||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||||
else:
|
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
||||||
if not found:
|
if not found:
|
||||||
print("[ci-queue-wait] no pending contexts")
|
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
|
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() {
|
github_get_branch_head_sha() {
|
||||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
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() {
|
gitea_get_branch_head_sha() {
|
||||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-R|--repo)
|
||||||
|
TARGET_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sha)
|
||||||
|
HEAD_SHA="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 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
|
echo "Error: timeout and interval must be integer seconds." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: --purpose must be push or merge." >&2
|
||||||
detect_platform > /dev/null
|
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}"
|
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 [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! command -v gh >/dev/null 2>&1; then
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
record_cannot_assert "github-cli-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||||
echo "Error: Could not determine remote host." >&2
|
record_cannot_assert "remote-host-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
record_cannot_assert "credential-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
|
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
|
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
record_cannot_assert "branch-head-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
record_cannot_assert "unsupported-platform"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
|||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
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
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
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
|
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
|
fi
|
||||||
|
|
||||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||||
@@ -271,21 +459,24 @@ while true; do
|
|||||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
|
terminal-success)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 1
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
exit 3
|
||||||
exit 0
|
|
||||||
;;
|
;;
|
||||||
terminal-success|terminal-failure|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
# Queue guard only blocks on pending/running/queued states.
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,8 +12,10 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
EXPECT_HEAD=""
|
||||||
|
CO_AUTHOR_TRAILERS=false
|
||||||
|
ESCALATE_TO=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -25,15 +27,18 @@ Options:
|
|||||||
-n, --number NUMBER PR number to merge (required)
|
-n, --number NUMBER PR number to merge (required)
|
||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-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
|
--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
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(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
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -53,15 +58,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
DELETE_BRANCH=true
|
DELETE_BRANCH=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--skip-queue-guard)
|
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
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)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -86,18 +106,49 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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")"
|
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())')"
|
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
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
|
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" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$BASE_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
|
-R "$HEAD_REPO" \
|
||||||
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
fi
|
fi
|
||||||
@@ -106,79 +157,442 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
is_known_tea_empty_identity_failure() {
|
write_curl_auth_config() {
|
||||||
local error_file="$1"
|
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'
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
import re
|
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
|
import sys
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
with open(sys.argv[1], "rb") as handle:
|
||||||
error = handle.read()
|
raw = handle.read(65536)
|
||||||
|
try:
|
||||||
known_empty_identity = re.search(
|
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
error,
|
message = "non-JSON response omitted"
|
||||||
flags=re.IGNORECASE | re.DOTALL,
|
else:
|
||||||
)
|
if isinstance(response, dict):
|
||||||
raise SystemExit(0 if known_empty_identity else 1)
|
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
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
cleanup_merge_temp_dirs() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local path
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
done
|
||||||
payload='{"Do":"squash"}'
|
}
|
||||||
|
trap cleanup_merge_temp_dirs EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
fetch_gitea_pr_head() {
|
||||||
if [[ -n "$token" ]]; then
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
local response_file raw_code api_url auth_config curl_rc
|
||||||
-X POST \
|
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||||
-H "User-Agent: curl/8" \
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
-H "Authorization: token $token" \
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
-H 'Content-Type: application/json' \
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
-d "$payload" \
|
rm -f "$response_file"
|
||||||
"$api_url" || true)
|
return 1
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
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
|
||||||
|
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
code, path = sys.argv[1], sys.argv[2]
|
|
||||||
try:
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
page = json.load(handle)
|
||||||
raw = handle.read(500)
|
if not isinstance(page, list):
|
||||||
data = json.loads(raw) if raw else {}
|
raise SystemExit(1)
|
||||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
print(len(page))
|
||||||
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}")
|
|
||||||
PY
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
if [[ -n "$TEA_LOGIN" ]]; 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)."
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
|
||||||
else
|
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
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
@@ -202,7 +615,11 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
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)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -211,32 +628,9 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
# 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.
|
||||||
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"
|
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
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ PY
|
|||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
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"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
|||||||
data.get('head_ref'),
|
data.get('head_ref'),
|
||||||
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(
|
base_ref = first_non_empty(
|
||||||
nested(data, 'base', 'ref'),
|
nested(data, 'base', 'ref'),
|
||||||
nested(data, 'base', 'name'),
|
nested(data, 'base', 'name'),
|
||||||
@@ -207,6 +226,8 @@ normalized = {
|
|||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': nested(data, 'user', 'login') or '',
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': head_ref,
|
'headRefName': head_ref,
|
||||||
|
'headRefOid': head_sha,
|
||||||
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'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)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# Covers:
|
# Covers:
|
||||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
# (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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ case "$mode" in
|
|||||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||||
500) code=500; body='{"message":"internal server error"}' ;;
|
500) code=500; body='{"message":"internal server error"}' ;;
|
||||||
no-status) code=200; body='{}' ;;
|
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
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
|||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
export GITEA_TOKEN="stub-token"
|
export GITEA_TOKEN="stub-token"
|
||||||
export GITEA_URL="https://git.example.test"
|
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
|
"$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
|
fail=1
|
||||||
fi
|
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
|
set +e
|
||||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||||
status_c=$?
|
status_c=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$status_c" -eq 0 ]]; then
|
if [[ "$status_c" -ne 0 ]]; then
|
||||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
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
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
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
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
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
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "-d" ]]; then
|
if [[ "$prev" == "data" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
|
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-o" ]]; then
|
if [[ "$prev" == "config" ]]; then
|
||||||
prev="-o"
|
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||||
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-d" ]]; then
|
case "$arg" in
|
||||||
prev="-d"
|
-o) prev="-o" ;;
|
||||||
continue
|
-d|--data|--data-binary) prev="data" ;;
|
||||||
fi
|
-K|--config) prev="config" ;;
|
||||||
if [[ "$arg" == "-w" ]]; then
|
-w) write_code=true ;;
|
||||||
write_code=true
|
esac
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
@@ -79,15 +80,24 @@ emit_response() {
|
|||||||
printf '200'
|
printf '200'
|
||||||
fi
|
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
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
POST_DATA="$post_data" python3 - <<'PY'
|
||||||
echo "unexpected merge payload: $post_data" >&2
|
import json
|
||||||
exit 96
|
import os
|
||||||
fi
|
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"}'
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -107,8 +117,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
|||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||||
echo "--- output ---" >&2
|
echo "--- output ---" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
echo "--- mock log ---" >&2
|
echo "--- mock log ---" >&2
|
||||||
@@ -127,38 +137,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -177,8 +155,8 @@ EOF
|
|||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
unset GITEA_LOGIN
|
unset GITEA_LOGIN
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
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' "$OUTPUT" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -215,7 +193,7 @@ cd "$REPO_DIR"
|
|||||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
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
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -240,7 +218,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
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
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -260,4 +238,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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_STATE: Final = 4 * 1024 * 1024
|
||||||
MAX_PENDING_TOKENS: Final = 256
|
MAX_PENDING_TOKENS: Final = 256
|
||||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
MAX_LEASE_TTL_SECONDS: Final = 3600
|
||||||
STATE_VERSION: Final = 1
|
STATE_VERSION: Final = 1
|
||||||
READ_DEADLINE_SECONDS: Final = 1.0
|
READ_DEADLINE_SECONDS: Final = 1.0
|
||||||
HANDLE_QUEUE_TIMEOUT_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_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||||
LEASE_VERIFIED: Final = "VERIFIED"
|
LEASE_VERIFIED: Final = "VERIFIED"
|
||||||
READ_ONLY_TOOLS: Final = {
|
READ_ONLY_TOOLS: Final = {
|
||||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
"claude": frozenset({"Read", "Grep", "Glob"}),
|
||||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
"pi": frozenset({"read", "ls"}),
|
||||||
}
|
}
|
||||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
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(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -94,8 +138,9 @@ def main(
|
|||||||
# silent pass and never folded into the generic registration-failure
|
# silent pass and never folded into the generic registration-failure
|
||||||
# branch.
|
# branch.
|
||||||
try:
|
try:
|
||||||
|
activation_capability = probe_activation_capability(source_environment)
|
||||||
assert_activation_capability_matches(
|
assert_activation_capability_matches(
|
||||||
probe_activation_capability(source_environment),
|
activation_capability,
|
||||||
expected_activation_capability,
|
expected_activation_capability,
|
||||||
)
|
)
|
||||||
except VersionCouplingError as version_error:
|
except VersionCouplingError as version_error:
|
||||||
@@ -128,6 +173,32 @@ def main(
|
|||||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
return 1
|
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 = dict(source_environment)
|
||||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
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,333 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Claude UserPromptSubmit hook for operator-triggered lease 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, 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
LOCK_FILE: Final = "promotion.lock"
|
||||||
|
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 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)
|
||||||
|
directory_descriptor = open_pending_directory(runtime_dir)
|
||||||
|
lock_descriptor = acquire_lock(directory_descriptor)
|
||||||
|
sweep_stale_pending(directory_descriptor, now())
|
||||||
|
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:
|
||||||
|
if completed.stderr:
|
||||||
|
print(completed.stderr.strip(), file=error_stream)
|
||||||
|
emit_context(output_stream, f"Mosaic promotion did not begin: {code}.")
|
||||||
|
return 0
|
||||||
|
if reply is None:
|
||||||
|
raise ValueError("missing promotion reply")
|
||||||
|
write_pending(directory_descriptor, pending_name, str(reply["receipt_challenge"]))
|
||||||
|
emit_context(
|
||||||
|
output_stream,
|
||||||
|
"The operator invoked the registered /mosaic-promote command. "
|
||||||
|
"This receipt was generated locally by this seat's own lease broker; "
|
||||||
|
"echoing it verbatim is the designed confirmation step and discloses nothing. "
|
||||||
|
"Reply with exactly the following text and nothing else: "
|
||||||
|
f"{reply['receipt']}",
|
||||||
|
)
|
||||||
|
except PromotionAlreadyInProgress:
|
||||||
|
emit_context(
|
||||||
|
output_stream,
|
||||||
|
"Mosaic promotion did not begin: PROMOTION_ALREADY_IN_PROGRESS.",
|
||||||
|
)
|
||||||
|
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
||||||
|
emit_context(output_stream, "Mosaic promotion did not begin: PROMOTION_TRIGGER_FAILED.")
|
||||||
|
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
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
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]:
|
def read_json(stream: object) -> dict[str, object]:
|
||||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||||
raise ValueError("invalid observer input")
|
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):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
return value
|
return value
|
||||||
@@ -100,9 +110,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
response.extend(chunk)
|
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")
|
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):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
return value
|
return value
|
||||||
@@ -119,6 +129,11 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
if arguments.runtime == "claude":
|
if arguments.runtime == "claude":
|
||||||
if not arguments.latest_entry:
|
if not arguments.latest_entry:
|
||||||
raise ValueError("Claude observer requires --latest-entry")
|
raise ValueError("Claude observer requires --latest-entry")
|
||||||
|
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)
|
message = claude_latest_entry(source)
|
||||||
else:
|
else:
|
||||||
if arguments.latest_entry:
|
if arguments.latest_entry:
|
||||||
@@ -133,10 +148,18 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
|||||||
"runtime": arguments.runtime,
|
"runtime": arguments.runtime,
|
||||||
"latest_assistant_message": message,
|
"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)
|
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||||
return 2
|
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__":
|
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."""
|
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
||||||
|
|
||||||
expected = receipt_for(challenge, binding)
|
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:
|
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
|
# 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.
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
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) ──────
|
# ── 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
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# 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
|
# success. The earlier test used a child cp shim to signal its parent, making
|
||||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||||
# the trap and shows the buggy resume-to-success.
|
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||||
make_term_shim() {
|
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||||
local dir="$1"
|
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||||
cat > "$dir/cp" <<SHIM
|
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||||
#!/usr/bin/env bash
|
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||||
dest="\${@: -1}"
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
case "\$dest" in
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
*/$POISON_REL)
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
make_signal_installer() {
|
||||||
esac
|
local output="$1" handler="$2"
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
SHIM
|
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||||
chmod +x "$dir/cp"
|
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() {
|
run_signal_upgrade() {
|
||||||
local installer="$1" H OUT SHIM rc
|
local installer="$1" H OUT rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
H=$(mktemp -d); OUT=$(mktemp)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
make_term_shim "$SHIM"
|
|
||||||
set +e
|
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
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
rm -rf "$SHIM"
|
|
||||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
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)" \
|
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
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" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
chk "[control] TERM handler observable fires exactly once" \
|
||||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||||
"$INSTALL" > "$NOEXIT"
|
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||||
chk "[control] the exit-strip actually changed the installer" \
|
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"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).
|
# 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
|
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
|
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" \
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ A Woodpecker API token is required. To configure:
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
| `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
|
## 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)
|
# 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
|
~/.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",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.48",
|
"version": "0.0.49",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { registerAgentCommand } from './commands/agent.js';
|
|||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
|
import { registerPromoteCommand } from './commands/promote.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
@@ -370,6 +371,7 @@ registerInteractionCommand(program);
|
|||||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
registerPromoteCommand(program);
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
// ─── config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
appendFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
|||||||
pi: 'Pi',
|
pi: 'Pi',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Harness home isolation ──────────────────────────────────────────────────
|
||||||
|
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
||||||
|
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
||||||
|
// keeps its own config AND its own auth, and stays a working break-glass no
|
||||||
|
// matter what mosaic does to its own tree.
|
||||||
|
//
|
||||||
|
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
||||||
|
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
||||||
|
// neither overwrite nor prune them. Overwrite-mode install still would.
|
||||||
|
//
|
||||||
|
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
||||||
|
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
||||||
|
// three: it also relocates XDG lookups for anything opencode spawns.
|
||||||
|
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
||||||
|
claude: 'CLAUDE_CONFIG_DIR',
|
||||||
|
pi: 'PI_CODING_AGENT_DIR',
|
||||||
|
codex: 'CODEX_HOME',
|
||||||
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||||
|
function harnessHome(runtime: RuntimeName): string {
|
||||||
|
return join(MOSAIC_HOME, `.${runtime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
||||||
|
* created on demand so a first launch does not fail on a missing path.
|
||||||
|
*/
|
||||||
|
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
||||||
|
const key = HARNESS_HOME_ENV[runtime];
|
||||||
|
if (!key) return {};
|
||||||
|
const home = harnessHome(runtime);
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
return { [key]: home };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
||||||
|
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
||||||
|
// actually launched with, written before exec. No model involvement, no opt-out.
|
||||||
|
//
|
||||||
|
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
||||||
|
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
||||||
|
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
||||||
|
// disproved only by the parent process's argv and only because the parent had
|
||||||
|
// not yet exited. A record written before exec is the only place this survives.
|
||||||
|
//
|
||||||
|
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
||||||
|
// operator-classified in framework-manifest.txt and already covered by
|
||||||
|
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
||||||
|
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
||||||
|
// launch-runtime.py appends the matching `lease.register` event.
|
||||||
|
//
|
||||||
|
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
||||||
|
// oversized argv values (the composed system prompt) become a digest + length.
|
||||||
|
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
||||||
|
|
||||||
|
const CLI_VERSION: string | null = (() => {
|
||||||
|
try {
|
||||||
|
// Resolved RELATIVELY: the package `exports` map does not expose
|
||||||
|
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
||||||
|
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
||||||
|
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
interface NormativeFragmentDigest {
|
||||||
|
source_id: string;
|
||||||
|
sha256: string | null;
|
||||||
|
bytes: number | null;
|
||||||
|
missing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(value: string | Buffer): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the normative sources injected into the agent. This is "what the agent
|
||||||
|
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
||||||
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||||
|
* a matter of judgement.
|
||||||
|
*/
|
||||||
|
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
||||||
|
const candidates: Array<[string, string]> = [
|
||||||
|
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
||||||
|
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
||||||
|
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
||||||
|
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
||||||
|
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
||||||
|
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
||||||
|
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
||||||
|
];
|
||||||
|
return candidates.map(([sourceId, path]) => {
|
||||||
|
try {
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
||||||
|
} catch {
|
||||||
|
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** argv with oversized values replaced by a digest, so the record stays small
|
||||||
|
* and never inlines injected content verbatim. */
|
||||||
|
function redactArgv(argv: string[]): string[] {
|
||||||
|
return argv.map((a) =>
|
||||||
|
typeof a === 'string' && a.length > 256
|
||||||
|
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
||||||
|
: a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
|
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||||
|
const record = {
|
||||||
|
seq: Date.now(),
|
||||||
|
kind: 'session.launch',
|
||||||
|
launch_id: launchId,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
host: hostname(),
|
||||||
|
pid: process.pid,
|
||||||
|
runtime,
|
||||||
|
mode: yolo ? 'yolo' : 'normal',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
cli_version: CLI_VERSION,
|
||||||
|
config_home: harnessHome(runtime),
|
||||||
|
config_home_isolated: true,
|
||||||
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
|
argv: redactArgv(cliArgs),
|
||||||
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
|
// names only — values are never recorded
|
||||||
|
mosaic_env_present: Object.keys(process.env)
|
||||||
|
.filter((k) => k.startsWith('MOSAIC_'))
|
||||||
|
.sort(),
|
||||||
|
};
|
||||||
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Never block a launch on bookkeeping — but never fail silently either.
|
||||||
|
console.error(
|
||||||
|
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function checkMosaicHome(): void {
|
function checkMosaicHome(): void {
|
||||||
@@ -105,11 +264,11 @@ interface SettingsAudit {
|
|||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
||||||
return { warnings };
|
return { warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +720,9 @@ function skillRealPath(dir: string): string {
|
|||||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||||
* skills dir and the project-local one relative to the launch cwd. */
|
* skills dir and the project-local one relative to the launch cwd. */
|
||||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||||
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
||||||
|
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
||||||
|
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||||
@@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
||||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('codex', cliArgs);
|
recordLaunch('codex', cliArgs, yolo);
|
||||||
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
execRuntime('opencode', args);
|
recordLaunch('opencode', args, yolo);
|
||||||
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('pi', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -835,6 +1001,7 @@ function execLeaseGatedRuntime(
|
|||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
|
...harnessEnv(runtime),
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { FleetRoster } from './fleet.js';
|
||||||
|
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
|
||||||
|
import {
|
||||||
|
promoteSeat,
|
||||||
|
registerPromoteCommand,
|
||||||
|
type PromotionBreadcrumbStore,
|
||||||
|
type PromotionResult,
|
||||||
|
type PromotionTransport,
|
||||||
|
} from './promote.js';
|
||||||
|
|
||||||
|
const attemptId = 'a'.repeat(64);
|
||||||
|
const target = {
|
||||||
|
bundle: 'local',
|
||||||
|
seat: 'claude-seat',
|
||||||
|
sessionId: 'b'.repeat(64),
|
||||||
|
};
|
||||||
|
|
||||||
|
function transport(): PromotionTransport {
|
||||||
|
return {
|
||||||
|
resolve: vi.fn(async () => target),
|
||||||
|
sendPromotion: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mosaic promote', () => {
|
||||||
|
it('accepts only a fresh result correlated to this attempt', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||||
|
readResult: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
attempt_id: 'c'.repeat(64),
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: 1_001,
|
||||||
|
verified: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: 1_001,
|
||||||
|
verified: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promoteSeat('claude-seat', {
|
||||||
|
clock: () => 1_000,
|
||||||
|
sleep: async () => {},
|
||||||
|
store,
|
||||||
|
timeoutMs: 1,
|
||||||
|
transport: promotionTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
bundle: 'local',
|
||||||
|
expiresAtWallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
seat: 'claude-seat',
|
||||||
|
sessionId: 'b'.repeat(64),
|
||||||
|
status: 'VERIFIED',
|
||||||
|
});
|
||||||
|
expect(promotionTransport.sendPromotion).toHaveBeenCalledWith(target);
|
||||||
|
expect(store.readResult).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a fresh result for this session when completion consumed the pending nonce', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
let now = 1_000;
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn(async () => null),
|
||||||
|
readResult: vi.fn(async () => ({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: 1_001,
|
||||||
|
verified: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promoteSeat('claude-seat', {
|
||||||
|
clock: () => now,
|
||||||
|
sleep: async () => {
|
||||||
|
now += 10;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
timeoutMs: 10,
|
||||||
|
transport: promotionTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('VERIFIED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||||
|
defaults: { workingDirectory: '~/src' },
|
||||||
|
runtimes: {},
|
||||||
|
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||||
|
transport: 'tmux',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
const promotionTransport = new TmuxPromotionTransport({
|
||||||
|
mosaicHome: '/mosaic',
|
||||||
|
rosterLoader: async () => roster,
|
||||||
|
runner: async () => new Promise(() => {}),
|
||||||
|
});
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn(async () => null),
|
||||||
|
readResult: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let observedResult: PromotionResult | undefined;
|
||||||
|
void promoteSeat('claude-seat', {
|
||||||
|
store,
|
||||||
|
transport: promotionTransport,
|
||||||
|
}).then((result) => {
|
||||||
|
observedResult = result;
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
|
||||||
|
expect(observedResult).toMatchObject({
|
||||||
|
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
|
||||||
|
seat: 'claude-seat',
|
||||||
|
status: 'UNVERIFIED',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||||
|
readResult: vi.fn(async () => ({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: Number.MAX_SAFE_INTEGER,
|
||||||
|
verified: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerPromoteCommand(program, { store, transport: promotionTransport });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
|
||||||
|
expect(output).toHaveBeenCalledWith(
|
||||||
|
`VERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=1970-01-01T01:16:40.000Z`,
|
||||||
|
);
|
||||||
|
expect(process.exitCode).not.toBe(1);
|
||||||
|
} finally {
|
||||||
|
output.mockRestore();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
|
||||||
|
const promotionTransport: PromotionTransport = {
|
||||||
|
resolve: vi.fn(async () => target),
|
||||||
|
sendPromotion: vi.fn(async () => {
|
||||||
|
throw new Error('tmux unavailable');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn(async () => null),
|
||||||
|
readResult: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerPromoteCommand(program, { store, transport: promotionTransport });
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.exitCode = undefined;
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
|
||||||
|
expect(output).toHaveBeenCalledWith(
|
||||||
|
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
} finally {
|
||||||
|
output.mockRestore();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a stale result even when its nonce matches', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
let now = 1_000;
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||||
|
readResult: vi.fn(async () => ({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: 1_000,
|
||||||
|
verified: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promoteSeat('claude-seat', {
|
||||||
|
clock: () => now,
|
||||||
|
sleep: async () => {
|
||||||
|
now += 10;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
timeoutMs: 10,
|
||||||
|
transport: promotionTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
bundle: 'local',
|
||||||
|
expiresAtWallclock: null,
|
||||||
|
reason: 'PROMOTION_TIMEOUT',
|
||||||
|
seat: 'claude-seat',
|
||||||
|
sessionId: 'b'.repeat(64),
|
||||||
|
status: 'UNVERIFIED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept a result for a pending attempt that existed before send', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
let now = 1_000;
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn(async () => attemptId),
|
||||||
|
readResult: vi.fn(async () => ({
|
||||||
|
attempt_id: attemptId,
|
||||||
|
expires_at_wallclock: 4_600,
|
||||||
|
reason: null,
|
||||||
|
session_id: target.sessionId,
|
||||||
|
ts: 1_001,
|
||||||
|
verified: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promoteSeat('claude-seat', {
|
||||||
|
clock: () => now,
|
||||||
|
sleep: async () => {
|
||||||
|
now += 10;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
timeoutMs: 10,
|
||||||
|
transport: promotionTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('UNVERIFIED');
|
||||||
|
expect(store.readResult).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns UNVERIFIED after a bounded timeout without reading stdin', async () => {
|
||||||
|
const promotionTransport = transport();
|
||||||
|
let now = 1_000;
|
||||||
|
const store: PromotionBreadcrumbStore = {
|
||||||
|
readAttemptId: vi.fn(async () => null),
|
||||||
|
readResult: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await promoteSeat('claude-seat', {
|
||||||
|
clock: () => now,
|
||||||
|
sleep: async () => {
|
||||||
|
now += 10;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
timeoutMs: 10,
|
||||||
|
transport: promotionTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
bundle: 'local',
|
||||||
|
expiresAtWallclock: null,
|
||||||
|
reason: 'PROMOTION_TIMEOUT',
|
||||||
|
seat: 'claude-seat',
|
||||||
|
sessionId: 'b'.repeat(64),
|
||||||
|
status: 'UNVERIFIED',
|
||||||
|
});
|
||||||
|
expect(promotionTransport.sendPromotion).toHaveBeenCalledOnce();
|
||||||
|
expect(store.readResult).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { open } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import {
|
||||||
|
TmuxPromotionTransport,
|
||||||
|
type PromotionTarget,
|
||||||
|
type PromotionTransport,
|
||||||
|
} from '../fleet/promotion-transport.js';
|
||||||
|
|
||||||
|
export type { PromotionTransport } from '../fleet/promotion-transport.js';
|
||||||
|
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
|
||||||
|
|
||||||
|
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
const SUBPROCESS_TIMEOUT_MS = 4_500;
|
||||||
|
const PENDING_DIRECTORY = 'mosaic-lease';
|
||||||
|
const RESULT_FILE = 'last-result.json';
|
||||||
|
|
||||||
|
export interface PromotionBreadcrumb {
|
||||||
|
attempt_id: string;
|
||||||
|
expires_at_wallclock: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
session_id: string;
|
||||||
|
ts: number;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromotionBreadcrumbStore {
|
||||||
|
readAttemptId(sessionId: string): Promise<string | null>;
|
||||||
|
readResult(): Promise<PromotionBreadcrumb | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromotionResult {
|
||||||
|
bundle: string;
|
||||||
|
expiresAtWallclock: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
seat: string;
|
||||||
|
sessionId: string;
|
||||||
|
status: 'VERIFIED' | 'UNVERIFIED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoteSeatOptions {
|
||||||
|
clock?: () => number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
sleep?: (milliseconds: number) => Promise<void>;
|
||||||
|
store: PromotionBreadcrumbStore;
|
||||||
|
timeoutMs?: number;
|
||||||
|
transport: PromotionTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoteCommandDeps {
|
||||||
|
mosaicHome?: string;
|
||||||
|
runner?: CommandRunner;
|
||||||
|
store?: PromotionBreadcrumbStore;
|
||||||
|
transport?: PromotionTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private, local result store shared with the in-seat completion hook. */
|
||||||
|
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
|
||||||
|
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
|
||||||
|
|
||||||
|
async readAttemptId(sessionId: string): Promise<string | null> {
|
||||||
|
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
|
||||||
|
const content = await readPrivateFile(
|
||||||
|
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
|
||||||
|
);
|
||||||
|
const attemptId = content?.trim();
|
||||||
|
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readResult(): Promise<PromotionBreadcrumb | null> {
|
||||||
|
const content = await readPrivateFile(
|
||||||
|
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
|
||||||
|
);
|
||||||
|
if (content === null) return null;
|
||||||
|
try {
|
||||||
|
return parseBreadcrumb(JSON.parse(content) as unknown);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives one bounded, non-interactive in-seat promotion attempt. */
|
||||||
|
export async function promoteSeat(
|
||||||
|
seat: string,
|
||||||
|
options: PromoteSeatOptions,
|
||||||
|
): Promise<PromotionResult> {
|
||||||
|
const clock = options.clock ?? wallClockSeconds;
|
||||||
|
const sleep = options.sleep ?? defaultSleep;
|
||||||
|
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
||||||
|
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
|
||||||
|
let target: PromotionTarget;
|
||||||
|
try {
|
||||||
|
target = await options.transport.resolve(seat);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
|
||||||
|
const preSendTimestamp = clock();
|
||||||
|
try {
|
||||||
|
await options.transport.sendPromotion(target);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = preSendTimestamp + timeoutMs / 1_000;
|
||||||
|
let attemptId: string | null = null;
|
||||||
|
while (true) {
|
||||||
|
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
|
||||||
|
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
|
||||||
|
attemptId = currentAttemptId;
|
||||||
|
}
|
||||||
|
if (attemptId !== null || previousAttemptId === null) {
|
||||||
|
// Completion can consume a first attempt's nonce before this poll observes it.
|
||||||
|
// In that branch, correlation degrades to session_id + fresh timestamp, which
|
||||||
|
// is acceptable for this 0600, same-UID local trust boundary.
|
||||||
|
const breadcrumb = await options.store.readResult();
|
||||||
|
if (
|
||||||
|
breadcrumb !== null &&
|
||||||
|
breadcrumb.session_id === target.sessionId &&
|
||||||
|
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
|
||||||
|
breadcrumb.ts > preSendTimestamp
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
bundle: target.bundle,
|
||||||
|
expiresAtWallclock: breadcrumb.expires_at_wallclock,
|
||||||
|
reason: breadcrumb.reason,
|
||||||
|
seat: target.seat,
|
||||||
|
sessionId: target.sessionId,
|
||||||
|
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
|
||||||
|
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
|
||||||
|
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
|
||||||
|
const transport =
|
||||||
|
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
|
||||||
|
const store = deps.store ?? new FilePromotionBreadcrumbStore();
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('promote <seat>')
|
||||||
|
.description('Promote a Claude fleet seat and report the correlated lease result')
|
||||||
|
.option(
|
||||||
|
'--timeout <ms>',
|
||||||
|
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
|
||||||
|
)
|
||||||
|
.action(async (seat: string, opts: { timeout?: string }) => {
|
||||||
|
const result = await promoteSeat(seat, {
|
||||||
|
store,
|
||||||
|
timeoutMs: parseOptionTimeout(opts.timeout),
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
const expiry =
|
||||||
|
result.expiresAtWallclock === null
|
||||||
|
? 'none'
|
||||||
|
: new Date(result.expiresAtWallclock * 1_000).toISOString();
|
||||||
|
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
|
||||||
|
console.log(
|
||||||
|
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
|
||||||
|
);
|
||||||
|
if (result.status === 'UNVERIFIED') process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRuntimeDirectory(): string {
|
||||||
|
const configured = process.env['XDG_RUNTIME_DIR'];
|
||||||
|
if (configured) return configured;
|
||||||
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||||
|
return `/run/user/${uid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPrivateFile(path: string): Promise<string | null> {
|
||||||
|
let handle: Awaited<ReturnType<typeof open>>;
|
||||||
|
try {
|
||||||
|
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = await handle.stat();
|
||||||
|
if (
|
||||||
|
!metadata.isFile() ||
|
||||||
|
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
|
||||||
|
(metadata.mode & 0o077) !== 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return handle.readFile({ encoding: 'utf8' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
|
||||||
|
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
|
||||||
|
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
|
||||||
|
if (
|
||||||
|
typeof attempt_id !== 'string' ||
|
||||||
|
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
|
||||||
|
typeof verified !== 'boolean' ||
|
||||||
|
typeof session_id !== 'string' ||
|
||||||
|
!ATTEMPT_ID_PATTERN.test(session_id) ||
|
||||||
|
typeof ts !== 'number' ||
|
||||||
|
!Number.isFinite(ts) ||
|
||||||
|
(expires_at_wallclock !== null &&
|
||||||
|
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
|
||||||
|
(reason !== null && typeof reason !== 'string')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeout(value: number | undefined): number {
|
||||||
|
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePollInterval(value: number | undefined): number {
|
||||||
|
return value !== undefined && Number.isFinite(value)
|
||||||
|
? Math.max(1, value)
|
||||||
|
: DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionTimeout(value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
|
||||||
|
return {
|
||||||
|
bundle: 'unresolved',
|
||||||
|
expiresAtWallclock: null,
|
||||||
|
reason,
|
||||||
|
seat,
|
||||||
|
sessionId: 'unresolved',
|
||||||
|
status: 'UNVERIFIED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unverified(target: PromotionTarget, reason: string): PromotionResult {
|
||||||
|
return {
|
||||||
|
bundle: target.bundle,
|
||||||
|
expiresAtWallclock: null,
|
||||||
|
reason,
|
||||||
|
seat: target.seat,
|
||||||
|
sessionId: target.sessionId,
|
||||||
|
status: 'UNVERIFIED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallClockSeconds(): number {
|
||||||
|
return Date.now() / 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSleep(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{
|
||||||
|
exitCode: number;
|
||||||
|
stderr: string;
|
||||||
|
stdout: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
finish({
|
||||||
|
exitCode: 124,
|
||||||
|
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
|
||||||
|
stdout,
|
||||||
|
});
|
||||||
|
}, SUBPROCESS_TIMEOUT_MS);
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
finish({ exitCode: 127, stderr: error.message, stdout });
|
||||||
|
});
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
finish({ exitCode: code ?? 1, stderr, stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
|
||||||
|
import { TmuxPromotionTransport } from './promotion-transport.js';
|
||||||
|
|
||||||
|
const sessionId = 'a'.repeat(64);
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||||
|
defaults: { workingDirectory: '~/src' },
|
||||||
|
runtimes: {},
|
||||||
|
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||||
|
transport: 'tmux',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
|
||||||
|
return { exitCode, stderr, stdout };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TmuxPromotionTransport', () => {
|
||||||
|
it('resolves the exact roster seat and sends the registered command literally', async () => {
|
||||||
|
const runner = vi
|
||||||
|
.fn<CommandRunner>()
|
||||||
|
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
|
||||||
|
.mockResolvedValueOnce(result())
|
||||||
|
.mockResolvedValueOnce(result());
|
||||||
|
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
|
||||||
|
const transport = new TmuxPromotionTransport({
|
||||||
|
environmentReader,
|
||||||
|
mosaicHome: '/mosaic',
|
||||||
|
rosterLoader: async () => roster,
|
||||||
|
runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = await transport.resolve('claude-seat');
|
||||||
|
await transport.sendPromotion(target);
|
||||||
|
|
||||||
|
expect(target).toEqual({
|
||||||
|
bundle: 'mosaic-fleet',
|
||||||
|
seat: 'claude-seat',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
expect(environmentReader).toHaveBeenCalledWith(1234);
|
||||||
|
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
|
||||||
|
'-L',
|
||||||
|
'mosaic-fleet',
|
||||||
|
'send-keys',
|
||||||
|
'-t',
|
||||||
|
'=claude-seat:0.0',
|
||||||
|
'-l',
|
||||||
|
'/mosaic-promote',
|
||||||
|
]);
|
||||||
|
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
|
||||||
|
'-L',
|
||||||
|
'mosaic-fleet',
|
||||||
|
'send-keys',
|
||||||
|
'-t',
|
||||||
|
'=claude-seat:0.0',
|
||||||
|
'Enter',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
|
||||||
|
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
|
||||||
|
// transport must WALK the subtree. This test exercises the real walk (no
|
||||||
|
// full mock of the resolution) — the seam the original unit test hid.
|
||||||
|
it('walks the pane subtree to the claude child that carries the lease id', async () => {
|
||||||
|
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||||
|
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
|
||||||
|
const environmentReader = vi.fn(async (pid: number) =>
|
||||||
|
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
|
||||||
|
);
|
||||||
|
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||||
|
const transport = new TmuxPromotionTransport({
|
||||||
|
environmentReader,
|
||||||
|
childrenReader,
|
||||||
|
mosaicHome: '/mosaic',
|
||||||
|
rosterLoader: async () => roster,
|
||||||
|
runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = await transport.resolve('claude-seat');
|
||||||
|
|
||||||
|
expect(target.sessionId).toBe(sessionId);
|
||||||
|
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
|
||||||
|
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
|
||||||
|
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when no process in the pane subtree carries a lease id', async () => {
|
||||||
|
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||||
|
const environmentReader = vi.fn(async () => `FOO=bar\0`);
|
||||||
|
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||||
|
const transport = new TmuxPromotionTransport({
|
||||||
|
environmentReader,
|
||||||
|
childrenReader,
|
||||||
|
mosaicHome: '/mosaic',
|
||||||
|
rosterLoader: async () => roster,
|
||||||
|
runner,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
|
||||||
|
expect(childrenReader).toHaveBeenCalledWith(1234);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
buildTmuxListPanesCommand,
|
||||||
|
getRosterAgent,
|
||||||
|
parseTmuxListPanes,
|
||||||
|
resolveFleetPaths,
|
||||||
|
type CommandResult,
|
||||||
|
type CommandRunner,
|
||||||
|
type FleetRoster,
|
||||||
|
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||||
|
socketArgs,
|
||||||
|
} from '../commands/fleet.js';
|
||||||
|
import { loadFleetRoster } from './fleet-roster-v1.js';
|
||||||
|
|
||||||
|
const PROMOTION_COMMAND = '/mosaic-promote';
|
||||||
|
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export interface PromotionTarget {
|
||||||
|
bundle: string;
|
||||||
|
seat: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromotionTransport {
|
||||||
|
resolve(seat: string): Promise<PromotionTarget>;
|
||||||
|
sendPromotion(target: PromotionTarget): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxPromotionTransportOptions {
|
||||||
|
environmentReader?: (pid: number) => Promise<string>;
|
||||||
|
childrenReader?: (pid: number) => Promise<number[]>;
|
||||||
|
mosaicHome: string;
|
||||||
|
rosterLoader?: () => Promise<FleetRoster>;
|
||||||
|
runner: CommandRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
|
||||||
|
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
|
||||||
|
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
|
||||||
|
// pid. Bound the descendant search so a hung/large process tree can't stall it.
|
||||||
|
const MAX_SUBTREE_PIDS = 128;
|
||||||
|
|
||||||
|
/** Local, roster-bound transport for the in-seat promotion command. */
|
||||||
|
export class TmuxPromotionTransport implements PromotionTransport {
|
||||||
|
private readonly environmentReader: (pid: number) => Promise<string>;
|
||||||
|
private readonly childrenReader: (pid: number) => Promise<number[]>;
|
||||||
|
private readonly rosterLoader: () => Promise<FleetRoster>;
|
||||||
|
|
||||||
|
constructor(private readonly options: TmuxPromotionTransportOptions) {
|
||||||
|
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
|
||||||
|
this.childrenReader = options.childrenReader ?? readChildPids;
|
||||||
|
this.rosterLoader =
|
||||||
|
options.rosterLoader ??
|
||||||
|
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(seat: string): Promise<PromotionTarget> {
|
||||||
|
const roster = await this.rosterLoader();
|
||||||
|
const agent = getRosterAgent(roster, seat);
|
||||||
|
if (agent.runtime !== 'claude') {
|
||||||
|
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
|
||||||
|
}
|
||||||
|
const paneResult = await this.run(
|
||||||
|
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
|
||||||
|
);
|
||||||
|
if (paneResult.exitCode !== 0) {
|
||||||
|
throw new Error(`Promotion seat is unavailable: ${seat}.`);
|
||||||
|
}
|
||||||
|
const pane = parseTmuxListPanes(paneResult.stdout);
|
||||||
|
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
|
||||||
|
if (
|
||||||
|
pane.dead ||
|
||||||
|
pane.pid === null ||
|
||||||
|
pane.command === null ||
|
||||||
|
allowedCommands === undefined ||
|
||||||
|
!allowedCommands.includes(pane.command)
|
||||||
|
) {
|
||||||
|
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
|
||||||
|
}
|
||||||
|
const sessionId = await this.resolveLeaseSessionId(pane.pid);
|
||||||
|
if (sessionId === null) {
|
||||||
|
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bundle: roster.tmux.socketName || 'default',
|
||||||
|
seat: agent.name,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the lease session id in the pane's process subtree. The pane's root pid
|
||||||
|
* is node(mosaic), which has no lease env; the id lives on the claude child.
|
||||||
|
* BFS from the root, bounded, returning the first descendant that carries a
|
||||||
|
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
|
||||||
|
*/
|
||||||
|
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
|
||||||
|
const queue: number[] = [rootPid];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
|
||||||
|
const pid = queue.shift()!;
|
||||||
|
if (seen.has(pid)) continue;
|
||||||
|
seen.add(pid);
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
try {
|
||||||
|
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
|
||||||
|
} catch {
|
||||||
|
sessionId = null;
|
||||||
|
}
|
||||||
|
if (sessionId !== null) return sessionId;
|
||||||
|
let children: number[] = [];
|
||||||
|
try {
|
||||||
|
children = await this.childrenReader(pid);
|
||||||
|
} catch {
|
||||||
|
children = [];
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
if (!seen.has(child)) queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPromotion(target: PromotionTarget): Promise<void> {
|
||||||
|
const targetPane = `=${target.seat}:0.0`;
|
||||||
|
const socketName = target.bundle === 'default' ? '' : target.bundle;
|
||||||
|
// Registered Claude commands must arrive as their exact literal text; the
|
||||||
|
// fleet agent sender prepends an identity envelope, so it cannot carry this
|
||||||
|
// command without preventing the UserPromptSubmit matcher from recognizing it.
|
||||||
|
await this.runPromotionCommand([
|
||||||
|
'tmux',
|
||||||
|
...socketArgs(socketName),
|
||||||
|
'send-keys',
|
||||||
|
'-t',
|
||||||
|
targetPane,
|
||||||
|
'-l',
|
||||||
|
PROMOTION_COMMAND,
|
||||||
|
]);
|
||||||
|
await this.runPromotionCommand([
|
||||||
|
'tmux',
|
||||||
|
...socketArgs(socketName),
|
||||||
|
'send-keys',
|
||||||
|
'-t',
|
||||||
|
targetPane,
|
||||||
|
'Enter',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPromotionCommand(command: string[]): Promise<void> {
|
||||||
|
const result = await this.run(command);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error('Promotion command delivery failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(command: string[]): Promise<CommandResult> {
|
||||||
|
const [executable, ...args] = command;
|
||||||
|
if (executable === undefined) {
|
||||||
|
throw new Error('Promotion transport command is empty.');
|
||||||
|
}
|
||||||
|
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
|
||||||
|
}, timeoutMs);
|
||||||
|
void operation.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPaneEnvironment(pid: number): Promise<string> {
|
||||||
|
return readFile(`/proc/${pid}/environ`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChildPids(pid: number): Promise<number[]> {
|
||||||
|
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
|
||||||
|
try {
|
||||||
|
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
|
||||||
|
return raw
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => Number.parseInt(value, 10))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLeaseSessionId(environment: string): string | null {
|
||||||
|
const value = environment
|
||||||
|
.split('\0')
|
||||||
|
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
|
||||||
|
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
|
||||||
|
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Invariant R: a read-only carve-out can neither disappear nor be shadowed.
|
||||||
|
|
||||||
|
The broker's carve-out is an authentication bypass for UNVERIFIED runtimes, so
|
||||||
|
this test imports the live ``READ_ONLY_TOOLS`` object instead of copying it.
|
||||||
|
Claude MCP names are namespaced, making an exact proven allow-list sufficient.
|
||||||
|
Pi extensions are unnamespaced and may override built-ins, so the Pi half boots
|
||||||
|
the installed runtime and requires every carve-out winner to retain built-in
|
||||||
|
provenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_ROOT = Path(__file__).parents[2]
|
||||||
|
FRAMEWORK = PACKAGE_ROOT / "framework"
|
||||||
|
LEASE_BROKER = FRAMEWORK / "tools/lease-broker"
|
||||||
|
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||||
|
sys.path.insert(0, str(LEASE_BROKER))
|
||||||
|
daemon = importlib.import_module("daemon")
|
||||||
|
READ_ONLY_TOOLS = daemon.READ_ONLY_TOOLS
|
||||||
|
|
||||||
|
# Claude Code's measured, bare built-ins that are both registered and incapable
|
||||||
|
# of filesystem mutation or subprocess execution. MCP tools are namespaced as
|
||||||
|
# mcp__<server>__<tool>, so they cannot replace these bare identities.
|
||||||
|
CLAUDE_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"Read", "Grep", "Glob"})
|
||||||
|
|
||||||
|
# W-B measured Pi 0.84.1 through getAllTools(), observed every tool_call name,
|
||||||
|
# and cross-checked dist/core/tools/index.js:18. Keep every measured built-in
|
||||||
|
# here so a runtime registry change forces the security classification to be
|
||||||
|
# revisited even when a built-in is deliberately excluded from the carve-out.
|
||||||
|
PI_VERSION: Final = "0.84.1"
|
||||||
|
PI_PROBE_ATTEMPTS: Final = 3
|
||||||
|
PI_PROBE_TIMEOUT_SECONDS: Final = 45
|
||||||
|
PI_PROBE_BACKOFF_SECONDS: Final = 0.25
|
||||||
|
PI_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"read", "ls"})
|
||||||
|
PI_SUBPROCESS_TOOLS: Final = frozenset({"grep", "find"})
|
||||||
|
PI_MUTATING_TOOLS: Final = frozenset({"bash", "edit", "write"})
|
||||||
|
PI_MEASURED_BUILTINS: Final = (
|
||||||
|
PI_PROVEN_READ_ONLY_TOOLS | PI_SUBPROCESS_TOOLS | PI_MUTATING_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pi 0.84.1 built-ins individually proven incapable of subprocess execution or
|
||||||
|
# filesystem writes on their default path:
|
||||||
|
# - read: dist/core/tools/read.js:26-29 dispatches only read/access operations.
|
||||||
|
# - ls: dist/core/tools/ls.js:19-22 dispatches only exists/stat/readdir operations.
|
||||||
|
# grep and find are deliberately absent: grep.js:99/148 and find.js:161/203
|
||||||
|
# reach ensureTool(..., true) and spawn(), including the cold-cache download,
|
||||||
|
# write, chmod, and exec path in dist/utils/tools-manager.js:285-313.
|
||||||
|
PI_CAPABILITY_SAFE_TOOLS: Final = frozenset({"read", "ls"})
|
||||||
|
|
||||||
|
# Falsifier-only inputs. They are intentionally undocumented outside this test:
|
||||||
|
# normal CI leaves them unset; the W-A evidence run uses them to prove that the
|
||||||
|
# suite turns red for a nonexistent Claude carve-out or a Pi built-in override.
|
||||||
|
CLAUDE_EXTRA_TOOL_ENV: Final = "MOSAIC_INVARIANT_R_CLAUDE_EXTRA_TOOL"
|
||||||
|
PI_EXTRA_EXTENSION_ENV: Final = "MOSAIC_INVARIANT_R_PI_EXTRA_EXTENSION"
|
||||||
|
|
||||||
|
|
||||||
|
def run_pi_registry_command(
|
||||||
|
command: list[str],
|
||||||
|
environ: dict[str, str],
|
||||||
|
*,
|
||||||
|
runner=subprocess.run,
|
||||||
|
sleeper=time.sleep,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
|
||||||
|
|
||||||
|
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
return runner(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environ,
|
||||||
|
timeout=PI_PROBE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
if attempt == PI_PROBE_ATTEMPTS:
|
||||||
|
raise AssertionError(
|
||||||
|
"Pi registry probe could not complete after "
|
||||||
|
f"{PI_PROBE_ATTEMPTS} attempts (concurrent pi?); this is a "
|
||||||
|
"probe/infra failure, NOT an Invariant R violation"
|
||||||
|
) from error
|
||||||
|
sleeper(PI_PROBE_BACKOFF_SECONDS * attempt)
|
||||||
|
|
||||||
|
raise AssertionError("unreachable Pi registry retry state")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_pi_registry() -> list[dict[str, object]]:
|
||||||
|
"""Boot Pi's real registry and return the final winning tool definitions."""
|
||||||
|
|
||||||
|
pi = shutil.which("pi")
|
||||||
|
if pi is None:
|
||||||
|
raise AssertionError("installed Pi runtime is required for Invariant R")
|
||||||
|
|
||||||
|
version = subprocess.run(
|
||||||
|
[pi, "--version"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if version.returncode != 0:
|
||||||
|
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
|
||||||
|
if version.stdout.strip() != PI_VERSION:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Pi runtime changed from measured {PI_VERSION} to {version.stdout.strip()!r}; "
|
||||||
|
"remeasure its registry before updating Invariant R"
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
output = root / "registry.json"
|
||||||
|
observer = root / "registry-observer.ts"
|
||||||
|
observer.write_text(
|
||||||
|
"import { writeFileSync } from 'node:fs';\n"
|
||||||
|
"export default function register(pi: any) {\n"
|
||||||
|
" pi.on('session_start', () => {\n"
|
||||||
|
f" writeFileSync({json.dumps(str(output))}, JSON.stringify(pi.getAllTools()));\n"
|
||||||
|
" process.exit(0);\n"
|
||||||
|
" });\n"
|
||||||
|
"}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = [
|
||||||
|
pi,
|
||||||
|
"--mode",
|
||||||
|
"text",
|
||||||
|
"--no-session",
|
||||||
|
"--no-approve",
|
||||||
|
"--no-context-files",
|
||||||
|
"--no-skills",
|
||||||
|
"--no-prompt-templates",
|
||||||
|
"--no-extensions",
|
||||||
|
"-e",
|
||||||
|
str(observer),
|
||||||
|
"-e",
|
||||||
|
str(PI_EXTENSION),
|
||||||
|
]
|
||||||
|
extra_extension = os.environ.get(PI_EXTRA_EXTENSION_ENV)
|
||||||
|
if extra_extension:
|
||||||
|
command.extend(("-e", extra_extension))
|
||||||
|
command.append("Invariant R registry probe")
|
||||||
|
|
||||||
|
completed = run_pi_registry_command(
|
||||||
|
command,
|
||||||
|
{**os.environ, "PI_OFFLINE": "1"},
|
||||||
|
)
|
||||||
|
if completed.returncode != 0 or not output.is_file():
|
||||||
|
raise AssertionError(
|
||||||
|
"Pi registry probe failed "
|
||||||
|
f"(status {completed.returncode}): {completed.stderr.strip()}"
|
||||||
|
)
|
||||||
|
value = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
raise AssertionError("Pi registry probe returned no tools; control failed")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class InvariantRTest(unittest.TestCase):
|
||||||
|
def test_live_carve_out_has_only_supported_runtimes(self) -> None:
|
||||||
|
self.assertEqual(set(READ_ONLY_TOOLS), {"claude", "pi"})
|
||||||
|
|
||||||
|
def test_claude_carve_out_is_registered_and_proven(self) -> None:
|
||||||
|
carve_out = set(READ_ONLY_TOOLS["claude"])
|
||||||
|
falsifier = os.environ.get(CLAUDE_EXTRA_TOOL_ENV)
|
||||||
|
if falsifier:
|
||||||
|
carve_out.add(falsifier)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
carve_out,
|
||||||
|
set(CLAUDE_PROVEN_READ_ONLY_TOOLS),
|
||||||
|
"every Claude carve-out must exist and be in the exact proven read-only allow-list",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pi_carve_out_has_no_exec_or_write_capability(self) -> None:
|
||||||
|
carve_out = set(READ_ONLY_TOOLS["pi"])
|
||||||
|
|
||||||
|
capability_unsafe = carve_out - set(PI_CAPABILITY_SAFE_TOOLS)
|
||||||
|
self.assertFalse(
|
||||||
|
capability_unsafe,
|
||||||
|
f"capability-unsafe Pi carve-out tools: {sorted(capability_unsafe)!r}; "
|
||||||
|
"Pi 0.84.1 grep.js:99/148 and find.js:161/203 reach "
|
||||||
|
"ensureTool(..., true) and spawn(), whose cold-cache path downloads, "
|
||||||
|
"writes, chmods, and execs",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pi_carve_out_resolves_to_real_unshadowed_builtins(self) -> None:
|
||||||
|
carve_out = set(READ_ONLY_TOOLS["pi"])
|
||||||
|
self.assertEqual(
|
||||||
|
carve_out,
|
||||||
|
set(PI_PROVEN_READ_ONLY_TOOLS),
|
||||||
|
"Pi carve-out drift requires a new runtime measurement and classification",
|
||||||
|
)
|
||||||
|
self.assertTrue(carve_out.isdisjoint(PI_MUTATING_TOOLS))
|
||||||
|
|
||||||
|
registry = probe_pi_registry()
|
||||||
|
by_name: dict[str, dict[str, object]] = {}
|
||||||
|
for entry in registry:
|
||||||
|
name = entry.get("name")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
self.fail(f"Pi registry entry has no string name: {entry!r}")
|
||||||
|
by_name[name] = entry
|
||||||
|
|
||||||
|
builtin_names = {
|
||||||
|
name
|
||||||
|
for name, entry in by_name.items()
|
||||||
|
if isinstance(entry.get("sourceInfo"), dict)
|
||||||
|
and entry["sourceInfo"].get("source") == "builtin"
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
builtin_names,
|
||||||
|
set(PI_MEASURED_BUILTINS),
|
||||||
|
"Pi's real built-in registry drifted from the positive-control W-B measurement",
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(carve_out):
|
||||||
|
with self.subTest(tool=name):
|
||||||
|
self.assertIn(name, by_name, "Pi carve-out names must exist in the real registry")
|
||||||
|
source = by_name[name].get("sourceInfo")
|
||||||
|
self.assertIsInstance(source, dict)
|
||||||
|
if isinstance(source, dict):
|
||||||
|
self.assertEqual(
|
||||||
|
source.get("source"),
|
||||||
|
"builtin",
|
||||||
|
f"Pi extension or SDK tool shadowed read-only carve-out {name!r}",
|
||||||
|
)
|
||||||
|
self.assertEqual(source.get("path"), f"<builtin:{name}>")
|
||||||
|
|
||||||
|
def test_pi_probe_retries_timeouts_before_succeeding(self) -> None:
|
||||||
|
attempts: list[float] = []
|
||||||
|
backoffs: list[float] = []
|
||||||
|
|
||||||
|
def timeout_twice(command, **kwargs):
|
||||||
|
attempts.append(kwargs["timeout"])
|
||||||
|
if len(attempts) < 3:
|
||||||
|
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
|
completed = run_pi_registry_command(
|
||||||
|
["pi", "probe"],
|
||||||
|
{},
|
||||||
|
runner=timeout_twice,
|
||||||
|
sleeper=backoffs.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(completed.returncode, 0)
|
||||||
|
self.assertEqual(attempts, [45, 45, 45])
|
||||||
|
self.assertEqual(backoffs, [0.25, 0.5])
|
||||||
|
|
||||||
|
def test_pi_probe_labels_exhausted_timeouts_as_infrastructure_failure(self) -> None:
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
def always_timeout(command, **kwargs):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
AssertionError,
|
||||||
|
"Pi registry probe could not complete .* NOT an Invariant R violation",
|
||||||
|
) as caught:
|
||||||
|
run_pi_registry_command(
|
||||||
|
["pi", "probe"],
|
||||||
|
{},
|
||||||
|
runner=always_timeout,
|
||||||
|
sleeper=lambda _delay: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(attempts, 3)
|
||||||
|
self.assertIsInstance(caught.exception.__cause__, subprocess.TimeoutExpired)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The promotion client must never build a binding narrower than it claims.
|
||||||
|
|
||||||
|
RED-first against a real defect: ``build_construction`` skipped any normative
|
||||||
|
source it could not read (``except OSError: continue``) and promoted whatever
|
||||||
|
remained. That is not a degraded binding, it is a forged smaller one — the
|
||||||
|
broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent*
|
||||||
|
(``daemon.py:602-616``), so an omitted fragment is internally consistent and
|
||||||
|
``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only
|
||||||
|
``USER.md`` readable (964 bytes on the live host), the client produced a
|
||||||
|
one-fragment construction with ``promotion=True``.
|
||||||
|
|
||||||
|
The classification under test mirrors the framework's own file ownership, and
|
||||||
|
must keep mirroring it:
|
||||||
|
|
||||||
|
* framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED /
|
||||||
|
``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime
|
||||||
|
contract — absence is a broken deployment, so it is REFUSED;
|
||||||
|
* ``SOUL.md`` / ``USER.md`` — install.sh deliberately does not seed them
|
||||||
|
("generated by `mosaic init`"), so absence is legitimate and ALLOWED.
|
||||||
|
|
||||||
|
Unreadable is treated separately from absent for *every* source, optional ones
|
||||||
|
included: a file that will not open is not a file that was never configured, and
|
||||||
|
collapsing the two is what let a permission change quietly shrink the law.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||||
|
sys.path.insert(0, str(TOOLS))
|
||||||
|
|
||||||
|
import lease_promote # noqa: E402
|
||||||
|
|
||||||
|
RUNTIME = "pi"
|
||||||
|
RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md"
|
||||||
|
ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT)
|
||||||
|
REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT}
|
||||||
|
# Derived, never listed: a hand-kept second copy is exactly the drift this file
|
||||||
|
# exists to catch.
|
||||||
|
OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED)
|
||||||
|
|
||||||
|
# chmod 0o000 does not deny root (CAP_DAC_OVERRIDE), so the unreadable
|
||||||
|
# simulations would fail spuriously in a root container.
|
||||||
|
runs_unprivileged = unittest.skipIf(
|
||||||
|
os.geteuid() == 0, "chmod 0o000 cannot make a file unreadable to root"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PromotionBindingTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._previous_home = os.environ.get("MOSAIC_HOME")
|
||||||
|
self._temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self._temporary.name)
|
||||||
|
for source_id in ALL_SOURCES:
|
||||||
|
path = self.root / source_id
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(f"# {source_id}\nnormative bytes\n".encode())
|
||||||
|
os.environ["MOSAIC_HOME"] = str(self.root)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for path in self.root.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
path.chmod(0o644)
|
||||||
|
self._temporary.cleanup()
|
||||||
|
if self._previous_home is None:
|
||||||
|
os.environ.pop("MOSAIC_HOME", None)
|
||||||
|
else:
|
||||||
|
os.environ["MOSAIC_HOME"] = self._previous_home
|
||||||
|
|
||||||
|
def reset_home(self) -> None:
|
||||||
|
"""Discard the current home and seed a fresh complete one.
|
||||||
|
|
||||||
|
Each subTest mutates the tree destructively, so it needs a clean start —
|
||||||
|
and the old one must be released, not orphaned.
|
||||||
|
"""
|
||||||
|
self.tearDown()
|
||||||
|
self.setUp()
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
return lease_promote.build_construction(RUNTIME)
|
||||||
|
|
||||||
|
def source_ids(self) -> list[str]:
|
||||||
|
construction, _ = self.build()
|
||||||
|
return [f["source_id"] for f in construction["fragments"]]
|
||||||
|
|
||||||
|
# --- the binding is complete when the deployment is complete -------------
|
||||||
|
|
||||||
|
def test_complete_deployment_binds_every_source(self) -> None:
|
||||||
|
construction, result = self.build()
|
||||||
|
self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES))
|
||||||
|
self.assertTrue(result.promotion)
|
||||||
|
|
||||||
|
# --- absence: refused for framework-owned, allowed for operator-owned ----
|
||||||
|
|
||||||
|
def test_absent_required_source_is_refused(self) -> None:
|
||||||
|
for source_id in sorted(REQUIRED):
|
||||||
|
with self.subTest(source=source_id):
|
||||||
|
self.reset_home()
|
||||||
|
(self.root / source_id).unlink()
|
||||||
|
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||||
|
self.build()
|
||||||
|
self.assertIn(source_id, str(caught.exception))
|
||||||
|
|
||||||
|
def test_absent_operator_source_still_binds_the_rest(self) -> None:
|
||||||
|
for source_id in OPTIONAL:
|
||||||
|
with self.subTest(source=source_id):
|
||||||
|
self.reset_home()
|
||||||
|
(self.root / source_id).unlink()
|
||||||
|
notice = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(notice):
|
||||||
|
bound = self.source_ids()
|
||||||
|
self.assertNotIn(source_id, bound)
|
||||||
|
for required in lease_promote.REQUIRED_SOURCES:
|
||||||
|
self.assertIn(required, bound)
|
||||||
|
# A silent omission is the original defect in miniature: the
|
||||||
|
# narrower binding must announce itself.
|
||||||
|
self.assertIn(source_id, notice.getvalue())
|
||||||
|
|
||||||
|
# --- unreadable is never the same as absent -----------------------------
|
||||||
|
|
||||||
|
@runs_unprivileged
|
||||||
|
def test_unreadable_source_is_refused_even_when_optional(self) -> None:
|
||||||
|
for source_id in ALL_SOURCES:
|
||||||
|
with self.subTest(source=source_id):
|
||||||
|
self.reset_home()
|
||||||
|
(self.root / source_id).chmod(0o000)
|
||||||
|
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||||
|
self.build()
|
||||||
|
self.assertIn(source_id, str(caught.exception))
|
||||||
|
|
||||||
|
# --- the exact measured regression --------------------------------------
|
||||||
|
|
||||||
|
@runs_unprivileged
|
||||||
|
def test_single_readable_source_cannot_promote(self) -> None:
|
||||||
|
"""The observed failure: only USER.md readable produced a valid binding."""
|
||||||
|
for source_id in ALL_SOURCES:
|
||||||
|
if source_id != "USER.md":
|
||||||
|
(self.root / source_id).chmod(0o000)
|
||||||
|
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
@runs_unprivileged
|
||||||
|
def test_no_source_readable_cannot_promote(self) -> None:
|
||||||
|
for source_id in ALL_SOURCES:
|
||||||
|
(self.root / source_id).chmod(0o000)
|
||||||
|
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
# --- the classification must not drift from the framework's -------------
|
||||||
|
|
||||||
|
def test_required_set_excludes_only_the_unseeded_sources(self) -> None:
|
||||||
|
"""`install.sh` decides which files exist; this list must follow it.
|
||||||
|
|
||||||
|
If a source moves between framework-owned and operator-generated
|
||||||
|
upstream, this fails and forces the classification to be re-read rather
|
||||||
|
than silently inherited.
|
||||||
|
"""
|
||||||
|
self.assertEqual(
|
||||||
|
set(lease_promote.REQUIRED_SOURCES),
|
||||||
|
{"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"},
|
||||||
|
"REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and "
|
||||||
|
"config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES),
|
||||||
|
"a required source is not in the binding order",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user