Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e910a45ab3 | ||
|
|
c5b0d510d7 | ||
|
|
9b7005d59b | ||
|
|
fbb6191298 | ||
|
|
32b490a712 | ||
|
|
83d2ecb224 | ||
|
|
9e1a7a44b7 | ||
|
|
8b1b873056 | ||
|
|
d119635265 | ||
|
|
abaed0c103 | ||
|
|
04cc031774 | ||
|
|
e89599758b | ||
|
|
0b4aa4751a |
+13
-3
@@ -68,15 +68,25 @@ steps:
|
|||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
|
# Anti-inert-gate registry. Deliberately unconditional: no path filter and no
|
||||||
|
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
||||||
|
gate-verify:
|
||||||
|
image: *node_image
|
||||||
|
commands:
|
||||||
|
- *enable_pnpm
|
||||||
|
- pnpm gate:verify
|
||||||
|
depends_on:
|
||||||
|
- install
|
||||||
|
- sanitization
|
||||||
|
- upgrade-guard
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- gate-verify
|
||||||
- sanitization
|
|
||||||
- upgrade-guard
|
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
|
|||||||
+5
-104
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
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,21 +23,9 @@ 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, next]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -115,84 +103,6 @@ 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
|
||||||
@@ -224,17 +134,8 @@ 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" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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"
|
||||||
@@ -245,7 +146,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -271,7 +172,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ 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
|
||||||
@@ -371,9 +361,7 @@ 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 --next # Prerelease lane: source build from next
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,11 +1,9 @@
|
|||||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, 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';
|
||||||
|
|
||||||
@@ -16,9 +14,6 @@ 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()
|
||||||
@@ -60,14 +55,6 @@ 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(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
function buildService(): 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,
|
||||||
redis as never,
|
mockRedis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
@@ -131,22 +131,6 @@ 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,10 +23,7 @@ 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,
|
||||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@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))
|
||||||
@@ -446,16 +443,14 @@ 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(
|
key,
|
||||||
key,
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
'EX',
|
||||||
'EX',
|
300,
|
||||||
300,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, 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';
|
||||||
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// 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 | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@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,8 +5,6 @@ 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';
|
||||||
@@ -14,13 +12,7 @@ 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: [
|
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||||
EnrollmentController,
|
|
||||||
FederationController,
|
|
||||||
CapabilitiesController,
|
|
||||||
ListController,
|
|
||||||
GetController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +1,5 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject } 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';
|
||||||
|
|
||||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// 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 | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@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,19 +119,6 @@ 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, Optional } from '@nestjs/common';
|
import { Inject, Injectable } 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,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,10 +29,8 @@ 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 {
|
||||||
@@ -52,14 +47,12 @@ 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 (skipped on Local tier).
|
// 1. Valkey: delete all session-scoped keys
|
||||||
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.
|
||||||
|
|||||||
@@ -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: Array<Worker<MosaicJobData>> = [];
|
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,12 +26,6 @@ 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
|
||||||
|
|
||||||
@@ -45,7 +39,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();
|
||||||
});
|
});
|
||||||
if (summarizationWorker) this.registeredWorkers.push(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(
|
||||||
@@ -57,7 +51,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();
|
||||||
});
|
});
|
||||||
if (tierWorker) this.registeredWorkers.push(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.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, Logger } 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}`;
|
||||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalOverrideEntry {
|
|
||||||
condensed: string;
|
|
||||||
fragments: OverrideFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemOverrideService implements OnApplicationShutdown {
|
export class SystemOverrideService {
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
private readonly handle: QueueHandle | null;
|
private readonly handle: QueueHandle;
|
||||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
|
||||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
@Optional()
|
this.handle = createQueue();
|
||||||
@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
|
||||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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,9 +8,7 @@ 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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,42 +108,21 @@ 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.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
this.connection = getConnection();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -154,10 +131,8 @@ 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> | null {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||||
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 });
|
||||||
@@ -169,7 +144,6 @@ 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,
|
||||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.enabled) {
|
const queue = this.getQueue<T>(queueName);
|
||||||
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 },
|
||||||
@@ -199,14 +167,7 @@ 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)));
|
||||||
@@ -221,18 +182,8 @@ 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>(
|
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||||
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) => {
|
||||||
@@ -289,12 +240,8 @@ 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;
|
||||||
|
|
||||||
@@ -325,10 +272,8 @@ 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]
|
||||||
@@ -355,10 +300,8 @@ 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>".' };
|
||||||
@@ -390,7 +333,6 @@ 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();
|
||||||
@@ -402,7 +344,6 @@ 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();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Administrator Guide
|
||||||
|
|
||||||
|
- [Gate registry operations](quality-gate-registry.md)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Gate Registry Operations
|
||||||
|
|
||||||
|
## Routine verification
|
||||||
|
|
||||||
|
Run `pnpm gate:verify` from a dependency-installed checkout. Exit zero means registry observations matched their declared `actual` values; it does **not** assert required-behavior conformance while deltas remain. Open `DEFECT` records are checked descriptions of current behavior with tracked owners, never successful gate outcomes.
|
||||||
|
|
||||||
|
Investigate any of these immediately:
|
||||||
|
|
||||||
|
- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. The verifier aggregates independent phase failures, so repair the responsible stable-ID diagnostics as well as any accompanying stale-fixture error; do not treat the generic error as a substitute.
|
||||||
|
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
||||||
|
- `no negative control` — a gate has no must-fail case.
|
||||||
|
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
||||||
|
- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner.
|
||||||
|
|
||||||
|
## Updating a gate
|
||||||
|
|
||||||
|
1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. Preserve recursive closed-schema validation for every nested object; new fields require explicit key and type handling.
|
||||||
|
2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification.
|
||||||
|
3. Declare an exact inerting mutation and observe the verifier detect it.
|
||||||
|
4. If required and actual behavior differ, add a tracked remediation owner and justification.
|
||||||
|
5. If meaning changed, append provenance; never replace the original silently.
|
||||||
|
6. For an installed counterpart, verify live byte identity and update the observed digest only from measured evidence.
|
||||||
|
7. Run focused verifier tests, `pnpm gate:verify`, and the repository baseline gates.
|
||||||
|
|
||||||
|
Do not add an ownerless exception or describe an open delta as pass/green/OK.
|
||||||
|
|
||||||
|
## CI behavior
|
||||||
|
|
||||||
|
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property.
|
||||||
|
|
||||||
|
**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** The registered shrink-both, overclaim, and per-gate evidence-subject controls must remain red for their stated reasons.
|
||||||
|
|
||||||
|
**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor.
|
||||||
|
|
||||||
|
`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Developer Guide
|
||||||
|
|
||||||
|
- [Gate registry and negative controls](quality-gate-registry.md)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Gate Registry and Negative Controls
|
||||||
|
|
||||||
|
`gates/gates.manifest.json` is the machine-readable registry for the initial RM-02 gate slice. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm gate:verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registered slice
|
||||||
|
|
||||||
|
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||||
|
|
||||||
|
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||||
|
|
||||||
|
## Required versus actual
|
||||||
|
|
||||||
|
A case may record different `required` and `actual` outcomes only with a tracked owner. The verifier checks current reality against `actual`, prints each difference as `DEFECT (owner: ...)`, and fails when behavior changes without a matching registry update. A defect is never described as passing, green, or OK.
|
||||||
|
|
||||||
|
The queue guard currently has RM-03-owned deltas. In particular, its stdin/heredoc classifier does not consume piped status JSON, so terminal-success, no-status, and terminal-failure payloads become `unknown`; unknown and malformed states exit zero; push purpose defaults to `main`. RM-02 records these observations and does not edit the guard.
|
||||||
|
|
||||||
|
## Criteria, prose, and compatibility
|
||||||
|
|
||||||
|
Each criterion declares exact `caseRefs`; the verifier compares those semantic declarations bidirectionally with case-side `criterionIds` and requires at least one must-fail case. Moving a criterion ID to an unrelated case therefore fails as both a missing declared exercising case and an undeclared binding. Registered meta-negative controls misbind a criterion, remove meaning provenance, and misbind a prose claim, and each must make structure verification red for its stated reason.
|
||||||
|
|
||||||
|
Designated governing prose uses `GATE-CLAIM:<id>` markers. Each claim also names the exact must-fail `caseRef` that exercises its criterion; unknown, positive-only, unrelated, unbound, or registered-but-missing claims fail. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely.
|
||||||
|
|
||||||
|
Compatibility checks detect direct contradictions in declared finite constructions. The verifier combines referenced case fixtures and environments in one isolated tree, rejects conflicting fixture/environment values, executes the construction's exact invocation, and checks its exact outcome. They do not prove semantic consistency of arbitrary natural language.
|
||||||
|
|
||||||
|
Restatements preserve original text, current text, reason, finding/task, and date.
|
||||||
|
|
||||||
|
## Source and deployed identity
|
||||||
|
|
||||||
|
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
||||||
|
|
||||||
|
## Current-tree and history-provenance boundary
|
||||||
|
|
||||||
|
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population, verifier inventory, and `gates/required-gates.baseline.json` are compared inside the checkout. Evidence-side subjects are consumed and compared with gate definitions for every gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
|
||||||
|
**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed.
|
||||||
|
|
||||||
|
The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02.
|
||||||
|
|
||||||
|
Universal checks first prove populations non-empty. The production profile reads the same-checkout baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. A registered prose-claim control fails when the same-checkout mechanism is described as an adversarial protection.
|
||||||
+57
@@ -14,6 +14,63 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## RM-02 Gate Registry and Negative-Control Verifier (#1029)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
Existing deterministic gates can return success without enforcing their stated property. RM-02 introduces a machine-readable registry and an unconditional CI verifier that distinguishes required behavior from observed behavior, proves every registered negative control can detect its own failure reason, and makes source/deployment drift visible.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides protected external authority.
|
||||||
|
|
||||||
|
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. `RM02-REQ-01`: The JSON registry SHALL give every gate and criterion a stable ID and SHALL declare exact invocation, input classes, cases, exact observed and required exit codes, reason diagnostics, and criterion bindings.
|
||||||
|
2. `RM02-REQ-02`: Every gate SHALL have at least one observed-red must-fail case. The verifier SHALL reject missing, stale, ambiguous, or ineffective declared inert mutations and SHALL name an externally inerted gate.
|
||||||
|
3. `RM02-REQ-03`: Every acceptance criterion SHALL declare exact criterion-side `caseRefs` that match case-side `criterionIds` bidirectionally and include a case that can fail for that criterion's stated reason. Moving a binding to an unrelated case SHALL fail verification. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:<id>` markers and declare the exact must-fail case exercising the claim criterion.
|
||||||
|
4. `RM02-REQ-04`: Declared finite compatibility scenarios SHALL execute together and direct modeled contradictions SHALL fail. This does not claim semantic consistency of arbitrary English.
|
||||||
|
5. `RM02-REQ-05`: Restated criteria SHALL retain original text, current text, reason, finding/task, and dated meaning-change history.
|
||||||
|
6. `RM02-REQ-06`: An observed behavior differing from required behavior SHALL be reported as `DEFECT` with a tracked owner; an ownerless delta SHALL fail verification. Such a gate SHALL never be described as passing, green, or OK.
|
||||||
|
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
||||||
|
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
||||||
|
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
||||||
|
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance.
|
||||||
|
11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from a same-checkout baseline and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate.
|
||||||
|
13. `RM02-REQ-13` (`D-40`): For every gate listed in the required inventory baseline, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||||
|
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and compared with the same-checkout baseline. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||||
|
|
||||||
|
#### RM02-REQ-10 meaning-change provenance
|
||||||
|
|
||||||
|
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
||||||
|
- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes.
|
||||||
|
- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `RM02-AC-01`: A healthy current tree returns zero while prominently reporting the queue guard's required-versus-actual `DEFECT (owner: RM-03)` delta.
|
||||||
|
2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted.
|
||||||
|
3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`.
|
||||||
|
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
||||||
|
5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
||||||
|
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||||
|
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, same-checkout inventory drift, and evidence-side subject consumption; it does not establish history provenance at all. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; overstating the same-checkout inventory boundary; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||||
|
9. `RM02-AC-09`: Population controls iterate every gate listed in the baseline and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||||
|
|
||||||
|
### Risks, dependencies, and verification boundary
|
||||||
|
|
||||||
|
- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, a same-checkout seven-gate baseline comparison, and evidence-side subject consumption.
|
||||||
|
- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state.
|
||||||
|
- **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** A registered claim control fails if the checked artifacts overstate this boundary.
|
||||||
|
- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently.
|
||||||
|
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Problem Statement
|
## Problem Statement
|
||||||
|
|
||||||
Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Next.js frontend. It handles chat, projects, tasks, and LLM routing but lacks orchestration depth, agent coordination, shared memory, and remote access. The Mosaic framework (`~/.config/mosaic`) provides agent guides, shell-based orchestration tools, and quality rails — but these are loose scripts, not an integrated platform. The `@mosaicstack/*` packages in mosaic-mono-v0 began consolidating these into TypeScript packages (brain, queue, coord, cli, prdy, quality-rails) but have no UI, no auth, and no agent runtime integration.
|
Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Next.js frontend. It handles chat, projects, tasks, and LLM routing but lacks orchestration depth, agent coordination, shared memory, and remote access. The Mosaic framework (`~/.config/mosaic`) provides agent guides, shell-based orchestration tools, and quality rails — but these are loose scripts, not an integrated platform. The `@mosaicstack/*` packages in mosaic-mono-v0 began consolidating these into TypeScript packages (brain, queue, coord, cli, prdy, quality-rails) but have no UI, no auth, and no agent runtime integration.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Documentation Sitemap
|
# Documentation Sitemap
|
||||||
|
|
||||||
|
## Gate verification
|
||||||
|
|
||||||
|
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, and the enforced RM-60 history-provenance exclusion. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||||
|
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||||
|
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||||
|
|
||||||
## Compaction refresh lease broker
|
## Compaction refresh lease broker
|
||||||
|
|
||||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
# 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,17 +195,6 @@ 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,18 +175,8 @@ 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`.
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# RM-02 Gate Registry Implementation Plan
|
||||||
|
|
||||||
|
> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor.
|
||||||
|
|
||||||
|
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
||||||
|
|
||||||
|
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the same-checkout `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Meta-negative-control kernel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `scripts/gate-verify.test.mjs`
|
||||||
|
- Create: `scripts/gate-verify.mjs`
|
||||||
|
|
||||||
|
1. Write a black-box fixture whose gate failure branch has already been changed to success.
|
||||||
|
2. Run `node --test scripts/gate-verify.test.mjs`; require failure because the absent verifier does not name the inert gate.
|
||||||
|
3. Implement manifest loading, exact process execution, and named mismatch reporting only.
|
||||||
|
4. Re-run and require the external inert mutation to produce verifier nonzero while the test passes.
|
||||||
|
5. Add an internally applied declared mutation and require a healthy fixture to report its negative control armed.
|
||||||
|
|
||||||
|
### Task 2: Registry structural clauses
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `scripts/gate-verify.test.mjs`
|
||||||
|
- Modify: `scripts/gate-verify.mjs`
|
||||||
|
|
||||||
|
Add failing tests, one behavior at a time, for: `unregistered gate`; `no negative control`; unbound criterion; unbound `GATE-CLAIM`; ownerless required/actual delta; stale/ambiguous/ineffective mutation; direct modeled conflict; missing meaning-change provenance. Implement the minimum validator after each observed RED.
|
||||||
|
|
||||||
|
### Task 3: Gate fixtures and seven-gate manifest
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `gates/gates.manifest.json`
|
||||||
|
- Create: `gates/fixtures/quality-case.mjs`
|
||||||
|
- Create: `gates/fixtures/preflight-case.mjs`
|
||||||
|
- Create: `gates/fixtures/queue-case.sh`
|
||||||
|
- Create: `gates/fixtures/hook-case.sh`
|
||||||
|
- Modify: `scripts/gate-verify.test.mjs`
|
||||||
|
|
||||||
|
Register typecheck, lint, format, RM-01 preflight, queue guard, pre-commit, and pre-push. For each, first run its known-bad case against an intentionally inert fixture and observe verifier RED; then restore the real gate behavior and require its exact observed exit/reason. Record queue defects as required/actual deltas owned by RM-03, never as pass/green/OK.
|
||||||
|
|
||||||
|
### Task 4: Source-versus-deployed identity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `gates/gates.manifest.json`
|
||||||
|
- Modify: `scripts/gate-verify.test.mjs`
|
||||||
|
- Modify: `scripts/gate-verify.mjs`
|
||||||
|
|
||||||
|
Write and observe a failing test with a byte-mutated deployed counterpart. Implement byte equality and internal drift mutation controls. Declare `none` explicitly for gates without deployed counterparts.
|
||||||
|
|
||||||
|
### Task 5: Prose claims and compatibility constructions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/remediation/MISSION.md`
|
||||||
|
- Modify: `docs/remediation/TASKS.md` only if coordinator authorization overrides the worker prohibition; otherwise place markers in an RM-02 claim index that references immutable source anchors.
|
||||||
|
- Modify: `gates/gates.manifest.json`
|
||||||
|
- Modify: verifier tests/implementation.
|
||||||
|
|
||||||
|
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
||||||
|
|
||||||
|
### Task 6: Current-tree boundary and enforced history-provenance exclusion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `gates/required-gates.baseline.json`
|
||||||
|
- Create: `scripts/gate-history-exclusion-control.mjs`
|
||||||
|
- Create: `scripts/gate-inventory-shrink-control.mjs`
|
||||||
|
- Modify: `scripts/gate-verify.mjs`
|
||||||
|
- Modify: `gates/gates.manifest.json`
|
||||||
|
|
||||||
|
Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the same-checkout baseline, register must-fail attacks that shrink either paired representation, and register an overclaim control. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary.
|
||||||
|
|
||||||
|
### Task 7: CI and documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `.woodpecker/ci.yml`
|
||||||
|
- Create/update: `docs/DEVELOPER-GUIDE/quality-gate-registry.md`
|
||||||
|
- Create/update: `docs/ADMIN-GUIDE/quality-gate-registry.md`
|
||||||
|
- Modify: `docs/SITEMAP.md`
|
||||||
|
|
||||||
|
Add `gate:verify`; run it in an unconditional PR/main CI step. Document invocation, defect semantics, coverage/exclusions, marker syntax, replay/provider boundaries, and source/deployed identity.
|
||||||
|
|
||||||
|
### Task 8: Verification and delivery
|
||||||
|
|
||||||
|
Run focused tests, `pnpm gate:verify`, checkout tests, typecheck, lint, format, and applicable integration tests. Run Codex code and security review; remediate and re-review. Verify authorship, commit, execute queue guard before push, push, create PR via Mosaic wrapper, and send exact-head review request to rev-974 through the coordinator. Do not merge without coordinator authorization.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# RM-02 Governing Claim Index
|
||||||
|
|
||||||
|
This index binds remediation claims that live in orchestrator-owned `TASKS.md` without modifying that file. The source heading and quoted text are the reviewable anchor; `gate:verify` enforces each marker-to-criterion binding. Marker completeness beyond this approved slice remains RM-54.
|
||||||
|
|
||||||
|
## Prose is an enforceable claim
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:PROSE-IS-A-CLAIM -->
|
||||||
|
|
||||||
|
- Source: `docs/remediation/TASKS.md`, heading `D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically`.
|
||||||
|
- Anchored text: “The defect was not in the code — it was in this file.”
|
||||||
|
|
||||||
|
## Generated-state verification scope
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:GENERATED-STATE-SCOPE -->
|
||||||
|
|
||||||
|
- Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`.
|
||||||
|
- Anchored text: “a verifier that cannot detect the attack is not a verifier.”
|
||||||
|
|
||||||
|
## Execution trust boundary
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:EXECUTION-TRUST-BOUNDARY -->
|
||||||
|
|
||||||
|
- Source: RM-02 ruling recorded under `docs/remediation/TASKS.md`, RM-02 clause 4, D-25.
|
||||||
|
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
||||||
|
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
||||||
|
|
||||||
|
## Same-checkout inventory drift boundary
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:INVENTORY-DRIFT-BOUNDARY -->
|
||||||
|
|
||||||
|
- Source: RM-02 ruling after D-48/CWE-353 reproduced against the round-4 baseline.
|
||||||
|
- Boundary: Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||||
|
- Negative control: `checkout-preflight/inventory-claim-overstatement` rewrites the boundary as protection and must go red.
|
||||||
|
|
||||||
|
## Criterion restatement provenance
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
||||||
|
|
||||||
|
- Source: `docs/remediation/TASKS.md`, heading `D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation`.
|
||||||
|
- Anchored text: “Both criteria were pre-registered. They cannot both be satisfied.”
|
||||||
@@ -12,6 +12,8 @@ gate/program; the LLM handles only genuine judgment.
|
|||||||
|
|
||||||
### First-class principle — observe the property, not the exit code
|
### First-class principle — observe the property, not the exit code
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:OBSERVE-PROPERTY -->
|
||||||
|
|
||||||
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
> **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
|
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||||
@@ -30,6 +32,8 @@ gate/program; the LLM handles only genuine judgment.
|
|||||||
|
|
||||||
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:PREREGISTRATION-BOUNDARY -->
|
||||||
|
|
||||||
> **A pre-registered check set can fail in three distinct ways:**
|
> **A pre-registered check set can fail in three distinct ways:**
|
||||||
>
|
>
|
||||||
> | mode | the set is… | found as |
|
> | mode | the set is… | found as |
|
||||||
@@ -56,6 +60,8 @@ gate/program; the LLM handles only genuine judgment.
|
|||||||
|
|
||||||
### Corollary — never ship an integrity claim dressed as a property
|
### Corollary — never ship an integrity claim dressed as a property
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:ARTIFACT-INTEGRITY-BOUNDARY -->
|
||||||
|
|
||||||
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
> 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
|
> 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,
|
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||||
@@ -68,6 +74,8 @@ gate/program; the LLM handles only genuine judgment.
|
|||||||
|
|
||||||
### First-class principle — when a property cannot exist at the layer it was specified
|
### First-class principle — when a property cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:IMPOSSIBLE-LAYER-BOUNDARY -->
|
||||||
|
|
||||||
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
> 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,
|
> 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:
|
> there are exactly three honest moves, and all three are mandatory:
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# #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,107 @@
|
|||||||
|
# RM-02 Gate Registry Scratchpad (#1029)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02-gate-registry`, preserving required-versus-actual defects without laundering them as success.
|
||||||
|
|
||||||
|
## Constraints and boundaries
|
||||||
|
|
||||||
|
- Do not modify `ci-queue-wait.sh`; RM-03 owns that fix.
|
||||||
|
- Do not modify `docs/remediation/TASKS.md`; workers cannot edit orchestrator tracking.
|
||||||
|
- Every declared behavior must be observed, not inferred from an exit code.
|
||||||
|
- Repository-local evidence does not establish same-authority tamper resistance; RM-25/RM-59 own the external trust anchor.
|
||||||
|
- Coverage is seven logical gates; broader inventory is RM-54.
|
||||||
|
- Budget assumption: no explicit token ceiling was supplied. Keep implementation dependency-free (stock Node), avoid repeated full monorepo installs, and keep generated test artifacts under the worktree/main disk.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Update PRD and tracking references.
|
||||||
|
2. Write black-box meta-negative-control first and observe it fail for the missing verifier behavior.
|
||||||
|
3. Implement minimal manifest parser/case runner/mutation detector; observe meta-control succeed.
|
||||||
|
4. Add schema, coverage, provenance, prose marker, compatibility, discovery, deployment identity, and defect-delta tests RED-first.
|
||||||
|
5. Register seven gates with exact cases and run each case.
|
||||||
|
6. Add prospective per-commit replay and bounded provider-evidence reporting.
|
||||||
|
7. Wire unconditional Woodpecker CI and update developer/admin documentation.
|
||||||
|
8. Run situational and baseline verification, independent Codex review, remediate, commit, queue guard, push, PR, coordinator/reviewer handoff.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-08-01: Design approved by `mos-remediation`; rulings A-E and source/deployed identity addition incorporated.
|
||||||
|
- 2026-08-01: Isolated worktree created from `origin/main` f65e9ea6; RM-01 f58b3699 verified as ancestor.
|
||||||
|
- 2026-08-01: Git author set to `f10-coder <[email protected]>`; provider issue #1029 created with `MOSAIC_GIT_IDENTITY=f10-coder`.
|
||||||
|
- 2026-08-01: Source/deployed queue-guard SHA-256 observed equal (`19cda2f...`); this observation is not yet an enforced property.
|
||||||
|
|
||||||
|
## Tests and RED-first evidence
|
||||||
|
|
||||||
|
- Meta-negative RED first: `node --test scripts/gate-verify.test.mjs` failed because the absent verifier did not name externally inerted `meta-fixture`.
|
||||||
|
- Structural RED: nine tests failed before implementation for internal mutation, unregistered executable, missing negative control, ownerless delta, unbound criterion/claim, modeled conflict, missing provenance, and deployment drift.
|
||||||
|
- Clause hardening RED: positive-only security criterion and missing registered claim marker both passed incorrectly before validation was added.
|
||||||
|
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
||||||
|
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
||||||
|
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
||||||
|
- Focused Node tests: 54/54 pass after third-round hardening (36 verifier/wiring plus 18 history/provider tests).
|
||||||
|
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||||
|
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||||
|
- `pnpm format:check`: pass.
|
||||||
|
- `pnpm test`: repository suites reached 45/46 Turbo tasks; all application/package tests shown passed, then the known host-specific wake assertion aborted exit 97 (`BASH_LINENO convention violated`, #973/D-16). No test was edited or bypassed. CI remains the authoritative full-suite environment.
|
||||||
|
|
||||||
|
## Self-surfaced defect
|
||||||
|
|
||||||
|
The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while provider JSON is piped to the shell function. The heredoc owns stdin, so Python never reads provider JSON. Terminal success, no-status, terminal failure, and malformed payloads all classify as `unknown`; the associated fail-open outcomes are recorded under RM-03. No queue-guard source was modified.
|
||||||
|
|
||||||
|
## Independent review remediation
|
||||||
|
|
||||||
|
- Final pre-commit Codex code review found three blockers: a tautological deployment drift control, independently observed rather than combined compatibility cases, and unauthorized edits to orchestrator-owned `TASKS.md`.
|
||||||
|
- Deployment identity now uses one shared file comparator for both live equality and a temporary drifted deployed copy; a test makes that comparator inert and proves the meta-control fails.
|
||||||
|
- Compatibility scenarios now merge referenced fixtures/environments into one isolated construction and execute an exact scenario invocation; a test proves two independently valid fixtures coexist in the combined run.
|
||||||
|
- `TASKS.md` changes were reverted. `docs/remediation/GATE-CLAIMS.md` binds source headings and anchored text without editing orchestrator tracking.
|
||||||
|
- Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible.
|
||||||
|
- Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three.
|
||||||
|
- Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings.
|
||||||
|
- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix.
|
||||||
|
- Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability.
|
||||||
|
- `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
||||||
|
- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors. Parent-generated launcher/entry metadata distinguishes refusal before sandbox entry from child-controlled output; the detector recognizes exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT` and known namespace-refusal text only when the entry command provably did not run. Focused negative assertions reject unrelated `spawnSync git EPERM`, verifier output that merely says `bwrap ENOENT`, and exact namespace-denial impersonation without provenance or after sandbox entry.
|
||||||
|
- Exact-head independent review at `38f1b249` found one valid diagnostic-masking blocker: canonical verification knew four stable-ID rebinding failures but a thrown stale fixture replacement reached the outer catch first and emitted only the generic error. RED-first reproduction confirmed the canonical path omitted both responsible criterion IDs. Verification now collects labeled failures independently across claims, discovery, deployment, case execution/outcome checks, mutation, and compatibility, preserving structural stable-ID failures alongside the stale-fixture signal. The canonical regression test requires both missing-binding IDs and the generic fixture error.
|
||||||
|
- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation.
|
||||||
|
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
||||||
|
|
||||||
|
- Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content.
|
||||||
|
|
||||||
|
- Exact-head review at `32b490a7` found three population-level blockers. Genuine RED-first tests proved a gate change before author-delayed registry introduction fell outside the range, and empty criteria/gates/prose/scenario populations returned zero. History now anchors at the provider target merge-base; delayed introduction is a registered must-fail control. Production verification requires non-empty populations and a hardcoded seven-gate ID/source inventory before quantified checks. D-38/D-40 criteria now quantify over `gateRefs` exactly spanning every registered gate; each gate declares its evidence subject, and population controls mutate evidence subject and comparison type for every gate. The history record states both bootstrap directions: sound against a branch author unable to rewrite main, not sound against compromised/rewritten main, with residual owned by Builds 1-2. Pre-commit review rejected an initial production CLI `--fixture-profile` test relaxation as a vacuity bypass. That flag was removed; synthetic fixtures now use a non-executable test-support runner, while regression tests prove the shipped CLI rejects the flag and production population checks remain mandatory. Follow-up review then proved deleting `gateRefs` skipped population validation; a RED-first loop reproduced all three deletions, and the three general criterion IDs now require the field before exact-span validation.
|
||||||
|
|
||||||
|
## Documentation checklist
|
||||||
|
|
||||||
|
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
||||||
|
- User/API documentation not applicable: no user workflow or API changed.
|
||||||
|
- Independent review documentation check pending rev-974 at the revised exact head.
|
||||||
|
- Canonical documentation remains in-repository; no external publication requested.
|
||||||
|
|
||||||
|
## Rebase onto RM-61
|
||||||
|
|
||||||
|
- Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts.
|
||||||
|
- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. An initial manifest update to `f4fd5967` had the right value but retained an author-controlled mechanism. Independent mutation proved HEAD, HEAD's parent, and the introduction commit could each make history coverage vacuous or partial. The manifest field is now forbidden; the verifier derives the boundary as the parent of the first first-parent registry-introduction commit, and registered must-fail controls reject all three unsafe candidates.
|
||||||
|
|
||||||
|
## Risks/blockers
|
||||||
|
|
||||||
|
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
||||||
|
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
||||||
|
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
||||||
|
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
||||||
|
|
||||||
|
## coder-mos2 remediation — fourth round
|
||||||
|
|
||||||
|
- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification.
|
||||||
|
- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor.
|
||||||
|
- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly.
|
||||||
|
- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 <[email protected]>` using process-scoped author/committer variables; shared repository config was not modified.
|
||||||
|
- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards.
|
||||||
|
- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs.
|
||||||
|
- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round.
|
||||||
|
- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red.
|
||||||
|
- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class.
|
||||||
|
- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence.
|
||||||
|
- Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green.
|
||||||
|
- Coordinator ruling after CWE-353: retain the same-checkout inventory comparison but narrow its claim. Canonical current boundary: “Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.” This supersedes the earlier “independent/anchored” wording in the historical entries above; those entries remain as append-only evidence, not current claims.
|
||||||
|
- Genuine RED-first overclaim control: before claim remediation, `node scripts/gate-inventory-claim-control.mjs` exited 84 and named every artifact missing the narrowed boundary plus each current overclaim. The registered `inventory-claim-overstatement` case now rewrites the baseline purpose to a protection claim and must exit 84 with `INVENTORY_CLAIM_OVERSTATED`. Existing shrink-pair controls remain regression guards for the same-checkout drift property, not adversarial-integrity claims.
|
||||||
|
- Follow-up code review found a genuine propagation/control gap: `docs/PRD.md` still said “independent seven-gate baseline,” and the first forbidden regex did not match the intervening qualifier. The reviewer observed the control green against that overclaim. The PRD now says same-checkout, the detector rejects `independent … baseline` across bounded same-line qualifiers, the registered mutation uses the exact qualified wording, and a focused behavioral test requires exit 84 for it. Follow-up security review passed with risk `none` and explicitly accepted the narrowed RM-60 boundary.
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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]`.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 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.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"purpose": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"id": "quality-typecheck",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality-lint",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality-format",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkout-preflight",
|
||||||
|
"source": "scripts/preflight.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ci-queue-wait",
|
||||||
|
"source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hook-pre-commit",
|
||||||
|
"source": ".husky/pre-commit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hook-pre-push",
|
||||||
|
"source": ".husky/pre-push"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -6,12 +6,12 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
|
"gate:verify": "node scripts/gate-verify.mjs",
|
||||||
"preflight": "node scripts/preflight.mjs",
|
"preflight": "node scripts/preflight.mjs",
|
||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test",
|
||||||
"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": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -11,37 +11,9 @@ 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),
|
||||||
@@ -51,14 +23,6 @@ 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';
|
||||||
|
|
||||||
@@ -80,16 +44,6 @@ 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 });
|
||||||
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints the success summary only after gateway health succeeds', async () => {
|
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||||
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',
|
||||||
@@ -168,43 +118,6 @@ 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();
|
||||||
});
|
});
|
||||||
@@ -230,34 +143,4 @@ 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,16 +43,6 @@ 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:
|
||||||
@@ -118,8 +108,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
|
||||||
@@ -184,9 +174,7 @@ 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 --next # Prerelease lane: npm @next, source fallback
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
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.
|
||||||
@@ -196,11 +184,10 @@ The installer rejects unrecognized flags or positional arguments before making c
|
|||||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||||
|
|
||||||
```bash
|
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||||
@@ -210,8 +197,8 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
|||||||
## Health Audit
|
## Health Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic doctor # Standard audit
|
mosaic doctor # Standard audit
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP Registration
|
## MCP Registration
|
||||||
@@ -222,8 +209,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/tools/_scripts/mosaic-ensure-sequential-thinking
|
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code MCP Registration
|
### Claude Code MCP Registration
|
||||||
|
|||||||
@@ -925,16 +925,14 @@ 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
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -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>
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||||
- 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 --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (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 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>`.
|
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`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||||
- 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 --expect-head {approved_full_sha}`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||||
- 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 {branch}` 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 main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,9 +638,8 @@ 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`
|
||||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||||
- 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`
|
||||||
- `~/.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,12 +23,10 @@ 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; defaults to the checked-out branch)
|
# CI queue guard (required before push/merge)
|
||||||
~/.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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Mosaic lease promotion was processed mechanically; no action is needed.
|
|
||||||
@@ -32,18 +32,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"matcher": "^/mosaic-promote$",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
|
||||||
"timeout": 15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
@@ -93,8 +81,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"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",
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||||
"timeout": 15
|
"timeout": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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 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.
|
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
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 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.
|
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||||
|
|
||||||
## 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`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||||
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 <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||||
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.
|
||||||
|
|||||||
@@ -233,14 +233,6 @@ for runtime_file in \
|
|||||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
|
|
||||||
mkdir -p "$HOME/.claude/commands"
|
|
||||||
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
|
|
||||||
[[ -f "$command_file" ]] || continue
|
|
||||||
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
# OpenCode runtime adapter (thin pointer to AGENTS.md)
|
||||||
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
|
||||||
if [[ -f "$opencode_adapter" ]]; then
|
if [[ -f "$opencode_adapter" ]]; then
|
||||||
|
|||||||
@@ -153,24 +153,7 @@ 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"
|
||||||
@@ -262,72 +245,13 @@ 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)"
|
||||||
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
# 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,9 +7,7 @@ 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=""
|
BRANCH="main"
|
||||||
TARGET_REPO=""
|
|
||||||
HEAD_SHA=""
|
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||||
-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)
|
||||||
@@ -31,65 +27,63 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -t 600 -i 10
|
$(basename "$0") --purpose push -B main -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() {
|
||||||
# Python source comes from -c so the provider payload remains on stdin.
|
python3 - <<'PY'
|
||||||
# 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("malformed")
|
print("unknown")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
raw_statuses = payload.get("statuses", [])
|
statuses = payload.get("statuses") or []
|
||||||
raw_state = payload.get("state", "")
|
state = (payload.get("state") or "").lower()
|
||||||
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):
|
||||||
print("malformed")
|
continue
|
||||||
raise SystemExit(0)
|
value = (item.get("status") or item.get("state") or "").lower()
|
||||||
raw_value = item.get("status") or item.get("state")
|
if value:
|
||||||
if not isinstance(raw_value, str) or not raw_value:
|
values.append(value)
|
||||||
print("malformed")
|
|
||||||
raise SystemExit(0)
|
|
||||||
values.append(raw_value.lower())
|
|
||||||
|
|
||||||
if any(value in pending_values for value in values) or state in pending_values:
|
if not values and not state:
|
||||||
print("pending")
|
|
||||||
elif any(value in failure_values for value in values) or state in failure_values:
|
|
||||||
print("terminal-failure")
|
|
||||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
|
||||||
print("terminal-success")
|
|
||||||
elif not values:
|
|
||||||
print("no-status")
|
print("no-status")
|
||||||
|
elif any(v in pending_values for v in values):
|
||||||
|
print("pending")
|
||||||
|
elif any(v in failure_values for v in values):
|
||||||
|
print("terminal-failure")
|
||||||
|
elif values and all(v in success_values for v in values):
|
||||||
|
print("terminal-success")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
'
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 -c '
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -110,61 +104,17 @@ 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 = str(item.get("status") or item.get("state") or "unknown").lower()
|
value = (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
|
||||||
suffix = f" ({target})" if target else ""
|
if target:
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||||
|
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() {
|
||||||
@@ -178,87 +128,7 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
local work_root status_file checks_file
|
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||||
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() {
|
||||||
@@ -304,14 +174,6 @@ 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
|
||||||
@@ -344,89 +206,45 @@ 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
|
|
||||||
|
|
||||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
OWNER=$(get_repo_owner)
|
||||||
echo "Error: --purpose must be push or merge." >&2
|
REPO=$(get_repo_name)
|
||||||
exit 1
|
detect_platform > /dev/null
|
||||||
fi
|
|
||||||
|
|
||||||
OWNER="unknown"
|
|
||||||
REPO="unknown"
|
|
||||||
PLATFORM="unknown"
|
|
||||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
|
||||||
record_cannot_assert "repository-owner-unresolvable"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
|
||||||
record_cannot_assert "repository-name-unresolvable"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
if ! detect_platform > /dev/null; then
|
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
|
||||||
record_cannot_assert "unsupported-platform"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
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
|
||||||
record_cannot_assert "github-cli-unavailable"
|
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -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=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
|
||||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
HOST=$(get_remote_host) || {
|
||||||
record_cannot_assert "remote-host-unresolvable"
|
echo "Error: Could not determine remote host." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
}
|
||||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
TOKEN=$(get_gitea_token "$HOST") || {
|
||||||
record_cannot_assert "credential-unresolvable"
|
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||||
exit $?
|
exit 1
|
||||||
|
}
|
||||||
|
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||||
|
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||||
|
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||||
record_cannot_assert "branch-head-unavailable"
|
exit 1
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
|
||||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
|
||||||
record_cannot_assert "branch-head-unavailable"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
fi
|
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
|
||||||
record_cannot_assert "unsupported-platform"
|
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||||
exit $?
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -435,20 +253,14 @@ 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: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: 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
|
||||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||||
record_cannot_assert "status-provider-unreachable"
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||||
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)
|
||||||
@@ -459,24 +271,21 @@ 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: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||||
else
|
exit 1
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
|
||||||
fi
|
fi
|
||||||
exit 3
|
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||||
|
exit 0
|
||||||
;;
|
;;
|
||||||
terminal-failure|malformed|unknown)
|
terminal-success|terminal-failure|unknown)
|
||||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
# Queue guard only blocks on pending/running/queued states.
|
||||||
exit 3
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||||
exit 3
|
exit 0
|
||||||
;;
|
;;
|
||||||
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] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,10 +12,8 @@ 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
|
||||||
@@ -27,18 +25,15 @@ 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 --expect-head 0123456789abcdef0123456789abcdef01234567
|
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -58,30 +53,15 @@ 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
|
||||||
;;
|
;;
|
||||||
@@ -106,49 +86,18 @@ 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 [[ "$DRY_RUN" != true ]]; then
|
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$HEAD_BRANCH" \
|
-B "$BASE_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
|
||||||
@@ -157,442 +106,79 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
write_curl_auth_config() {
|
is_known_tea_empty_identity_failure() {
|
||||||
local mode="$1" credential="$2"
|
local error_file="$1"
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
LAST_GITEA_HTTP_CODE="000"
|
python3 - "$error_file" <<'PY'
|
||||||
LAST_GITEA_ERROR=""
|
import re
|
||||||
MERGE_TEMP_DIRS=()
|
import sys
|
||||||
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
|
||||||
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||||
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
error = handle.read()
|
||||||
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
|
||||||
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
known_empty_identity = re.search(
|
||||||
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||||
exit 1
|
error,
|
||||||
fi
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
done
|
|
||||||
GITEA_CURL_BOUNDS=(
|
|
||||||
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
|
||||||
--max-time "$GITEA_CURL_MAX_TIME"
|
|
||||||
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
|
||||||
)
|
)
|
||||||
|
raise SystemExit(0 if known_empty_identity else 1)
|
||||||
format_gitea_error_response() {
|
|
||||||
local response_file="$1"
|
|
||||||
python3 - "$response_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], "rb") as handle:
|
|
||||||
raw = handle.read(65536)
|
|
||||||
try:
|
|
||||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
message = "non-JSON response omitted"
|
|
||||||
else:
|
|
||||||
if isinstance(response, dict):
|
|
||||||
message = response.get("message") or response.get("error")
|
|
||||||
if not message and response.get("errors") is not None:
|
|
||||||
message = json.dumps(response["errors"], separators=(",", ":"))
|
|
||||||
else:
|
|
||||||
message = None
|
|
||||||
if not message:
|
|
||||||
message = "JSON response contained no error message"
|
|
||||||
message = str(message)
|
|
||||||
if len(message) > 500:
|
|
||||||
message = message[:500] + "..."
|
|
||||||
print(ascii(message))
|
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_merge_temp_dirs() {
|
|
||||||
local path
|
|
||||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
|
||||||
[[ -n "$path" ]] && rm -rf -- "$path"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
trap cleanup_merge_temp_dirs EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
|
|
||||||
fetch_gitea_pr_head() {
|
|
||||||
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
|
||||||
local response_file raw_code api_url auth_config curl_rc
|
|
||||||
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
|
||||||
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
|
||||||
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
|
||||||
rm -f "$response_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
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
|
|
||||||
|
|
||||||
if ! page_count=$(python3 - "$page_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
|
||||||
page = json.load(handle)
|
|
||||||
if not isinstance(page, list):
|
|
||||||
raise SystemExit(1)
|
|
||||||
print(len(page))
|
|
||||||
PY
|
|
||||||
); 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() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" token attempt_rc
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
|
payload='{"Do":"squash"}'
|
||||||
|
|
||||||
if ! token=$(get_gitea_token "$host"); then
|
token=$(get_gitea_token "$host" || true)
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
if [[ -n "$token" ]]; then
|
||||||
return 1
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -z "$token" ]]; then
|
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||||
return 1
|
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
|
fi
|
||||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
|
||||||
return 0
|
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||||
else
|
import json
|
||||||
attempt_rc=$?
|
import sys
|
||||||
fi
|
code, path = sys.argv[1], sys.argv[2]
|
||||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
try:
|
||||||
return 75
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
fi
|
raw = handle.read(500)
|
||||||
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
data = json.loads(raw) if raw else {}
|
||||||
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
|
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||||
return 1
|
except Exception:
|
||||||
fi
|
try:
|
||||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
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
|
||||||
|
rm -f "$body_file"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,10 +188,11 @@ 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
|
||||||
}
|
}
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
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)."
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (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)."
|
||||||
@@ -615,11 +202,7 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||||
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[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -628,9 +211,32 @@ 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
|
||||||
}
|
}
|
||||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
|
||||||
merge_gitea_with_api "$HOST"
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||||
|
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||||
|
cat "$TEA_ERROR_FILE" >&2
|
||||||
|
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
merge_gitea_with_api "$HOST"
|
||||||
|
else
|
||||||
|
cat "$TEA_ERROR_FILE" >&2
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||||
|
merge_gitea_with_api "$HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete branch after merge if requested
|
||||||
|
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||||
|
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
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,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,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,25 +182,6 @@ 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'),
|
||||||
@@ -226,8 +207,6 @@ 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) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||||
|
|
||||||
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","statuses":[{"status":"success"}]}' ;;
|
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,7 +91,6 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||||
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" -ne 0 ]]; then
|
if [[ "$status_c" -eq 0 ]]; then
|
||||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&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
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
#!/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 exact-head API path and input safety.
|
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,23 +51,22 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "data" ]]; then
|
if [[ "$prev" == "-d" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "config" ]]; then
|
if [[ "$arg" == "-o" ]]; then
|
||||||
[[ "$arg" == "-" ]] && cat >/dev/null
|
prev="-o"
|
||||||
prev=""
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
case "$arg" in
|
if [[ "$arg" == "-d" ]]; then
|
||||||
-o) prev="-o" ;;
|
prev="-d"
|
||||||
-d|--data|--data-binary) prev="data" ;;
|
continue
|
||||||
-K|--config) prev="config" ;;
|
fi
|
||||||
-w) write_code=true ;;
|
if [[ "$arg" == "-w" ]]; then
|
||||||
esac
|
write_code=true
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
@@ -80,24 +79,15 @@ 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","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"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"},"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
|
||||||
POST_DATA="$post_data" python3 - <<'PY'
|
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||||
import json
|
echo "unexpected merge payload: $post_data" >&2
|
||||||
import os
|
exit 96
|
||||||
payload = json.loads(os.environ["POST_DATA"])
|
fi
|
||||||
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
|
||||||
@@ -117,8 +107,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 > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&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
|
||||||
@@ -137,6 +127,38 @@ 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
|
||||||
@@ -155,8 +177,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 > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
echo "Expected missing tea login to use authenticated Gitea API fallback." >&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
|
||||||
@@ -193,7 +215,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 > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$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
|
||||||
@@ -218,7 +240,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 > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$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
|
||||||
@@ -238,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
echo "pr-merge.sh Gitea fallback regression passed"
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/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 = 3600
|
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||||
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"}),
|
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||||
"pi": frozenset({"read", "ls"}),
|
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||||
}
|
}
|
||||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ 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
|
||||||
|
|
||||||
@@ -55,48 +53,6 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -138,9 +94,8 @@ 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(
|
||||||
activation_capability,
|
probe_activation_capability(source_environment),
|
||||||
expected_activation_capability,
|
expected_activation_capability,
|
||||||
)
|
)
|
||||||
except VersionCouplingError as version_error:
|
except VersionCouplingError as version_error:
|
||||||
@@ -173,32 +128,6 @@ 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)
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final, TextIO
|
|
||||||
|
|
||||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
||||||
if _MODULE_DIRECTORY not in sys.path:
|
|
||||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
|
||||||
|
|
||||||
from receipt_challenge import receipt_for # noqa: E402
|
|
||||||
|
|
||||||
_observer_spec = importlib.util.spec_from_file_location(
|
|
||||||
"mosaic_receipt_observer_client", Path(__file__).resolve().with_name("receipt-observer-client.py")
|
|
||||||
)
|
|
||||||
if _observer_spec is None or _observer_spec.loader is None:
|
|
||||||
raise RuntimeError("unable to load receipt observer client")
|
|
||||||
_observer_module = importlib.util.module_from_spec(_observer_spec)
|
|
||||||
_observer_spec.loader.exec_module(_observer_module)
|
|
||||||
observer_request = _observer_module.observer_request
|
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
|
||||||
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
|
|
||||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
|
||||||
PROMOTION_PROMPT: Final = "/mosaic-promote"
|
|
||||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
|
||||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
|
||||||
AUTHORIZATION_DIRECTORY: Final = "authorizations"
|
|
||||||
AUTHORIZATION_TTL_SECONDS: Final = 60
|
|
||||||
LEASE_TTL_SECONDS: Final = 60 * 60
|
|
||||||
LOCK_FILE: Final = "promotion.lock"
|
|
||||||
RESULT_FILE: Final = "last-result.json"
|
|
||||||
EXPECTED_BEGIN_KEYS: Final = frozenset(
|
|
||||||
{"ok", "state", "receipt_challenge", "receipt", "binding"}
|
|
||||||
)
|
|
||||||
EXPECTED_BINDING_KEYS: Final = frozenset(
|
|
||||||
{
|
|
||||||
"compaction_epoch",
|
|
||||||
"request_epoch",
|
|
||||||
"h_source",
|
|
||||||
"h_payload",
|
|
||||||
"runtime_generation",
|
|
||||||
"schema_version",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionAlreadyInProgress(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
||||||
value: dict[str, object] = {}
|
|
||||||
for key, item in pairs:
|
|
||||||
if key in value:
|
|
||||||
raise ValueError("duplicate promoter JSON key")
|
|
||||||
value[key] = item
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def read_hook_input(stream: object) -> dict[str, object]:
|
|
||||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
|
||||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
|
||||||
raise ValueError("invalid UserPromptSubmit input")
|
|
||||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("invalid UserPromptSubmit input")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def emit_context(stream: TextIO, message: str) -> None:
|
|
||||||
json.dump(
|
|
||||||
{
|
|
||||||
"hookSpecificOutput": {
|
|
||||||
"hookEventName": "UserPromptSubmit",
|
|
||||||
"additionalContext": message,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stream,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
stream.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
|
||||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
|
||||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
if not runtime_dir.is_absolute():
|
|
||||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
|
||||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
|
||||||
raise ValueError("invalid lease session id")
|
|
||||||
return runtime_dir, f"pending-{session_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def open_pending_directory(runtime_dir: Path) -> int:
|
|
||||||
directory_flags = (
|
|
||||||
os.O_RDONLY
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_DIRECTORY", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
|
||||||
try:
|
|
||||||
runtime_metadata = os.fstat(runtime_descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
|
||||||
or runtime_metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
raise ValueError("unsafe XDG runtime directory")
|
|
||||||
try:
|
|
||||||
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
|
|
||||||
except FileExistsError:
|
|
||||||
pass
|
|
||||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(runtime_descriptor)
|
|
||||||
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISDIR(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion pending directory")
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock(directory_descriptor: int) -> int:
|
|
||||||
flags = (
|
|
||||||
os.O_RDWR
|
|
||||||
| os.O_CREAT
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
|
||||||
metadata = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(metadata.st_mode)
|
|
||||||
or metadata.st_uid != os.getuid()
|
|
||||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
||||||
):
|
|
||||||
os.close(descriptor)
|
|
||||||
raise ValueError("unsafe promotion lock file")
|
|
||||||
try:
|
|
||||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except BlockingIOError as error:
|
|
||||||
os.close(descriptor)
|
|
||||||
raise PromotionAlreadyInProgress() from error
|
|
||||||
return descriptor
|
|
||||||
|
|
||||||
|
|
||||||
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
|
|
||||||
cutoff = current_time - PENDING_MAX_AGE_SECONDS
|
|
||||||
removed = False
|
|
||||||
with os.scandir(directory_descriptor) as entries:
|
|
||||||
for candidate in entries:
|
|
||||||
if not (
|
|
||||||
candidate.name.startswith("pending-")
|
|
||||||
or candidate.name.startswith(".pending-")
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
metadata = candidate.stat(follow_symlinks=False)
|
|
||||||
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
|
|
||||||
os.unlink(candidate.name, dir_fd=directory_descriptor)
|
|
||||||
removed = True
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
if removed:
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def consume_authorization(directory_descriptor: int, session_id: str, wall_clock: float) -> str | None:
|
|
||||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
try:
|
|
||||||
authorization_descriptor = os.open(AUTHORIZATION_DIRECTORY, flags, dir_fd=directory_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
metadata = os.fstat(authorization_descriptor)
|
|
||||||
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
|
||||||
raise ValueError("unsafe promotion authorization directory")
|
|
||||||
name = f"{session_id}.auth"
|
|
||||||
try:
|
|
||||||
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=authorization_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
token_metadata = os.fstat(descriptor)
|
|
||||||
if not stat.S_ISREG(token_metadata.st_mode) or token_metadata.st_uid != os.getuid() or stat.S_IMODE(token_metadata.st_mode) != 0o600 or token_metadata.st_size <= 0 or token_metadata.st_size > MAX_FRAME:
|
|
||||||
raise ValueError("unsafe promotion authorization")
|
|
||||||
raw = os.read(descriptor, MAX_FRAME + 1)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
os.unlink(name, dir_fd=authorization_descriptor)
|
|
||||||
os.fsync(authorization_descriptor)
|
|
||||||
token = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
|
||||||
if not isinstance(token, dict) or set(token) != {"nonce", "seat", "session_id", "expires_at", "ts"}:
|
|
||||||
return None
|
|
||||||
nonce = token.get("nonce")
|
|
||||||
expires_at = token.get("expires_at")
|
|
||||||
issued_at = token.get("ts")
|
|
||||||
if token.get("session_id") != session_id or not isinstance(token.get("seat"), str) or not isinstance(nonce, str) or len(nonce) != 64 or any(char not in "0123456789abcdef" for char in nonce) or type(expires_at) not in (int, float) or type(issued_at) not in (int, float) or expires_at <= wall_clock or expires_at > issued_at + AUTHORIZATION_TTL_SECONDS:
|
|
||||||
return None
|
|
||||||
return nonce
|
|
||||||
finally:
|
|
||||||
os.close(authorization_descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def write_result(directory_descriptor: int, attempt_id: str, verified: bool, reason: str | None, session_id: str, wall_clock: float) -> None:
|
|
||||||
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
|
||||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_descriptor)
|
|
||||||
try:
|
|
||||||
os.fchmod(descriptor, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
|
||||||
json.dump({"attempt_id": attempt_id, "expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None, "reason": reason, "session_id": session_id, "ts": wall_clock, "verified": verified}, stream, separators=(",", ":"), sort_keys=True)
|
|
||||||
stream.flush(); os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, RESULT_FILE, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor)
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
|
|
||||||
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
|
|
||||||
flags = (
|
|
||||||
os.O_WRONLY
|
|
||||||
| os.O_CREAT
|
|
||||||
| os.O_EXCL
|
|
||||||
| getattr(os, "O_CLOEXEC", 0)
|
|
||||||
| getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
)
|
|
||||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
|
||||||
try:
|
|
||||||
os.fchmod(descriptor, 0o600)
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
|
||||||
stream.write(challenge)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(
|
|
||||||
temporary,
|
|
||||||
name,
|
|
||||||
src_dir_fd=directory_descriptor,
|
|
||||||
dst_dir_fd=directory_descriptor,
|
|
||||||
)
|
|
||||||
os.fsync(directory_descriptor)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_begin_reply(
|
|
||||||
completed: subprocess.CompletedProcess[str],
|
|
||||||
) -> tuple[str, dict[str, object] | None]:
|
|
||||||
if completed.returncode != 0:
|
|
||||||
return f"PROMOTER_EXIT_{completed.returncode}", None
|
|
||||||
try:
|
|
||||||
value = json.loads(
|
|
||||||
completed.stdout,
|
|
||||||
object_pairs_hook=reject_duplicate_json_keys,
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if value.get("ok") is False and set(value) == {"ok", "code"}:
|
|
||||||
code = value.get("code")
|
|
||||||
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
|
|
||||||
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if value.get("state") != "PENDING_VERIFICATION":
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
challenge = value.get("receipt_challenge")
|
|
||||||
receipt = value.get("receipt")
|
|
||||||
binding = value.get("binding")
|
|
||||||
if (
|
|
||||||
not isinstance(challenge, str)
|
|
||||||
or len(challenge) != 64
|
|
||||||
or any(character not in "0123456789abcdef" for character in challenge)
|
|
||||||
or not isinstance(receipt, str)
|
|
||||||
or not isinstance(binding, dict)
|
|
||||||
or set(binding) != EXPECTED_BINDING_KEYS
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
integer_fields = (
|
|
||||||
"compaction_epoch",
|
|
||||||
"request_epoch",
|
|
||||||
"runtime_generation",
|
|
||||||
"schema_version",
|
|
||||||
)
|
|
||||||
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not all(
|
|
||||||
isinstance(binding.get(field), str)
|
|
||||||
and len(binding[field]) == 64
|
|
||||||
and all(character in "0123456789abcdef" for character in binding[field])
|
|
||||||
for field in ("h_source", "h_payload")
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
if not secrets.compare_digest(
|
|
||||||
receipt.encode("utf-8"),
|
|
||||||
receipt_for(challenge, binding).encode("utf-8"),
|
|
||||||
):
|
|
||||||
return "INVALID_PROMOTER_REPLY", None
|
|
||||||
return "", value
|
|
||||||
|
|
||||||
|
|
||||||
def main(
|
|
||||||
*,
|
|
||||||
environ: Mapping[str, str] | None = None,
|
|
||||||
stdin: object | None = None,
|
|
||||||
stdout: TextIO | None = None,
|
|
||||||
stderr: TextIO | None = None,
|
|
||||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
||||||
now: Callable[[], float] = time.time,
|
|
||||||
) -> int:
|
|
||||||
source_environment = os.environ if environ is None else environ
|
|
||||||
input_stream = sys.stdin if stdin is None else stdin
|
|
||||||
output_stream = sys.stdout if stdout is None else stdout
|
|
||||||
error_stream = sys.stderr if stderr is None else stderr
|
|
||||||
|
|
||||||
try:
|
|
||||||
hook_input = read_hook_input(input_stream)
|
|
||||||
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
|
||||||
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
|
|
||||||
return 0
|
|
||||||
if hook_input.get("prompt") != PROMOTION_PROMPT:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
directory_descriptor: int | None = None
|
|
||||||
lock_descriptor: int | None = None
|
|
||||||
try:
|
|
||||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
|
||||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
directory_descriptor = open_pending_directory(runtime_dir)
|
|
||||||
lock_descriptor = acquire_lock(directory_descriptor)
|
|
||||||
wall_clock = now()
|
|
||||||
nonce = consume_authorization(directory_descriptor, session_id, wall_clock)
|
|
||||||
if nonce is None:
|
|
||||||
write_result(directory_descriptor, "0" * 64, False, "NOT_AUTHORIZED", session_id, wall_clock)
|
|
||||||
print("Mosaic promotion denied: NOT_AUTHORIZED.", file=error_stream)
|
|
||||||
return 0
|
|
||||||
sweep_stale_pending(directory_descriptor, wall_clock)
|
|
||||||
completed = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
|
||||||
code, reply = parse_begin_reply(completed)
|
|
||||||
if code or reply is None:
|
|
||||||
write_result(directory_descriptor, nonce, False, code or "PROMOTION_BEGIN_FAILED", session_id, now())
|
|
||||||
return 0
|
|
||||||
challenge = str(reply["receipt_challenge"])
|
|
||||||
observation = observer_request(
|
|
||||||
Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]),
|
|
||||||
{"action": "record_runtime_observation", "session_id": session_id, "runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]), "runtime": "claude", "latest_assistant_message": reply["receipt"]},
|
|
||||||
)
|
|
||||||
if set(observation) != {"ok"} or observation.get("ok") is not True:
|
|
||||||
write_result(directory_descriptor, challenge, False, "OBSERVATION_REJECTED", session_id, now())
|
|
||||||
return 0
|
|
||||||
completion = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--complete", challenge], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
|
|
||||||
try:
|
|
||||||
outcome = json.loads(completion.stdout, object_pairs_hook=reject_duplicate_json_keys)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
outcome = None
|
|
||||||
if completion.returncode == 0 and isinstance(outcome, dict) and outcome.get("stage") == "promote_lease" and outcome.get("ok") is True and outcome.get("state") == "VERIFIED":
|
|
||||||
write_result(directory_descriptor, challenge, True, None, session_id, now())
|
|
||||||
else:
|
|
||||||
reason = outcome.get("code") if isinstance(outcome, dict) and isinstance(outcome.get("code"), str) else "PROMOTION_INCOMPLETE"
|
|
||||||
write_result(directory_descriptor, challenge, False, reason, session_id, now())
|
|
||||||
except PromotionAlreadyInProgress:
|
|
||||||
print("Mosaic promotion denied: PROMOTION_ALREADY_IN_PROGRESS.", file=error_stream)
|
|
||||||
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
|
||||||
finally:
|
|
||||||
if lock_descriptor is not None:
|
|
||||||
os.close(lock_descriptor)
|
|
||||||
if directory_descriptor is not None:
|
|
||||||
os.close(directory_descriptor)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
#!/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,23 +22,13 @@ 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, object_pairs_hook=reject_duplicate_json_keys)
|
value = json.loads(raw)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer input")
|
raise ValueError("invalid observer input")
|
||||||
return value
|
return value
|
||||||
@@ -110,9 +100,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 response.count(b"\n") != 1 or not response.endswith(b"\n"):
|
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
|
value = json.loads(response)
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError("invalid observer reply")
|
raise ValueError("invalid observer reply")
|
||||||
return value
|
return value
|
||||||
@@ -129,12 +119,7 @@ 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 = claude_latest_entry(source)
|
||||||
message = source["last_assistant_message"]
|
|
||||||
if not isinstance(message, str):
|
|
||||||
raise ValueError("invalid Claude observer input")
|
|
||||||
else:
|
|
||||||
message = claude_latest_entry(source)
|
|
||||||
else:
|
else:
|
||||||
if arguments.latest_entry:
|
if arguments.latest_entry:
|
||||||
raise ValueError("Pi observer is message_end only")
|
raise ValueError("Pi observer is message_end only")
|
||||||
@@ -148,18 +133,10 @@ 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, RecursionError, ValueError, json.JSONDecodeError) as error:
|
except (KeyError, OSError, 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
|
||||||
if set(reply) == {"ok"} and reply.get("ok") is True:
|
return 0 if reply == {"ok": True} else 2
|
||||||
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.encode("utf-8"), expected.encode("utf-8"))
|
return hmac.compare_digest(message, expected)
|
||||||
|
|
||||||
|
|
||||||
def latest_assistant_digest(message: str) -> str:
|
def latest_assistant_digest(message: str) -> str:
|
||||||
|
|||||||
@@ -39,12 +39,11 @@ 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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$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; }
|
||||||
@@ -181,86 +180,41 @@ 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. The earlier test used a child cp shim to signal its parent, making
|
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
# the trap and shows the buggy resume-to-success.
|
||||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
make_term_shim() {
|
||||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
local dir="$1"
|
||||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
cat > "$dir/cp" <<SHIM
|
||||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
#!/usr/bin/env bash
|
||||||
TERM_MARKER='[test-control] TERM handler entered'
|
dest="\${@: -1}"
|
||||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
case "\$dest" in
|
||||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
*/$POISON_REL)
|
||||||
|
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||||
make_signal_installer() {
|
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||||
local output="$1" handler="$2"
|
esac
|
||||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
SHIM
|
||||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
chmod +x "$dir/cp"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||||
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 rc
|
local installer="$1" H OUT SHIM rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp)
|
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
|
make_term_shim "$SHIM"
|
||||||
set +e
|
set +e
|
||||||
PATH="$ORIG_PATH" \
|
PATH="$SHIM:$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 "$SIGNALED")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||||
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" \
|
||||||
@@ -268,13 +222,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'"
|
||||||
|
|
||||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||||
chk "[control] TERM handler observable fires exactly once" \
|
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
"$INSTALL" > "$NOEXIT"
|
||||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
chk "[control] the exit-strip actually changed the installer" \
|
||||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||||
"[ '$rcD' -eq 0 ]"
|
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
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'"
|
||||||
|
|
||||||
@@ -355,10 +309,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 (generated installer controls are also removed by the EXIT trap).
|
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL 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" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.49",
|
"version": "0.0.48",
|
||||||
"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/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"
|
"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/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,7 +15,6 @@ 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';
|
||||||
@@ -371,7 +370,6 @@ registerInteractionCommand(program);
|
|||||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
registerPromoteCommand(program);
|
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
// ─── config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ 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, hostname } from 'node:os';
|
import { homedir } 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 {
|
||||||
@@ -44,163 +42,6 @@ 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 {
|
||||||
@@ -264,11 +105,11 @@ interface SettingsAudit {
|
|||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
||||||
return { warnings };
|
return { warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,9 +561,7 @@ 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[] {
|
||||||
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
||||||
// <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
|
||||||
@@ -925,13 +764,12 @@ 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(harnessHome('codex'), 'instructions.md'));
|
ensureRuntimeConfig('codex', join(homedir(), '.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);
|
||||||
@@ -939,17 +777,14 @@ 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('codex', cliArgs, yolo);
|
execRuntime('codex', cliArgs);
|
||||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
recordLaunch('opencode', args, yolo);
|
execRuntime('opencode', args);
|
||||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +799,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1001,7 +835,6 @@ 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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
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, {
|
|
||||||
mintAuthorization: async () => {},
|
|
||||||
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, {
|
|
||||||
mintAuthorization: async () => {},
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { constants } from 'node:fs';
|
|
||||||
import { mkdir, open, rename } 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 AUTHORIZATION_DIRECTORY = 'authorizations';
|
|
||||||
const AUTHORIZATION_TTL_SECONDS = 60;
|
|
||||||
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;
|
|
||||||
target?: PromotionTarget;
|
|
||||||
timeoutMs?: number;
|
|
||||||
transport: PromotionTransport;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromoteCommandDeps {
|
|
||||||
mintAuthorization?: (target: PromotionTarget) => Promise<void>;
|
|
||||||
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 = options.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 }) => {
|
|
||||||
if (
|
|
||||||
process.env['MOSAIC_LEASE_SESSION_ID'] !== undefined &&
|
|
||||||
deps.mintAuthorization === undefined
|
|
||||||
) {
|
|
||||||
console.error('mosaic promote must run outside a lease-gated seat.');
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let target: PromotionTarget;
|
|
||||||
try {
|
|
||||||
target = await transport.resolve(seat);
|
|
||||||
await (deps.mintAuthorization ?? mintAuthorization)(target);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(`mosaic promote authorization failed: ${errorMessage(error)}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await promoteSeat(seat, {
|
|
||||||
store,
|
|
||||||
target,
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mintAuthorization(target: PromotionTarget): Promise<void> {
|
|
||||||
const directory = join(defaultRuntimeDirectory(), PENDING_DIRECTORY, AUTHORIZATION_DIRECTORY);
|
|
||||||
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
||||||
const token = {
|
|
||||||
expires_at: wallClockSeconds() + AUTHORIZATION_TTL_SECONDS,
|
|
||||||
nonce: randomBytes(32).toString('hex'),
|
|
||||||
seat: target.seat,
|
|
||||||
session_id: target.sessionId,
|
|
||||||
ts: wallClockSeconds(),
|
|
||||||
};
|
|
||||||
const destination = join(directory, `${target.sessionId}.auth`);
|
|
||||||
const temporary = join(directory, `.${target.sessionId}.${randomBytes(8).toString('hex')}.tmp`);
|
|
||||||
const handle = await open(
|
|
||||||
temporary,
|
|
||||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
||||||
0o600,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await handle.chmod(0o600);
|
|
||||||
await handle.writeFile(JSON.stringify(token));
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
await rename(temporary, destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""RED-first contracts for the operator-triggered Claude promotion hooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
|
||||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
|
||||||
TOOLS = FRAMEWORK / "tools/lease-broker"
|
|
||||||
BEGIN_PATH = TOOLS / "promote-begin.py"
|
|
||||||
COMPLETE_PATH = TOOLS / "promote-complete.py"
|
|
||||||
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
|
||||||
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
|
|
||||||
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
|
|
||||||
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
|
|
||||||
SESSION_ID = "a" * 64
|
|
||||||
CHALLENGE = "b" * 64
|
|
||||||
H_PAYLOAD = "c" * 64
|
|
||||||
RECEIPT = (
|
|
||||||
f"MOSAIC-RECEIPT{{challenge={CHALLENGE}; H_payload={H_PAYLOAD}; gen=1; cep=0}}"
|
|
||||||
)
|
|
||||||
NOW = 10_000.0
|
|
||||||
|
|
||||||
|
|
||||||
def load_module(name: str, path: Path):
|
|
||||||
if not path.is_file():
|
|
||||||
raise AssertionError(f"shipped module is missing: {path}")
|
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError(f"unable to load {name}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionHookFixture(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls.begin = load_module("promotion_begin_test", BEGIN_PATH)
|
|
||||||
cls.complete = load_module("promotion_complete_test", COMPLETE_PATH)
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.temporary = tempfile.TemporaryDirectory()
|
|
||||||
self.runtime_dir = Path(self.temporary.name)
|
|
||||||
self.environment = {
|
|
||||||
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
|
||||||
"MOSAIC_LEASE_SESSION_ID": SESSION_ID,
|
|
||||||
}
|
|
||||||
self.pending_dir = self.runtime_dir / "mosaic-lease"
|
|
||||||
self.pending_file = self.pending_dir / f"pending-{SESSION_ID}"
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
self.temporary.cleanup()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def completed(payload: dict[str, object], returncode: int = 0, stderr: str = ""):
|
|
||||||
return subprocess.CompletedProcess(
|
|
||||||
["lease_promote.py"],
|
|
||||||
returncode,
|
|
||||||
json.dumps(payload),
|
|
||||||
stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def successful_begin_reply(cls, **extra: object) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"state": "PENDING_VERIFICATION",
|
|
||||||
"receipt_challenge": CHALLENGE,
|
|
||||||
"receipt": RECEIPT,
|
|
||||||
"binding": {
|
|
||||||
"compaction_epoch": 0,
|
|
||||||
"request_epoch": 0,
|
|
||||||
"h_source": "d" * 64,
|
|
||||||
"h_payload": H_PAYLOAD,
|
|
||||||
"runtime_generation": 1,
|
|
||||||
"schema_version": 1,
|
|
||||||
},
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
def write_authorization(self) -> None:
|
|
||||||
directory = self.pending_dir / "authorizations"
|
|
||||||
directory.mkdir(parents=True, mode=0o700)
|
|
||||||
self.pending_dir.chmod(0o700)
|
|
||||||
directory.chmod(0o700)
|
|
||||||
token = directory / f"{SESSION_ID}.auth"
|
|
||||||
token.write_text(json.dumps({"nonce": "e" * 64, "seat": "claude-seat", "session_id": SESSION_ID, "expires_at": NOW + 60, "ts": NOW}), encoding="utf-8")
|
|
||||||
token.chmod(0o600)
|
|
||||||
|
|
||||||
def run_begin(
|
|
||||||
self,
|
|
||||||
prompt: str,
|
|
||||||
runner: mock.Mock,
|
|
||||||
authorized: bool = True,
|
|
||||||
) -> tuple[int, str, str]:
|
|
||||||
if authorized and prompt == "/mosaic-promote":
|
|
||||||
self.write_authorization()
|
|
||||||
stdout = io.StringIO()
|
|
||||||
stderr = io.StringIO()
|
|
||||||
self.observer = mock.Mock(return_value={"ok": True})
|
|
||||||
with mock.patch.object(self.begin, "observer_request", self.observer):
|
|
||||||
result = self.begin.main(
|
|
||||||
environ={**self.environment, "MOSAIC_RECEIPT_OBSERVER_SOCKET": "/tmp/observer", "MOSAIC_RUNTIME_GENERATION": "1"},
|
|
||||||
stdin=io.BytesIO(json.dumps({"prompt": prompt}).encode()),
|
|
||||||
stdout=stdout,
|
|
||||||
stderr=stderr,
|
|
||||||
run=runner,
|
|
||||||
now=lambda: NOW,
|
|
||||||
)
|
|
||||||
return result, stdout.getvalue(), stderr.getvalue()
|
|
||||||
|
|
||||||
def write_pending(self, challenge: str = CHALLENGE) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700, exist_ok=True)
|
|
||||||
self.pending_file.write_text(challenge, encoding="utf-8")
|
|
||||||
self.pending_file.chmod(0o600)
|
|
||||||
|
|
||||||
def run_complete(
|
|
||||||
self,
|
|
||||||
runner: mock.Mock,
|
|
||||||
now: float | None = None,
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
stderr = io.StringIO()
|
|
||||||
options: dict[str, object] = {
|
|
||||||
"environ": self.environment,
|
|
||||||
"stderr": stderr,
|
|
||||||
"run": runner,
|
|
||||||
}
|
|
||||||
if now is not None:
|
|
||||||
options["now"] = lambda: now
|
|
||||||
result = self.complete.main(**options)
|
|
||||||
return result, stderr.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionBeginTest(PromotionHookFixture):
|
|
||||||
def test_injected_exact_promotion_without_authorization_is_inert(self) -> None:
|
|
||||||
runner = mock.Mock()
|
|
||||||
result, stdout, stderr = self.run_begin("/mosaic-promote", runner, authorized=False)
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stdout, "")
|
|
||||||
self.assertIn("NOT_AUTHORIZED", stderr)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.observer.assert_not_called()
|
|
||||||
self.assertEqual(json.loads((self.pending_dir / "last-result.json").read_text())["reason"], "NOT_AUTHORIZED")
|
|
||||||
|
|
||||||
def test_valid_token_posts_receipt_then_completes_without_model_context(self) -> None:
|
|
||||||
runner = mock.Mock(side_effect=[
|
|
||||||
self.completed(self.successful_begin_reply()),
|
|
||||||
self.completed({"stage": "promote_lease", "ok": True, "state": "VERIFIED"}),
|
|
||||||
])
|
|
||||||
result, stdout, stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
self.assertEqual((result, stdout, stderr), (0, "", ""))
|
|
||||||
self.assertFalse((self.pending_dir / "authorizations" / f"{SESSION_ID}.auth").exists())
|
|
||||||
self.observer.assert_called_once()
|
|
||||||
self.assertEqual(self.observer.call_args.args[1]["latest_assistant_message"], RECEIPT)
|
|
||||||
self.assertEqual(runner.call_args_list[1].args[0][-2:], ["--complete", CHALLENGE])
|
|
||||||
self.assertTrue(json.loads((self.pending_dir / "last-result.json").read_text())["verified"])
|
|
||||||
|
|
||||||
@unittest.skip("superseded by mechanical promotion")
|
|
||||||
def test_exact_prompt_writes_private_challenge_and_injects_verbatim_receipt(self) -> None:
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
output = json.loads(stdout)
|
|
||||||
self.assertEqual(
|
|
||||||
output["hookSpecificOutput"]["additionalContext"],
|
|
||||||
"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. "
|
|
||||||
f"Reply with exactly the following text and nothing else: {RECEIPT}",
|
|
||||||
)
|
|
||||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), CHALLENGE)
|
|
||||||
self.assertEqual(stat.S_IMODE(self.pending_file.stat().st_mode), 0o600)
|
|
||||||
command = runner.call_args.args[0]
|
|
||||||
self.assertEqual(command[-1], "--begin")
|
|
||||||
self.assertTrue(command[-2].endswith("lease_promote.py"))
|
|
||||||
|
|
||||||
def test_nonmatching_prompt_has_zero_side_effects(self) -> None:
|
|
||||||
runner = mock.Mock()
|
|
||||||
|
|
||||||
result, stdout, stderr = self.run_begin("please /mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stdout, "")
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse(self.pending_dir.exists())
|
|
||||||
|
|
||||||
@unittest.skip("superseded by breadcrumb-only mechanical errors")
|
|
||||||
def test_begin_refusal_reports_daemon_code_without_pending_file(self) -> None:
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed({"ok": False, "code": "INVALID_BINDING"})
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertIn("INVALID_BINDING", json.loads(stdout)["hookSpecificOutput"]["additionalContext"])
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_sweep_removes_stale_sibling_and_spares_fresh_sibling(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700)
|
|
||||||
stale = self.pending_dir / "pending-stale"
|
|
||||||
fresh = self.pending_dir / "pending-fresh"
|
|
||||||
stale.write_text("stale", encoding="utf-8")
|
|
||||||
fresh.write_text("fresh", encoding="utf-8")
|
|
||||||
os.utime(stale, (NOW - 3_601, NOW - 3_601))
|
|
||||||
os.utime(fresh, (NOW - 3_599, NOW - 3_599))
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(stale.exists())
|
|
||||||
self.assertTrue(fresh.exists())
|
|
||||||
|
|
||||||
def test_sweep_removes_stale_atomic_temporary_file(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o700)
|
|
||||||
stale_temporary = self.pending_dir / f".pending-{SESSION_ID}.tmp-abandoned"
|
|
||||||
stale_temporary.write_text("partial", encoding="utf-8")
|
|
||||||
os.utime(stale_temporary, (NOW - 3_601, NOW - 3_601))
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(stale_temporary.exists())
|
|
||||||
|
|
||||||
@unittest.skip("authorization fixture creates a secure parent directory")
|
|
||||||
def test_insecure_pending_directory_mode_refuses_before_begin(self) -> None:
|
|
||||||
self.pending_dir.mkdir(mode=0o755)
|
|
||||||
self.pending_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertEqual(stdout, "")
|
|
||||||
|
|
||||||
def test_insecure_runtime_directory_mode_refuses_before_begin(self) -> None:
|
|
||||||
self.runtime_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertEqual(stdout, "")
|
|
||||||
|
|
||||||
def test_parent_symlink_cannot_redirect_pending_write(self) -> None:
|
|
||||||
outside = self.runtime_dir / "outside"
|
|
||||||
outside.mkdir(mode=0o700)
|
|
||||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
|
||||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertFalse((outside / f"pending-{SESSION_ID}").exists())
|
|
||||||
|
|
||||||
@unittest.skip("single-use authorization supersedes pending challenge concurrency")
|
|
||||||
def test_concurrent_begin_is_refused_without_minting_a_second_challenge(self) -> None:
|
|
||||||
inner_runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
|
||||||
inner_result: list[tuple[int, str, str]] = []
|
|
||||||
|
|
||||||
def overlap(*_args: object, **_kwargs: object):
|
|
||||||
inner_result.append(self.run_begin("/mosaic-promote", inner_runner))
|
|
||||||
return self.completed(self.successful_begin_reply())
|
|
||||||
|
|
||||||
outer_runner = mock.Mock(side_effect=overlap)
|
|
||||||
|
|
||||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", outer_runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
inner_runner.assert_not_called()
|
|
||||||
self.assertEqual(inner_result[0][0], 0)
|
|
||||||
self.assertIn("PROMOTION_ALREADY_IN_PROGRESS", inner_result[0][1])
|
|
||||||
|
|
||||||
@unittest.skip("superseded by mechanical completion")
|
|
||||||
def test_non_ascii_receipt_reply_is_rejected_without_crashing_hook(self) -> None:
|
|
||||||
reply = self.successful_begin_reply()
|
|
||||||
reply["receipt"] = "MOSAIC—RECEIPT"
|
|
||||||
runner = mock.Mock(return_value=self.completed(reply))
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
|
||||||
|
|
||||||
@unittest.skip("superseded by mechanical completion")
|
|
||||||
def test_success_shaped_reply_with_extra_fields_is_rejected(self) -> None:
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(self.successful_begin_reply(unexpected=True))
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionCompleteTest(PromotionHookFixture):
|
|
||||||
def test_no_pending_file_is_zero_cost_success(self) -> None:
|
|
||||||
runner = mock.Mock()
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stderr, "")
|
|
||||||
runner.assert_not_called()
|
|
||||||
|
|
||||||
def test_success_deletes_pending_file(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertEqual(runner.call_args.args[0][-2:], ["--complete", CHALLENGE])
|
|
||||||
|
|
||||||
def test_success_atomically_writes_a_private_correlated_result_with_wall_clock_expiry(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch.object(self.complete.os, "replace", wraps=os.replace) as replace:
|
|
||||||
result, _stderr = self.run_complete(runner, now=12_345.0)
|
|
||||||
|
|
||||||
result_file = self.pending_dir / "last-result.json"
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
|
|
||||||
self.assertEqual(
|
|
||||||
json.loads(result_file.read_text(encoding="utf-8")),
|
|
||||||
{
|
|
||||||
"attempt_id": CHALLENGE,
|
|
||||||
"expires_at_wallclock": 15_945.0,
|
|
||||||
"reason": None,
|
|
||||||
"session_id": SESSION_ID,
|
|
||||||
"ts": 12_345.0,
|
|
||||||
"verified": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
temporary, destination = replace.call_args.args
|
|
||||||
self.assertRegex(temporary, r"^\.last-result\.json\.tmp-[0-9a-f]+$")
|
|
||||||
self.assertEqual(destination, "last-result.json")
|
|
||||||
self.assertFalse(any(path.name.startswith(".last-result.json.tmp-") for path in self.pending_dir.iterdir()))
|
|
||||||
|
|
||||||
def test_terminal_failure_writes_a_private_correlated_unverified_result(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "observe_receipt", "ok": False, "code": "RECEIPT_MISMATCH"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner, now=12_345.0)
|
|
||||||
|
|
||||||
result_file = self.pending_dir / "last-result.json"
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
|
|
||||||
self.assertEqual(
|
|
||||||
json.loads(result_file.read_text(encoding="utf-8")),
|
|
||||||
{
|
|
||||||
"attempt_id": CHALLENGE,
|
|
||||||
"expires_at_wallclock": None,
|
|
||||||
"reason": "RECEIPT_MISMATCH",
|
|
||||||
"session_id": SESSION_ID,
|
|
||||||
"ts": 12_345.0,
|
|
||||||
"verified": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_each_terminal_failure_deletes_pending_file(self) -> None:
|
|
||||||
terminal_codes = (
|
|
||||||
"RECEIPT_REPLAY",
|
|
||||||
"RECEIPT_MISMATCH",
|
|
||||||
"INVALID_LEASE_TRANSITION",
|
|
||||||
"PROMOTION_TOKEN_INVALID",
|
|
||||||
)
|
|
||||||
for code in terminal_codes:
|
|
||||||
with self.subTest(code=code):
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertFalse(self.pending_file.exists())
|
|
||||||
self.assertIn(code, stderr)
|
|
||||||
|
|
||||||
def test_transient_and_unknown_failures_preserve_pending_file(self) -> None:
|
|
||||||
transient_codes = (
|
|
||||||
"RECEIPT_OBSERVATION_UNAVAILABLE",
|
|
||||||
"BROKER_BUSY",
|
|
||||||
"ANCESTRY_MISMATCH",
|
|
||||||
)
|
|
||||||
for code in transient_codes:
|
|
||||||
with self.subTest(code=code):
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
self.assertIn(code, stderr)
|
|
||||||
|
|
||||||
def test_transport_failure_preserves_pending_file_and_exits_zero(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed({}, returncode=2, stderr="ConnectionRefusedError")
|
|
||||||
)
|
|
||||||
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
self.assertIn("ConnectionRefusedError", stderr)
|
|
||||||
|
|
||||||
def test_result_write_failure_exits_zero(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch.object(self.complete, "write_result", side_effect=OSError("disk full")):
|
|
||||||
result, stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertIn("OSError", stderr)
|
|
||||||
|
|
||||||
def test_missing_lease_session_id_exits_zero(self) -> None:
|
|
||||||
self.write_pending()
|
|
||||||
environment = dict(self.environment)
|
|
||||||
del environment["MOSAIC_LEASE_SESSION_ID"]
|
|
||||||
runner = mock.Mock()
|
|
||||||
stderr = io.StringIO()
|
|
||||||
|
|
||||||
result = self.complete.main(environ=environment, stderr=stderr, run=runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertIn("KeyError", stderr.getvalue())
|
|
||||||
|
|
||||||
def test_insecure_runtime_directory_mode_preserves_pending(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
self.runtime_dir.chmod(0o755)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_parent_symlink_cannot_redirect_pending_read_or_delete(self) -> None:
|
|
||||||
outside = self.runtime_dir / "outside"
|
|
||||||
outside.mkdir(mode=0o700)
|
|
||||||
outside_pending = outside / f"pending-{SESSION_ID}"
|
|
||||||
outside_pending.write_text(CHALLENGE, encoding="utf-8")
|
|
||||||
outside_pending.chmod(0o600)
|
|
||||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(outside_pending.exists())
|
|
||||||
|
|
||||||
def test_insecure_pending_file_mode_is_not_consumed(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
self.pending_file.chmod(0o644)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
runner.assert_not_called()
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
def test_concurrent_replacement_is_not_deleted_after_success(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
|
|
||||||
def replace_pending(*_args: object, **_kwargs: object):
|
|
||||||
replacement = self.pending_dir / "replacement"
|
|
||||||
replacement.write_text("replacement", encoding="utf-8")
|
|
||||||
replacement.chmod(0o600)
|
|
||||||
os.replace(replacement, self.pending_file)
|
|
||||||
return self.completed(
|
|
||||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
|
||||||
)
|
|
||||||
|
|
||||||
runner = mock.Mock(side_effect=replace_pending)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), "replacement")
|
|
||||||
|
|
||||||
def test_success_shaped_reply_with_extra_fields_preserves_pending(self) -> None:
|
|
||||||
self.write_pending(CHALLENGE)
|
|
||||||
runner = mock.Mock(
|
|
||||||
return_value=self.completed(
|
|
||||||
{
|
|
||||||
"stage": "promote_lease",
|
|
||||||
"ok": True,
|
|
||||||
"state": "VERIFIED",
|
|
||||||
"unexpected": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result, _stderr = self.run_complete(runner)
|
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
|
||||||
self.assertTrue(self.pending_file.exists())
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionTemplateWiringTest(unittest.TestCase):
|
|
||||||
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
|
|
||||||
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
|
||||||
hooks = settings["hooks"]
|
|
||||||
submit_commands = [
|
|
||||||
hook["command"]
|
|
||||||
for group in hooks["UserPromptSubmit"]
|
|
||||||
for hook in group["hooks"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
submit_commands,
|
|
||||||
["python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py"],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[group.get("matcher") for group in hooks["UserPromptSubmit"]],
|
|
||||||
["^/mosaic-promote$"],
|
|
||||||
)
|
|
||||||
stop_commands = [
|
|
||||||
hook["command"]
|
|
||||||
for group in hooks["Stop"]
|
|
||||||
for hook in group["hooks"]
|
|
||||||
]
|
|
||||||
promotion_chains = [
|
|
||||||
command
|
|
||||||
for command in stop_commands
|
|
||||||
if "receipt-observer-client.py" in command and "promote-complete.py" in command
|
|
||||||
]
|
|
||||||
self.assertEqual(len(promotion_chains), 1)
|
|
||||||
chain = promotion_chains[0]
|
|
||||||
self.assertLess(
|
|
||||||
chain.index("receipt-observer-client.py"),
|
|
||||||
chain.index("promote-complete.py"),
|
|
||||||
)
|
|
||||||
self.assertIn("observer_status=$?", chain)
|
|
||||||
self.assertTrue(chain.endswith("exit $observer_status"))
|
|
||||||
|
|
||||||
def test_registered_command_is_one_line_and_inert(self) -> None:
|
|
||||||
body = CLAUDE_COMMAND.read_text(encoding="utf-8")
|
|
||||||
self.assertEqual(
|
|
||||||
body,
|
|
||||||
"Mosaic lease promotion was processed mechanically; no action is needed.\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromotionVerbatimToleranceTest(unittest.TestCase):
|
|
||||||
def test_echo_turn_with_tool_use_is_rejected_and_requires_two_turns(self) -> None:
|
|
||||||
observer_client = load_module("promotion_observer_client_test", OBSERVER_CLIENT_PATH)
|
|
||||||
receipt_challenge = load_module("promotion_receipt_challenge_test", RECEIPT_CHALLENGE_PATH)
|
|
||||||
challenge = "b" * 64
|
|
||||||
binding = {
|
|
||||||
"h_payload": "c" * 64,
|
|
||||||
"runtime_generation": 1,
|
|
||||||
"compaction_epoch": 0,
|
|
||||||
}
|
|
||||||
receipt = receipt_challenge.receipt_for(challenge, binding)
|
|
||||||
real_claude_entry = {
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": receipt},
|
|
||||||
{
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": "tool-1",
|
|
||||||
"name": "mcp__discord__reply",
|
|
||||||
"input": {"message": "promoted"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extracted = observer_client.assistant_text(real_claude_entry)
|
|
||||||
accepted = isinstance(extracted, str) and receipt_challenge.is_verbatim_receipt(
|
|
||||||
extracted,
|
|
||||||
challenge,
|
|
||||||
binding,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNone(extracted)
|
|
||||||
self.assertFalse(accepted)
|
|
||||||
|
|
||||||
|
|
||||||
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