Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea806262c8 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 |
@@ -1,49 +0,0 @@
|
||||
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
||||
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
||||
when:
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: [next, main]
|
||||
|
||||
steps:
|
||||
greenfield-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||
> /tmp/greenfield-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-main-git-present:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||
> /tmp/greenfield-main-git-present.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-main-git-present.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
||||
|
||||
greenfield-git-absent:
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- |
|
||||
set +e
|
||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||
> /tmp/greenfield-git-absent.log 2>&1
|
||||
fixture_status=$?
|
||||
set -e
|
||||
cat /tmp/greenfield-git-absent.log
|
||||
bash tools/verify-greenfield-expected-red.sh \
|
||||
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
||||
+5
-104
@@ -1,5 +1,5 @@
|
||||
# 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:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,21 +23,9 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -115,84 +103,6 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -224,17 +134,8 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -245,7 +146,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -271,7 +172,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -30,16 +30,6 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||
| 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` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -48,14 +38,10 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||
- Node.js ≥ 20 and npm ≥ 9
|
||||
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
|
||||
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||
|
||||
## Usage
|
||||
|
||||
### Launching Agent Sessions
|
||||
@@ -351,7 +337,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```bash
|
||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||
```
|
||||
|
||||
Or use the direct URL:
|
||||
@@ -372,17 +358,15 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
### Installer Flags
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -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 { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -16,9 +14,6 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -60,14 +55,6 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||
function buildService(): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
redis as never,
|
||||
mockRedis as never,
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
@@ -131,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(ttl).toBe(300);
|
||||
});
|
||||
|
||||
it('/provider login remains available without Redis on the local tier', async () => {
|
||||
const localService = buildService(null);
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const result = await localService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).not.toContain('token=');
|
||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
|
||||
@@ -23,10 +23,7 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -446,16 +443,14 @@ export class CommandExecutorService {
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||
if (this.redis) {
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
|
||||
@@ -1,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 type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection.
|
||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
|
||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||
// an unclamped result still misses by three orders of magnitude.
|
||||
//
|
||||
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||
const CI_JITTER_MS = 5_000;
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
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 type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
|
||||
).resolves.toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||
|
||||
const result = await localService.collect('local-session');
|
||||
|
||||
expect(result.sessionId).toBe('local-session');
|
||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||
'local-session',
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('collect() returns sessionId in result', async () => {
|
||||
const result = await service.collect('test-session-id');
|
||||
expect(result.sessionId).toBe('test-session-id');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
||||
@Injectable()
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@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).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns an empty population on the Local tier where Redis is disabled.
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -52,14 +47,12 @@ export class SessionGCService {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||
if (!this.queueService.isEnabled()) {
|
||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
|
||||
@@ -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 type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
private readonly handle: QueueHandle;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
const key = scopedSessionId(sessionId, scope);
|
||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(
|
||||
entry.fragments.map((fragment) => fragment.text),
|
||||
);
|
||||
this.localStore.set(key, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) return;
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
|
||||
@@ -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';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
this.connection = this.enabled
|
||||
? getConnection()
|
||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||
}
|
||||
|
||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
this.connection = getConnection();
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.enabled) {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||
);
|
||||
}
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
}
|
||||
|
||||
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.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
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).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueue<T>(queueName)!;
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* safe retirement of previously registered system-wide jobs.
|
||||
*/
|
||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const queue = this.getQueue(queueName);
|
||||
if (!queue) return 0;
|
||||
const jobs = await queue.getRepeatableJobs();
|
||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
handler: JobHandler<T>,
|
||||
): Worker<T> | null {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
const worker = new Worker<T>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* job IDs are not globally unique — they are scoped to their queue.
|
||||
* Returns an error on Local tier.
|
||||
*/
|
||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
-33
@@ -1368,36 +1368,3 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||
|
||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||
|
||||
---
|
||||
|
||||
## Greenfield install correctness — C1 (#1050)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
||||
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
||||
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
||||
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
||||
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass.
|
||||
|
||||
### C1 acceptance criteria
|
||||
|
||||
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
||||
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
||||
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
||||
4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869.
|
||||
|
||||
### Explicit exclusions and dependencies
|
||||
|
||||
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
||||
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
||||
- RM-02 and #869 activation are out of scope.
|
||||
|
||||
@@ -9,11 +9,6 @@
|
||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||
|
||||
## Installation and upgrades
|
||||
|
||||
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Installer State Machine and Recovery
|
||||
|
||||
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||
|
||||
## Canonical phases
|
||||
|
||||
| Phase | Responsibility | Failure disposition |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||
| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot |
|
||||
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||
| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding |
|
||||
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||
|
||||
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||
|
||||
## Side-effect-free check
|
||||
|
||||
```bash
|
||||
bash tools/install.sh --check # stable/latest lane
|
||||
bash tools/install.sh --check --next # prerelease lane
|
||||
```
|
||||
|
||||
`--check`:
|
||||
|
||||
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||
- exits non-zero if any predicate fails;
|
||||
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||
|
||||
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||
|
||||
## Durable journal
|
||||
|
||||
Each mutating run creates a private transaction directory:
|
||||
|
||||
```text
|
||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||
active.json
|
||||
<UTC-run-id>/
|
||||
journal.ndjson
|
||||
journal.ndjson.sha256 # committed runs only
|
||||
commands.log
|
||||
snapshot/
|
||||
```
|
||||
|
||||
Before each mutation scope is touched, `journal.ndjson` records:
|
||||
|
||||
- phase and path;
|
||||
- whether prior state existed and where its snapshot lives;
|
||||
- the reversal action;
|
||||
- the captured command-output location and command status.
|
||||
|
||||
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||
|
||||
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||
|
||||
`active.json` is the current projection:
|
||||
|
||||
- `in-progress`: incomplete/open transaction;
|
||||
- `rolled-back`: a fault restored the snapshot;
|
||||
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||
- `committed`: P9 passed and the journal is sealed.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
1. Read the named phase and remediation line from installer stderr.
|
||||
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||
|
||||
## Greenfield CI gate
|
||||
|
||||
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||
|
||||
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
||||
|
||||
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||
|
||||
The fixture is lane-parametric:
|
||||
|
||||
```bash
|
||||
bash tools/e2e-install-test.sh --lane next --git present
|
||||
bash tools/e2e-install-test.sh --lane main --git present
|
||||
```
|
||||
|
||||
CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
||||
|
||||
## Source trust boundary
|
||||
|
||||
Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture.
|
||||
@@ -12,20 +12,6 @@ with no snapshot to fall back to.
|
||||
Protection is layered. Each layer is independent; a later layer catches what an
|
||||
earlier one misses.
|
||||
|
||||
## Layer 0 — Transaction journal (install-wide recovery)
|
||||
|
||||
The unified installer opens a private journal under
|
||||
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||
seals successful journals with a SHA-256 sidecar. See
|
||||
[Installer state machine and recovery](./installer-state-machine.md).
|
||||
|
||||
This transaction journal is distinct from the retained operator-only backup
|
||||
below. The transaction journal is required for correctness and rollback;
|
||||
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||
for a manifest bug that the normal transaction did not detect.
|
||||
|
||||
## Layer 1 — Manifest-owned sync (prevention)
|
||||
|
||||
The single source of truth for ownership is
|
||||
|
||||
@@ -175,18 +175,8 @@ Or use the direct URL:
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||
|
||||
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:
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||
non-interactive use:
|
||||
|
||||
```bash
|
||||
--yes # Accept all defaults
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -1,68 +0,0 @@
|
||||
# #1050 — Installer P0–P9 state machine and red-first fixture
|
||||
|
||||
## Objective
|
||||
|
||||
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
||||
|
||||
## Authority and scope
|
||||
|
||||
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
||||
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
||||
2. Commit the immutable red-first acceptance fixture before implementation.
|
||||
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
||||
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
||||
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 32K reasoning/output tokens.
|
||||
- Hard external cap: none stated.
|
||||
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
||||
|
||||
## Pre-registered acceptance checks
|
||||
|
||||
| ID | Exact case | Expected pre-fix result |
|
||||
|---|---|---|
|
||||
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
||||
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
||||
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
||||
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
||||
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
||||
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
||||
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||
- [x] Issue #1050 created and provider author read back.
|
||||
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
||||
- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
||||
- #869 must remain staged and inactive.
|
||||
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||
|
||||
## Verification log
|
||||
|
||||
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization.
|
||||
- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch.
|
||||
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
||||
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# PR merge squash message field
|
||||
|
||||
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Branch:** `fix/pr-merge-message-field`
|
||||
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||
|
||||
## Objective
|
||||
|
||||
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||
|
||||
## Binding requirements
|
||||
|
||||
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||
|
||||
## Derived interface decisions
|
||||
|
||||
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||
|
||||
## Canonical delivery plan
|
||||
|
||||
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||
|
||||
## Remediation and current review state
|
||||
|
||||
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||
|
||||
## Disposable provider fixture acceptance
|
||||
|
||||
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||
|
||||
## Fixture preflight
|
||||
|
||||
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||
|
||||
## Fixture result
|
||||
|
||||
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||
- Trailer-shaped lines, verbatim and in order:
|
||||
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||
|
||||
## Current hold point
|
||||
|
||||
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||
|
||||
## Security review 96 remediation
|
||||
|
||||
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||
|
||||
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||
|
||||
Security remediation:
|
||||
|
||||
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||
|
||||
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||
+1
-2
@@ -10,8 +10,7 @@
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
|
||||
@@ -11,37 +11,9 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
import type { SelectOption } from '../../src/prompter/interface.js';
|
||||
import type { MenuSection, WizardState } from '../../src/types.js';
|
||||
|
||||
const gatewayConfigMock = vi.fn();
|
||||
const gatewayBootstrapMock = vi.fn();
|
||||
const providerSetupMock = vi.fn();
|
||||
const skillsSelectMock = vi.fn();
|
||||
|
||||
class SequencedMenuPrompter extends HeadlessPrompter {
|
||||
constructor(
|
||||
answers: Record<string, string | boolean | string[]>,
|
||||
private readonly menuChoices: string[],
|
||||
) {
|
||||
super(answers);
|
||||
}
|
||||
|
||||
override async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
if (opts.message === 'What would you like to configure?') {
|
||||
const next = this.menuChoices.shift();
|
||||
if (!next) throw new Error('No queued menu choice left');
|
||||
const match = opts.options.find((o) => String(o.value) === next);
|
||||
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
|
||||
return match.value;
|
||||
}
|
||||
return super.select(opts);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
@@ -51,14 +23,6 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/provider-setup.js', () => ({
|
||||
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/skills-select.js', () => ({
|
||||
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
@@ -80,16 +44,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
}
|
||||
gatewayConfigMock.mockReset();
|
||||
gatewayBootstrapMock.mockReset();
|
||||
providerSetupMock.mockReset();
|
||||
skillsSelectMock.mockReset();
|
||||
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.providerType = 'none';
|
||||
state.completedSections?.add('providers' satisfies MenuSection);
|
||||
});
|
||||
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.selectedSkills = [];
|
||||
state.completedSections?.add('skills' satisfies MenuSection);
|
||||
});
|
||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||
// branch does not call `process.exit(1)` during these tests.
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('prints the success summary only after gateway health succeeds', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.log('Gateway is healthy.');
|
||||
return { ready: true, host: 'localhost', port: 14242 };
|
||||
});
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
@@ -168,43 +118,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
|
||||
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
|
||||
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
|
||||
|
||||
expect(healthIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(summaryIndex).toBeGreaterThan(healthIndex);
|
||||
expect(readyIndex).toBeGreaterThan(summaryIndex);
|
||||
});
|
||||
|
||||
it('does not claim success when gateway health reports not ready', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
||||
return { ready: false };
|
||||
});
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -230,34 +143,4 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-run completed provider or skills menu steps', async () => {
|
||||
const prompter = new SequencedMenuPrompter(
|
||||
{
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
},
|
||||
['providers', 'providers', 'skills', 'skills', 'finish'],
|
||||
);
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(providerSetupMock).toHaveBeenCalledTimes(1);
|
||||
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
|
||||
expect(prompter.getLogs()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Providers [done] is already complete; skipping.'),
|
||||
expect.stringContaining('Skills [done] is already complete; skipping.'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,16 +43,6 @@ The installer:
|
||||
- Runs a health audit
|
||||
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
|
||||
|
||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
|
||||
|
||||
## First Run
|
||||
|
||||
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
||||
@@ -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)
|
||||
├── STANDARDS.md ← Machine-wide standards
|
||||
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
||||
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
|
||||
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
||||
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
|
||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||
@@ -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 --framework # Framework only (skip npm CLI)
|
||||
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 --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 --ref v1.0 # Install from a specific git ref
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
mosaic sync # Full canonical catalog sync
|
||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
mosaic sync # Full canonical catalog sync
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||
```
|
||||
|
||||
## MCP Registration
|
||||
@@ -222,8 +209,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
|
||||
To verify or re-register manually:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||
```
|
||||
|
||||
### Claude Code MCP Registration
|
||||
|
||||
@@ -58,7 +58,6 @@ done
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
@@ -223,14 +222,12 @@ prune_durable_snapshots() {
|
||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||
list="$(mktemp)"
|
||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||
# than risk pruning in an undefined order.
|
||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
while IFS= read -r d; do
|
||||
@@ -269,11 +266,7 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||
return 0
|
||||
fi
|
||||
if ! chmod 700 "$root"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$root" 2>/dev/null || true
|
||||
dir="$root/pre-update-$ts"
|
||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||
@@ -288,10 +281,7 @@ make_durable_snapshot() {
|
||||
if ! enumerate_operator_files "$list"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||
rm -f "$list"
|
||||
if ! rmdir "$dir"; then
|
||||
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||
fi
|
||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' rel; do
|
||||
@@ -302,18 +292,12 @@ make_durable_snapshot() {
|
||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||
continue
|
||||
fi
|
||||
if ! chmod 600 "$dst"; then
|
||||
rm -f "$dst"
|
||||
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||
continue
|
||||
fi
|
||||
chmod 600 "$dst" 2>/dev/null || true
|
||||
count=$((count + 1))
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||
fi
|
||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||
DURABLE_SNAPSHOT_DIR="$dir"
|
||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||
@@ -360,9 +344,7 @@ verify_operator_surface() {
|
||||
continue
|
||||
fi
|
||||
if cp "$snap" "$cur"; then
|
||||
if ! chmod 600 "$cur"; then
|
||||
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||
fi
|
||||
chmod 600 "$cur" 2>/dev/null || true
|
||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||
healed=$((healed + 1))
|
||||
else
|
||||
@@ -553,7 +535,7 @@ sync_framework_keep() {
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete; then
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
@@ -599,7 +581,7 @@ run_migrations() {
|
||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||
rm -rf "${TARGET_DIR:?}/bin"
|
||||
rm -rf "$TARGET_DIR/bin"
|
||||
fi
|
||||
|
||||
# Remove old mosaic PATH entry from shell profiles
|
||||
@@ -724,23 +706,13 @@ mkdir -p "$TARGET_DIR/credentials"
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
reconcile_framework_files
|
||||
|
||||
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped shell tools executable."
|
||||
exit 1
|
||||
fi
|
||||
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||
fail "Could not mark shipped runtime scripts executable."
|
||||
exit 1
|
||||
fi
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||
fail "Could not mark git-credential-mosaic executable."
|
||||
exit 1
|
||||
fi
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||
|
||||
ok "Framework synced to $TARGET_DIR"
|
||||
|
||||
@@ -767,110 +739,49 @@ step "Post-install tasks"
|
||||
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||
# also appended to the parent transaction's private command log. Failure to
|
||||
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||
# INV-C forbids.
|
||||
record_phase_outcome() {
|
||||
local phase="$1" status="$2" reason="$3"
|
||||
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_captured() {
|
||||
local label="$1" output status=0
|
||||
shift
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-install.XXXXXX.log")"
|
||||
if "$@" >"$output" 2>&1; then status=0; else status=$?; fi
|
||||
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$output"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||
cat "$output" >&2
|
||||
rm -f "$output"
|
||||
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ "$status" -ne 0 ]]; then cat "$output" >&2; fi
|
||||
rm -f "$output"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||
link_args=()
|
||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||
# stdout is suppressed as before, but stderr is left connected: the
|
||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||
# the operator, not be swallowed silently.
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||
ok "Runtime assets linked"
|
||||
else
|
||||
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
||||
ok "sequential-thinking MCP configured"
|
||||
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||
else
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
||||
else
|
||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||
ok "excalidraw MCP configured"
|
||||
else
|
||||
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||
fi
|
||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||
record_phase_outcome P4 committed "skills sync exited zero"
|
||||
ok "Skills synced"
|
||||
else
|
||||
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||
fi
|
||||
else
|
||||
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||
ok "Local skills migrated"
|
||||
else
|
||||
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||
fi
|
||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||
ok "Health audit passed"
|
||||
else
|
||||
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||
fi
|
||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
||||
fi
|
||||
|
||||
# The version stamp records the successfully committed framework file sync.
|
||||
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||
# by this stamp.
|
||||
# Write version stamp AFTER everything succeeds
|
||||
write_framework_version
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
detect_platform() {
|
||||
local remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -39,7 +42,10 @@ detect_platform() {
|
||||
|
||||
get_repo_info() {
|
||||
local remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -240,6 +246,21 @@ PY
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
||||
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
||||
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
||||
# Diagnostic only -- stderr, no control flow, no exit.
|
||||
explain_tea_user_does_not_exist() {
|
||||
cat >&2 <<'MSG'
|
||||
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
||||
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
||||
credential store does NOT update it.
|
||||
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
||||
then re-register that login against the current token.
|
||||
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
||||
MSG
|
||||
}
|
||||
|
||||
get_gitea_login_for_host() {
|
||||
local host="${1:-}"
|
||||
local login
|
||||
|
||||
@@ -156,6 +156,7 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_create_api
|
||||
;;
|
||||
|
||||
@@ -71,6 +71,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_view_api
|
||||
else
|
||||
|
||||
@@ -219,6 +219,7 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
gitea_pr_create_api
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
CO_AUTHOR_TRAILERS=false
|
||||
ESCALATE_TO=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -27,12 +29,16 @@ Options:
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
--co-author-trailers)
|
||||
CO_AUTHOR_TRAILERS=true
|
||||
shift
|
||||
;;
|
||||
--escalate-to)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --escalate-to requires one principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
ESCALATE_TO="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -88,17 +110,30 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
write_curl_auth_config() {
|
||||
local mode="$1" credential="$2"
|
||||
printf '%s' "$credential" | python3 -c '
|
||||
import sys
|
||||
mode = sys.argv[1]
|
||||
credential = sys.stdin.read()
|
||||
if not credential or any(char in credential for char in "\r\n"):
|
||||
raise SystemExit(1)
|
||||
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
if mode == "token":
|
||||
print(f"header = \"Authorization: token {escaped}\"")
|
||||
elif mode == "basic":
|
||||
print(f"user = \"{escaped}\"")
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
' "$mode"
|
||||
}
|
||||
|
||||
LAST_GITEA_HTTP_CODE="000"
|
||||
LAST_GITEA_ERROR=""
|
||||
MERGE_TEMP_DIRS=()
|
||||
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
GITEA_CURL_BOUNDS=(
|
||||
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||
--max-time "$GITEA_CURL_MAX_TIME"
|
||||
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||
)
|
||||
|
||||
format_gitea_error_response() {
|
||||
local response_file="$1"
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
with open(sys.argv[1], "rb") as handle:
|
||||
raw = handle.read(65536)
|
||||
try:
|
||||
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
message = "non-JSON response omitted"
|
||||
else:
|
||||
if isinstance(response, dict):
|
||||
message = response.get("message") or response.get("error")
|
||||
if not message and response.get("errors") is not None:
|
||||
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||
else:
|
||||
message = None
|
||||
if not message:
|
||||
message = "JSON response contained no error message"
|
||||
message = str(message)
|
||||
if len(message) > 500:
|
||||
message = message[:500] + "..."
|
||||
print(ascii(message))
|
||||
PY
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
then
|
||||
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$fields_file"
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-u "$basic_auth" \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
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 ]]
|
||||
}
|
||||
|
||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||
import json
|
||||
import sys
|
||||
code, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
raw = handle.read(500)
|
||||
data = json.loads(raw) if raw else {}
|
||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||
except Exception:
|
||||
try:
|
||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
||||
except Exception:
|
||||
message = "unreadable response"
|
||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
||||
PY
|
||||
rm -f "$body_file"
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" token attempt_rc
|
||||
|
||||
if ! token=$(get_gitea_token "$host"); then
|
||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$token" ]]; then
|
||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||
return 1
|
||||
fi
|
||||
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||
return 0
|
||||
else
|
||||
attempt_rc=$?
|
||||
fi
|
||||
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||
return 75
|
||||
fi
|
||||
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||
fi
|
||||
else
|
||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||
@@ -209,6 +615,10 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||
exit 1
|
||||
fi
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
@@ -219,7 +629,7 @@ case "$PLATFORM" in
|
||||
exit 1
|
||||
}
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -9,10 +9,51 @@ 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
|
||||
@@ -33,6 +74,9 @@ 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
|
||||
@@ -44,6 +88,7 @@ case "$url" in
|
||||
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"}]}' ;;
|
||||
@@ -63,7 +108,31 @@ case "$url" in
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
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"
|
||||
@@ -83,13 +152,46 @@ run_guard() {
|
||||
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"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
# 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
|
||||
@@ -124,6 +226,13 @@ run_assertion() {
|
||||
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
|
||||
@@ -140,6 +249,27 @@ 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))
|
||||
@@ -160,6 +290,7 @@ 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
|
||||
@@ -223,6 +354,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||
# not kill the caller silently.
|
||||
#
|
||||
# Both functions already contained the right error path:
|
||||
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||
#
|
||||
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||
# `blocked` with no diagnostic to report.
|
||||
#
|
||||
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||
local fn="$1" out rc
|
||||
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||
# does nothing. Resolve it before exporting.
|
||||
TMP="$(cd "$TMP" && pwd -P)"
|
||||
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||
|
||||
echo "== inside a git repo the functions still work =="
|
||||
git init -q "$TMP/repo" 2>/dev/null
|
||||
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||
exit "$fail"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||
#
|
||||
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||
# declare -F explain_... >/dev/null && explain_...
|
||||
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||
# passing control.
|
||||
#
|
||||
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||
# probe pass on a build whose real call sites still carry the bare && form.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||
script="$TMP/probe.sh"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo 'set -e'
|
||||
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||
echo "$construct" # the shipped line, parsed by a real shell
|
||||
echo 'echo FALLBACK_REACHED'
|
||||
} > "$script"
|
||||
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||
done
|
||||
|
||||
echo "== all three call sites use the status-neutral form =="
|
||||
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
p="$GIT_DIR_UNDER_TEST/$f"
|
||||
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||
done
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||
exit "$fail"
|
||||
@@ -51,22 +51,23 @@ for arg in "$@"; do
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$prev" == "-d" ]]; then
|
||||
if [[ "$prev" == "data" ]]; then
|
||||
post_data="$arg"
|
||||
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-o" ]]; then
|
||||
prev="-o"
|
||||
if [[ "$prev" == "config" ]]; then
|
||||
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-d" ]]; then
|
||||
prev="-d"
|
||||
continue
|
||||
fi
|
||||
if [[ "$arg" == "-w" ]]; then
|
||||
write_code=true
|
||||
fi
|
||||
case "$arg" in
|
||||
-o) prev="-o" ;;
|
||||
-d|--data|--data-binary) prev="data" ;;
|
||||
-K|--config) prev="config" ;;
|
||||
-w) write_code=true ;;
|
||||
esac
|
||||
done
|
||||
emit_response() {
|
||||
local body="$1"
|
||||
|
||||
@@ -36,13 +36,30 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||
ORIG_PATH="$PATH"
|
||||
failures=0
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
fail() {
|
||||
echo "FAIL $1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
make_case() {
|
||||
local name="$1" case_dir
|
||||
case_dir="$WORK_DIR/$name"
|
||||
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||
chmod +x "$case_dir/pr-merge.sh"
|
||||
|
||||
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||
get_repo_owner() { printf 'acme\n'; }
|
||||
get_repo_name() { printf 'widgets\n'; }
|
||||
get_remote_host() { printf 'git.example.test\n'; }
|
||||
get_gitea_token() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
printf 'fixture-token\n'
|
||||
}
|
||||
get_gitea_basic_auth() {
|
||||
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||
printf 'fixture-user:fixture-password\n'
|
||||
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
get_gitea_login_for_host() { return 1; }
|
||||
SH
|
||||
|
||||
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||
else
|
||||
title='Preserve both branch authors'
|
||||
fi
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/python3" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||
SH
|
||||
|
||||
cat > "$case_dir/bin/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
url=""
|
||||
method="GET"
|
||||
out_file=""
|
||||
data=""
|
||||
config=""
|
||||
auth_mode="none"
|
||||
has_max_filesize=0
|
||||
has_max_time=0
|
||||
has_connect_timeout=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
out_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data|--data-binary)
|
||||
data="$2"
|
||||
if [[ "$data" == @* ]]; then
|
||||
data=$(<"${data#@}")
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
-K|--config)
|
||||
if [[ "$2" == "-" ]]; then
|
||||
config=$(cat)
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--max-filesize)
|
||||
has_max_filesize=1
|
||||
shift 2
|
||||
;;
|
||||
--max-time)
|
||||
has_max_time=1
|
||||
shift 2
|
||||
;;
|
||||
--connect-timeout)
|
||||
has_connect_timeout=1
|
||||
shift 2
|
||||
;;
|
||||
-H|--header|-u|--user)
|
||||
if [[ "$2" == *"fixture-token"* ]]; then
|
||||
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||
fi
|
||||
if [[ "$2" == *"fixture-password"* ]]; then
|
||||
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||
auth_mode="token"
|
||||
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||
auth_mode="basic"
|
||||
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||
fi
|
||||
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/pulls/42)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||
head_sha=4444444444444444444444444444444444444444
|
||||
fi
|
||||
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/commits*)
|
||||
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||
verified)
|
||||
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
else
|
||||
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
fi
|
||||
;;
|
||||
null-login)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||
;;
|
||||
unsafe-identity)
|
||||
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
single)
|
||||
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||
;;
|
||||
*)
|
||||
echo "unknown commits mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
fi
|
||||
;;
|
||||
*/pulls/42/merge)
|
||||
body='{}'
|
||||
code=200
|
||||
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||
body='{"message":"token rejected"}'
|
||||
code=401
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||
code=409
|
||||
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||
body='{"message":"permission denied"}'
|
||||
code=403
|
||||
else
|
||||
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||
fi
|
||||
;;
|
||||
*/users/*)
|
||||
body='{"message":"not found"}'
|
||||
code=404
|
||||
;;
|
||||
*)
|
||||
body='{"message":"unexpected URL"}'
|
||||
code=500
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$out_file" ]]; then
|
||||
printf '%s' "$body" > "$out_file"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
printf '%s' "$code"
|
||||
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||
oversize) exit 63 ;;
|
||||
stalled) exit 28 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||
printf '%s\n' "$case_dir"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local case_dir="$1" mode="$2"
|
||||
shift 2
|
||||
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||
}
|
||||
|
||||
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||
# linked author.login and that same commit's author email. No /users lookup.
|
||||
verified_dir=$(make_case verified)
|
||||
set +e
|
||||
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
verified_rc=$?
|
||||
set -e
|
||||
if [[ "$verified_rc" -ne 0 ]]; then
|
||||
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||
fail "verified multi-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||
"MergeTitleField": "Preserve both branch authors",
|
||||
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||
fail "verified path performed a forbidden second /users lookup"
|
||||
fi
|
||||
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||
fi
|
||||
|
||||
# A linked email containing a terminal escape must block before mutation.
|
||||
escape_email_dir=$(make_case escape-email)
|
||||
set +e
|
||||
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
escape_email_rc=$?
|
||||
set -e
|
||||
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||
|
||||
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||
for failure_mode in oversize stalled; do
|
||||
failure_dir=$(make_case "curl-$failure_mode")
|
||||
set +e
|
||||
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
failure_rc=$?
|
||||
set -e
|
||||
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||
done
|
||||
|
||||
# The authenticated head is re-read under the mutation credential but cannot
|
||||
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||
# or mutation even though the provider returned a valid new SHA.
|
||||
moved_dir=$(make_case moved-head)
|
||||
set +e
|
||||
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
moved_rc=$?
|
||||
set -e
|
||||
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||
|
||||
# Token resolution failure is not an authentication response. It must fail
|
||||
# closed instead of borrowing a Basic credential under a different principal.
|
||||
token_missing_dir=$(make_case token-missing)
|
||||
set +e
|
||||
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||
run_case "$token_missing_dir" single 2>&1)
|
||||
token_missing_rc=$?
|
||||
set -e
|
||||
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||
|
||||
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||
set +e
|
||||
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
basic_rc_rc=$?
|
||||
set -e
|
||||
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||
|
||||
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||
# resolving or attempting Basic Auth.
|
||||
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||
set +e
|
||||
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_inspect_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||
|
||||
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||
fallback_merge_dir=$(make_case fallback-merge)
|
||||
set +e
|
||||
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
fallback_merge_rc=$?
|
||||
set -e
|
||||
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||
|
||||
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||
null_dir=$(make_case null-login)
|
||||
set +e
|
||||
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
null_rc=$?
|
||||
set -e
|
||||
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||
|
||||
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||
# including an invalid non-head SHA that contains control characters.
|
||||
unsafe_dir=$(make_case unsafe-identity)
|
||||
set +e
|
||||
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
unsafe_rc=$?
|
||||
set -e
|
||||
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||
|
||||
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||
title_dir=$(make_case title-injection)
|
||||
set +e
|
||||
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||
title_rc=$?
|
||||
set -e
|
||||
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||
|
||||
# Provider failures remain diagnosable after their temporary response file is
|
||||
# removed, but provider-controlled control characters stay log-safe.
|
||||
error_dir=$(make_case provider-error)
|
||||
set +e
|
||||
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||
run_case "$error_dir" single 2>&1)
|
||||
error_rc=$?
|
||||
set -e
|
||||
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# Authorization denials likewise fail closed instead of changing principals.
|
||||
forbidden_dir=$(make_case forbidden)
|
||||
set +e
|
||||
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||
run_case "$forbidden_dir" single 2>&1)
|
||||
forbidden_rc=$?
|
||||
set -e
|
||||
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||
|
||||
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||
# without a named principal is refused before any provider operation.
|
||||
principal_dir=$(make_case missing-principal)
|
||||
set +e
|
||||
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||
principal_rc=$?
|
||||
set -e
|
||||
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||
|
||||
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||
# set -u unbound-variable crash.
|
||||
value_dir=$(make_case missing-principal-value)
|
||||
set +e
|
||||
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||
value_rc=$?
|
||||
set -e
|
||||
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||
|
||||
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||
# compatible and hardcoded to squash, with no optional message fields.
|
||||
single_dir=$(make_case single)
|
||||
set +e
|
||||
single_output=$(run_case "$single_dir" single 2>&1)
|
||||
single_rc=$?
|
||||
set -e
|
||||
if [[ "$single_rc" -ne 0 ]]; then
|
||||
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||
fail "ordinary single-author merge did not reach the API payload"
|
||||
else
|
||||
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||
}, payload
|
||||
PY
|
||||
fi
|
||||
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||
|
||||
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||
method_dir=$(make_case method-refusal)
|
||||
set +e
|
||||
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||
method_rc=$?
|
||||
set -e
|
||||
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
PATH="$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||
|
||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-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-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-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/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'Pi',
|
||||
command: 'pi',
|
||||
versionFlag: '--version',
|
||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||
installHint: 'npm install -g @mariozechner/pi-coding-agent',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
|
||||
|
||||
describe('finalizeStage — skill installer', () => {
|
||||
let tmp: string;
|
||||
let scriptsDir: string;
|
||||
let binDir: string;
|
||||
let syncScript: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
|
||||
scriptsDir = join(tmp, 'tools', '_scripts');
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
syncScript = join(scriptsDir, 'mosaic-sync-skills');
|
||||
binDir = join(tmp, 'bin');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
syncScript = join(binDir, 'mosaic-sync-skills');
|
||||
|
||||
// Default: current framework layout has tools/_scripts and succeeds.
|
||||
// Default: script exists and succeeds
|
||||
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
|
||||
});
|
||||
@@ -156,29 +156,10 @@ describe('finalizeStage — skill installer', () => {
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
|
||||
const opts = call![2] as { env?: Record<string, string> };
|
||||
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
|
||||
});
|
||||
|
||||
it('falls back to legacy bin path for pre-migration installs', async () => {
|
||||
rmSync(syncScript);
|
||||
const legacyBinDir = join(tmp, 'bin');
|
||||
mkdirSync(legacyBinDir, { recursive: true });
|
||||
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
|
||||
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
|
||||
|
||||
const state = makeState(tmp, ['brainstorming']);
|
||||
const p = buildPrompter();
|
||||
const config = makeConfigService();
|
||||
|
||||
await finalizeStage(p, state, config);
|
||||
|
||||
const call = findSkillsSyncCall();
|
||||
expect(call).toBeDefined();
|
||||
expect(call![1]).toEqual([legacySyncScript]);
|
||||
});
|
||||
|
||||
it('skips the sync script entirely when no skills are selected', async () => {
|
||||
const state = makeState(tmp, []);
|
||||
const p = buildPrompter();
|
||||
@@ -218,9 +199,7 @@ describe('finalizeStage — skill installer', () => {
|
||||
|
||||
// spawnSync should NOT have been called for the skills script
|
||||
expect(findSkillsSyncCall()).toBeUndefined();
|
||||
expect(p.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
|
||||
);
|
||||
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('includes skills count in the summary when install succeeds', async () => {
|
||||
|
||||
@@ -13,22 +13,16 @@ import {
|
||||
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||
} from '../commands/skill.js';
|
||||
|
||||
function frameworkScriptPath(mosaicHome: string, name: string): string {
|
||||
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
|
||||
if (existsSync(currentPath)) return currentPath;
|
||||
|
||||
// Backward-compatible fallback for pre-migration installs that still have bin/.
|
||||
const legacyPath = join(mosaicHome, 'bin', name);
|
||||
if (existsSync(legacyPath)) return legacyPath;
|
||||
|
||||
// Return the current expected path so user-facing errors point at the layout
|
||||
// installed by packages/mosaic/framework/install.sh.
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
/** Link runtime assets and surface a non-zero install-ordering guard outcome. */
|
||||
/**
|
||||
* Link runtime assets. Returns a warning string when the install-ordering
|
||||
* guard (#869 Point-1 C2) reported a degraded outcome — i.e. the
|
||||
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
|
||||
* this host could not confirm it can activate them — so the caller can
|
||||
* surface it via `p.warn(...)` instead of it being swallowed by `stdio:
|
||||
* 'pipe'`. Non-fatal either way: the wizard always continues.
|
||||
*/
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (!existsSync(script)) return undefined;
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
@@ -75,7 +69,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
|
||||
return { success: true, installedCount: 0 };
|
||||
}
|
||||
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
if (!existsSync(script)) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -123,7 +117,7 @@ interface DoctorResult {
|
||||
}
|
||||
|
||||
function runDoctor(mosaicHome: string): DoctorResult {
|
||||
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
||||
if (!existsSync(script)) {
|
||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
||||
}
|
||||
@@ -176,24 +170,11 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinalizeStageOptions {
|
||||
/**
|
||||
* Defer the success summary/outro so callers can run downstream readiness
|
||||
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
|
||||
*/
|
||||
deferSummary?: boolean;
|
||||
}
|
||||
|
||||
export interface FinalizeStageResult {
|
||||
showSummary: () => void;
|
||||
}
|
||||
|
||||
export async function finalizeStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
options: FinalizeStageOptions = {},
|
||||
): Promise<FinalizeStageResult> {
|
||||
): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
@@ -288,56 +269,44 @@ export async function finalizeStage(
|
||||
// 7. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
let summaryShown = false;
|
||||
const showSummary = () => {
|
||||
if (summaryShown) return;
|
||||
summaryShown = true;
|
||||
// 8. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
: 'none selected'
|
||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
||||
|
||||
// 7. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
: 'none selected'
|
||||
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${skillsSummary}`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
}
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
||||
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
};
|
||||
|
||||
if (!options.deferSummary) {
|
||||
showSummary();
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
}
|
||||
|
||||
return { showSummary };
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 9. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
||||
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ describe('gatewayConfigStage', () => {
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['MOSAIC_DATABASE_URL'];
|
||||
delete process.env['MOSAIC_VALKEY_URL'];
|
||||
delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -168,75 +167,6 @@ describe('gatewayConfigStage', () => {
|
||||
expect(state.gateway?.regeneratedConfig).toBe(true);
|
||||
});
|
||||
|
||||
it('installs the gateway package on fresh install when skipInstall is not set', async () => {
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: false,
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(daemonState.installPkgCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => {
|
||||
process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1';
|
||||
const p = buildPrompter();
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: false,
|
||||
});
|
||||
|
||||
// The source-built global gateway must NOT be overwritten by @latest.
|
||||
expect(result.ready).toBe(true);
|
||||
expect(daemonState.installPkgCalled).toBe(0);
|
||||
});
|
||||
|
||||
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
|
||||
try {
|
||||
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
|
||||
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
|
||||
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
|
||||
if (opts.message.includes('API_KEY')) {
|
||||
throw new Error('gateway API key prompt should be skipped');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
|
||||
const state = makeState('/home/user/.config/mosaic');
|
||||
|
||||
const result = await gatewayConfigStage(p, state, {
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
providerType: 'none',
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(textFn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
|
||||
);
|
||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
|
||||
expect(envContents).not.toContain('OPENAI_API_KEY=');
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
|
||||
// Pre-populate both files + running daemon + meta with token
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -294,12 +294,7 @@ export async function gatewayConfigStage(
|
||||
}
|
||||
|
||||
// Install the gateway npm package on first install or after failure.
|
||||
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall:
|
||||
// used by dev/offline installs where @mosaicstack/gateway is already present
|
||||
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
|
||||
// overwritten by the registry @latest build.
|
||||
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
|
||||
if (!skipNpmInstall && !daemonRunning) {
|
||||
if (!opts.skipInstall && !daemonRunning) {
|
||||
installGatewayPackage();
|
||||
}
|
||||
|
||||
@@ -511,9 +506,6 @@ async function collectAndWriteConfig(
|
||||
if (opts.providerKey) {
|
||||
anthropicKey = opts.providerKey;
|
||||
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
||||
} else if (opts.providerType === 'none') {
|
||||
anthropicKey = '';
|
||||
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
|
||||
} else {
|
||||
anthropicKey = await p.text({
|
||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||
|
||||
@@ -37,7 +37,6 @@ export async function quickStartPath(
|
||||
|
||||
// 1. Provider setup (first question)
|
||||
await providerSetupStage(prompter, state);
|
||||
state.completedSections?.add('providers');
|
||||
|
||||
// Apply sensible defaults for everything else
|
||||
state.soul.agentName ??= 'Mosaic';
|
||||
@@ -58,13 +57,9 @@ export async function quickStartPath(
|
||||
|
||||
// Skills (recommended set, no user input in quick mode)
|
||||
await skillsSelectStage(prompter, state);
|
||||
state.completedSections?.add('skills');
|
||||
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway config + bootstrap
|
||||
if (!options.skipGateway) {
|
||||
@@ -77,7 +72,7 @@ export async function quickStartPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
@@ -85,24 +80,19 @@ export async function quickStartPath(
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +126,6 @@ type MenuChoice =
|
||||
| 'advanced'
|
||||
| 'finish';
|
||||
|
||||
function menuSectionKey(section: MenuChoice): MenuSection | null {
|
||||
if (section === 'quick-start' || section === 'finish') return null;
|
||||
return section === 'gateway-config' ? 'gateway' : section;
|
||||
}
|
||||
|
||||
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
const labels: Record<MenuChoice, string> = {
|
||||
'quick-start': 'Quick Start',
|
||||
@@ -142,24 +137,14 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||
finish: 'Finish & Apply',
|
||||
};
|
||||
const base = labels[section];
|
||||
const sectionKey = menuSectionKey(section);
|
||||
if (sectionKey && completed.has(sectionKey)) {
|
||||
const sectionKey: MenuSection =
|
||||
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
||||
if (completed.has(sectionKey)) {
|
||||
return `${base} [done]`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function skipCompletedMenuChoice(
|
||||
prompter: WizardPrompter,
|
||||
completed: Set<MenuSection>,
|
||||
choice: MenuChoice,
|
||||
): boolean {
|
||||
const sectionKey = menuSectionKey(choice);
|
||||
if (!sectionKey || !completed.has(sectionKey)) return false;
|
||||
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runMenuLoop(
|
||||
prompter: WizardPrompter,
|
||||
state: WizardState,
|
||||
@@ -216,25 +201,21 @@ async function runMenuLoop(
|
||||
return; // Quick start is a complete flow — exit menu
|
||||
|
||||
case 'providers':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await providerSetupStage(prompter, state);
|
||||
completed.add('providers');
|
||||
break;
|
||||
|
||||
case 'identity':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await agentIntentStage(prompter, state);
|
||||
completed.add('identity');
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await skillsSelectStage(prompter, state);
|
||||
completed.add('skills');
|
||||
break;
|
||||
|
||||
case 'gateway-config':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
// Gateway config is handled during Finish — mark as "configured"
|
||||
// after user reviews settings.
|
||||
await runGatewaySubMenu(prompter, state, options);
|
||||
@@ -242,7 +223,6 @@ async function runMenuLoop(
|
||||
break;
|
||||
|
||||
case 'advanced':
|
||||
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
|
||||
await runAdvancedSubMenu(prompter, state);
|
||||
completed.add('advanced');
|
||||
break;
|
||||
@@ -330,11 +310,8 @@ async function runFinishPath(
|
||||
await skillsSelectStage(prompter, state);
|
||||
}
|
||||
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -345,7 +322,7 @@ async function runFinishPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
@@ -356,16 +333,12 @@ async function runFinishPath(
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,11 +374,8 @@ async function runHeadlessPath(
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -416,31 +386,26 @@ async function runHeadlessPath(
|
||||
portOverride: options.gatewayPortOverride,
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
providerKey: state.providerKey,
|
||||
providerType: state.providerType,
|
||||
providerType: state.providerType ?? 'none',
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
} else {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,11 +426,8 @@ async function runKeepPath(
|
||||
// Skills
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||
// after the gateway health/bootstrap gates complete.
|
||||
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||
deferSummary: true,
|
||||
});
|
||||
// Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
|
||||
// Gateway stages
|
||||
if (!options.skipGateway) {
|
||||
@@ -485,15 +447,11 @@ async function runKeepPath(
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
finalizeResult.showSummary();
|
||||
}
|
||||
}
|
||||
|
||||
+159
-335
@@ -1,360 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
# Greenfield installer acceptance fixture.
|
||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
||||
#
|
||||
# The fixture itself is intentionally RED until the C2-C5 phase owners repair
|
||||
# their postconditions. C1's CI gate executes it and validates that the RED is
|
||||
# attributable (including the discriminating P3 PASS); it does not turn the
|
||||
# failed install into a false green.
|
||||
# Runs a clean-container install test to verify the full first-run flow:
|
||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
||||
# -> mosaic gateway install
|
||||
# -> mosaic gateway verify
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker (skips gracefully if not available)
|
||||
# - Run from the repository root
|
||||
#
|
||||
# How it works:
|
||||
# 1. Mounts the repository into a node:22-alpine container.
|
||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
||||
# framework and CLI from the Gitea registry.
|
||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
||||
# pre-dates this branch (does not have `gateway verify`), the test
|
||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
||||
# 7. Reports PASS or FAIL with a summary.
|
||||
#
|
||||
# To run manually:
|
||||
# cd /path/to/mosaic-stack
|
||||
# bash tools/e2e-install-test.sh
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LANE="${MOSAIC_INSTALL_LANE:-next}"
|
||||
SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
||||
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="node:22-alpine"
|
||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent]
|
||||
|
||||
Runs the documented installer command from zero in Debian/glibc as a non-root
|
||||
uid with an isolated HOME. The fixture exits non-zero when any P0-P8
|
||||
postcondition fails. `next` is always selected with the --next installer flag.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane) LANE="${2:-}"; shift 2 ;;
|
||||
--source) SOURCE="${2:-}"; shift 2 ;;
|
||||
--git) GIT_MODE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac
|
||||
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||
exit 2
|
||||
fi
|
||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
||||
else
|
||||
R="" G="" Y="" BOLD="" RESET=""
|
||||
fi
|
||||
|
||||
installer_b64=""
|
||||
framework_payload_count="NOT-MEASURED"
|
||||
repo_root_count="NOT-MEASURED"
|
||||
checkout_archive=""
|
||||
checkout_digest=""
|
||||
checkout_content_id=""
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$ROOT/skills" ]] \
|
||||
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX.tar.gz")"
|
||||
repo_parent="$(dirname "$ROOT")"
|
||||
repo_name="$(basename "$ROOT")"
|
||||
tar -C "$repo_parent" \
|
||||
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||
--exclude='*/.env' --exclude='*/.env.*' \
|
||||
-czf "$checkout_archive" "$repo_name"
|
||||
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||
checkout_content_id="${checkout_digest:0:40}"
|
||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
||||
|
||||
# ─── Docker availability check ────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found — skipping e2e install test."
|
||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX.sh")"
|
||||
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||
cat > "$inner" <<'INNER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||
|
||||
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > /tmp/source-checkout.tar.gz
|
||||
actual_checkout_digest="$(sha256sum /tmp/source-checkout.tar.gz | awk '{print $1}')"
|
||||
if [[ "$actual_checkout_digest" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info &>/dev/null 2>&1; then
|
||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||
info "Docker available — proceeding with e2e install test."
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
info "Container image: ${IMAGE}"
|
||||
|
||||
case "$FIXTURE_SOURCE" in
|
||||
checkout)
|
||||
printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh
|
||||
;;
|
||||
remote)
|
||||
curl -fsSL "https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${FIXTURE_LANE}/tools/install.sh" > /tmp/install.sh
|
||||
;;
|
||||
esac
|
||||
chmod 0755 /tmp/install.sh
|
||||
sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /'
|
||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
||||
|
||||
cat > /tmp/run-as-target.sh <<'TARGET'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
lane="$FIXTURE_LANE"
|
||||
home="$HOME"
|
||||
prefix="$home/.npm-global"
|
||||
mosaic_home="$home/.config/mosaic"
|
||||
install_log="$home/install.log"
|
||||
failures=0
|
||||
|
||||
phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; }
|
||||
phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); }
|
||||
|
||||
lane_args=()
|
||||
resolved_spec='@mosaicstack/mosaic'
|
||||
if [[ "$lane" == "next" ]]; then
|
||||
lane_args+=(--next)
|
||||
resolved_spec='@mosaicstack/mosaic@next'
|
||||
fi
|
||||
|
||||
resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)"
|
||||
printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}"
|
||||
|
||||
set +e
|
||||
MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 \
|
||||
bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1
|
||||
install_status=$?
|
||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
||||
#!/bin/sh
|
||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
||||
set -e
|
||||
cat "$install_log"
|
||||
printf '[fixture] installer_exit=%d done_claims=%s\n' \
|
||||
"$install_status" "$(grep -cF 'Done.' "$install_log" || true)"
|
||||
|
||||
# P0 Resolve context
|
||||
shell="$(getent passwd "$(id -u)" | cut -d: -f7)"
|
||||
if [[ "$(id -u)" -ne 0 && "$home" == "/home/mosaic" && "$shell" == "/bin/bash" ]] \
|
||||
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
|
||||
&& [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then
|
||||
phase_pass P0 "target=mosaic uid=$(id -u) HOME=$home shell=$shell libc=glibc node=$(node --version)"
|
||||
else
|
||||
phase_fail P0 "context unresolved or unsupported (uid=$(id -u) HOME=$home shell=${shell:-unknown})"
|
||||
echo "=== [inner] Installing system prerequisites ==="
|
||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
||||
|
||||
# Re-exec under bash.
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# P1 Preflight
|
||||
missing_tools=()
|
||||
for tool in bash curl git node npm python3 tar; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||
done
|
||||
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||
phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved"
|
||||
else
|
||||
phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)"
|
||||
fi
|
||||
# ── bash from here ────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
# P2 Acquire artifacts
|
||||
if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then
|
||||
phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript"
|
||||
else
|
||||
phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version"
|
||||
fi
|
||||
echo "=== [inner] Node.js / npm versions ==="
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
# P3 Install CLI — the discriminating row. Use the known absolute path only.
|
||||
cli="$prefix/bin/mosaic"
|
||||
cli_version=""
|
||||
if [[ -x "$cli" ]]; then
|
||||
cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
||||
fi
|
||||
if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then
|
||||
phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version"
|
||||
else
|
||||
phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}"
|
||||
fi
|
||||
echo "=== [inner] Setting up npm global prefix ==="
|
||||
export NPM_PREFIX="/root/.npm-global"
|
||||
mkdir -p "$NPM_PREFIX/bin"
|
||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
|
||||
# P4 Framework + skills. C1 does not choose among the four disagreeing
|
||||
# candidate populations. It requires the installer to publish a lane/versioned
|
||||
# shipped-set declaration that a checkout-free install can resolve; C5 owns its
|
||||
# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count.
|
||||
declared_set="$mosaic_home/.install-shipped-skills.json"
|
||||
sync_store_count=0
|
||||
runtime_link_count=0
|
||||
[[ -d "$mosaic_home/skills" ]] \
|
||||
&& sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
[[ -d "$home/.pi/agent/skills" ]] \
|
||||
&& runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')"
|
||||
printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \
|
||||
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||
if [[ ! -s "$declared_set" ]]; then
|
||||
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||
for (const name of data.skills) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||
const skill = path.join(root, name, 'SKILL.md');
|
||||
let real;
|
||||
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||
const text = fs.readFileSync(real, 'utf8');
|
||||
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
||||
# Install both framework and CLI from the Gitea registry.
|
||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||
MOSAIC_ASSUME_YES=1 \
|
||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
||||
|
||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
||||
|
||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
||||
mosaic wizard \
|
||||
--non-interactive \
|
||||
--name "test-agent" \
|
||||
--user-name "tester" \
|
||||
--pronouns "they/them" \
|
||||
--timezone "UTC" || {
|
||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
||||
}
|
||||
NODE
|
||||
then
|
||||
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||
else
|
||||
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||
fi
|
||||
|
||||
echo "=== [inner] Running mosaic gateway install ==="
|
||||
# Feed non-interactive answers:
|
||||
# "1" → storage tier: local
|
||||
# "" → port: accept default (14242)
|
||||
# "" → ANTHROPIC_API_KEY: skip
|
||||
# "" → CORS origin: accept default
|
||||
# Then admin bootstrap: name, email, password
|
||||
printf '1\n\n\n\nTest Admin\[email protected]\ntestpassword123\n' \
|
||||
| mosaic gateway install
|
||||
INSTALL_EXIT="$?"
|
||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
||||
mosaic gateway status 2>/dev/null || true
|
||||
exit "${INSTALL_EXIT}"
|
||||
fi
|
||||
|
||||
echo "=== [inner] Running mosaic gateway verify ==="
|
||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
||||
# If the installed version pre-dates this, skip gracefully.
|
||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
||||
# Treat as pass — the install flow itself worked.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mosaic gateway verify
|
||||
VERIFY_EXIT="$?"
|
||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
||||
exit "${VERIFY_EXIT}"
|
||||
INNER_SCRIPT_EOF
|
||||
|
||||
chmod +x "$INNER_SCRIPT"
|
||||
|
||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
||||
info "Pulling ${IMAGE}…"
|
||||
docker pull "${IMAGE}" --quiet
|
||||
|
||||
# ─── Run container ────────────────────────────────────────────────────────────
|
||||
info "Starting container ${CONTAINER_NAME}…"
|
||||
|
||||
EXIT_CODE=0
|
||||
docker run --rm \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
--volume "${REPO_ROOT}:/repo:ro" \
|
||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
||||
--network host \
|
||||
"${IMAGE}" \
|
||||
/bin/sh /e2e-inner.sh \
|
||||
|| EXIT_CODE=$?
|
||||
|
||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
||||
else
|
||||
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||
fi
|
||||
|
||||
# P5 Identity
|
||||
identity_ok=true
|
||||
identity_reason=()
|
||||
for f in SOUL.md USER.md; do
|
||||
path="$mosaic_home/$f"
|
||||
if [[ ! -s "$path" ]]; then
|
||||
identity_ok=false; identity_reason+=("$f missing-or-empty"); continue
|
||||
fi
|
||||
owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")"
|
||||
if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then
|
||||
identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode")
|
||||
fi
|
||||
done
|
||||
if [[ "$identity_ok" == true ]]; then
|
||||
phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes"
|
||||
else
|
||||
phase_fail P5 "${identity_reason[*]}"
|
||||
fi
|
||||
|
||||
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
broker_present=false
|
||||
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||
dead_hooks=0
|
||||
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||
fi
|
||||
p6_action_failed=false
|
||||
if [[ -s "$manifest" ]]; then
|
||||
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||
fi
|
||||
if [[ "$p6_action_failed" == true ]]; then
|
||||
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||
elif [[ "$broker_present" == true ]]; then
|
||||
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||
else
|
||||
phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)"
|
||||
fi
|
||||
|
||||
# P7 Services — none requested by --no-auto-launch.
|
||||
phase_pass P7 "no services requested by this fixture"
|
||||
|
||||
# P8 Shell discoverability — actual target shell, fresh login and non-login.
|
||||
base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin)
|
||||
login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)"
|
||||
nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)"
|
||||
if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then
|
||||
phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path"
|
||||
else
|
||||
phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli"
|
||||
fi
|
||||
|
||||
manifest="$mosaic_home/.install-manifest.json"
|
||||
p0_p8_failures="$failures"
|
||||
if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then
|
||||
phase_pass P9 "P0-P8 reasserted; manifest present"
|
||||
else
|
||||
phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success"
|
||||
fi
|
||||
|
||||
printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures"
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
||||
echo ""
|
||||
echo " Troubleshooting:"
|
||||
echo " - Review the output above for the failing step."
|
||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
||||
exit 1
|
||||
fi
|
||||
TARGET
|
||||
chmod 0755 /tmp/run-as-target.sh
|
||||
chown mosaic:mosaic /tmp/run-as-target.sh
|
||||
|
||||
exec runuser -u mosaic -- env -i \
|
||||
HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \
|
||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||
FIXTURE_LANE="$FIXTURE_LANE" \
|
||||
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$([[ "$FIXTURE_SOURCE" == "checkout" ]] && echo /tmp/source-checkout.tar.gz)" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$FIXTURE_CHECKOUT_CONTENT_ID" \
|
||||
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$FIXTURE_CHECKOUT_SHA256" \
|
||||
/bin/bash /tmp/run-as-target.sh
|
||||
INNER
|
||||
if [[ "$SOURCE" == "checkout" ]]; then
|
||||
{
|
||||
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||
base64 "$checkout_archive"
|
||||
} >> "$inner"
|
||||
fi
|
||||
chmod 0755 "$inner"
|
||||
|
||||
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||
|
||||
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||
# Woodpecker already supplies the clean Debian container. The target install
|
||||
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||
# enter the target user's process.
|
||||
FIXTURE_LANE="$LANE" \
|
||||
FIXTURE_SOURCE="$SOURCE" \
|
||||
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
/bin/bash "$inner"
|
||||
else
|
||||
# Copy the self-contained script+archive into a stopped container instead of
|
||||
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||
# payload avoids argv/environment size limits.
|
||||
fixture_cid="$(docker create \
|
||||
--network bridge \
|
||||
--env FIXTURE_LANE="$LANE" \
|
||||
--env FIXTURE_SOURCE="$SOURCE" \
|
||||
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||
set +e
|
||||
docker start -a "$fixture_cid"
|
||||
fixture_status=$?
|
||||
set -e
|
||||
docker rm "$fixture_cid" >/dev/null
|
||||
exit "$fixture_status"
|
||||
fi
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Pinned C1 expected-RED contract. Updating a verdict/reason requires review by the owning remediation lane.
|
||||
# case kind key/value
|
||||
next-git-present exit 1
|
||||
next-git-present phase P0=PASS
|
||||
next-git-present phase P1=PASS
|
||||
next-git-present phase P2=PASS
|
||||
next-git-present phase P3=PASS
|
||||
next-git-present phase P4=FAIL
|
||||
next-git-present phase P5=FAIL
|
||||
next-git-present phase P6=FAIL
|
||||
next-git-present phase P7=PASS
|
||||
next-git-present phase P8=FAIL
|
||||
next-git-present phase P9=FAIL
|
||||
next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.
|
||||
next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
next-git-present require ^\[P6\] FAIL:
|
||||
next-git-present forbid Done\.
|
||||
main-git-present exit 1
|
||||
main-git-present phase P0=PASS
|
||||
main-git-present phase P1=PASS
|
||||
main-git-present phase P2=PASS
|
||||
main-git-present phase P3=PASS
|
||||
main-git-present phase P4=FAIL
|
||||
main-git-present phase P5=FAIL
|
||||
main-git-present phase P6=FAIL
|
||||
main-git-present phase P7=PASS
|
||||
main-git-present phase P8=FAIL
|
||||
main-git-present phase P9=FAIL
|
||||
main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$
|
||||
main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||
main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||
main-git-present require ^\[P6\] FAIL:
|
||||
main-git-present forbid Done\.
|
||||
next-git-absent exit 1
|
||||
next-git-absent phase P0=PASS
|
||||
next-git-absent phase P1=FAIL
|
||||
next-git-absent phase P2=FAIL
|
||||
next-git-absent phase P3=FAIL
|
||||
next-git-absent phase P4=FAIL
|
||||
next-git-absent phase P5=FAIL
|
||||
next-git-absent phase P6=PASS
|
||||
next-git-absent phase P7=PASS
|
||||
next-git-absent phase P8=FAIL
|
||||
next-git-absent phase P9=FAIL
|
||||
next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||
next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git;
|
||||
next-git-absent require ^\[P3\] FAIL: .*executable=no
|
||||
next-git-absent forbid Done\.
|
||||
|
@@ -1,359 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
FAKE_BIN="$TMP/bin"
|
||||
HOME_DIR="$TMP/home"
|
||||
PREFIX="$HOME_DIR/prefix"
|
||||
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||
STATE="$TMP/state"
|
||||
LOG="$TMP/npm.log"
|
||||
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||
|
||||
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
STATE="${MOSAIC_TEST_STATE:?}"
|
||||
echo "$*" >> "$LOG"
|
||||
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
echo "10.6.2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install_cli() {
|
||||
local version="$1"
|
||||
echo "$version" > "$STATE/mosaic"
|
||||
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\\n' '$version'
|
||||
CLI
|
||||
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||
}
|
||||
|
||||
if [[ "$1" == "view" ]]; then
|
||||
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||
echo "forced registry metadata failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$2 $3" in
|
||||
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "install" ]]; then
|
||||
case "$*" in
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
install_cli "0.0.49-next.999"
|
||||
;;
|
||||
*"@mosaicstack/[email protected]"*)
|
||||
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||
echo "forced gateway install failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||
;;
|
||||
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||
install_cli "0.0.0-source"
|
||||
;;
|
||||
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||
echo "0.0.0-source" > "$STATE/gateway"
|
||||
;;
|
||||
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "ls" ]]; then
|
||||
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||
node -e '
|
||||
const cli = process.argv[1];
|
||||
const gateway = process.argv[2];
|
||||
const dependencies = {};
|
||||
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||
process.stdout.write(JSON.stringify({ dependencies }));
|
||||
' "$cli" "$gateway"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected npm command: $*" >&2
|
||||
exit 1
|
||||
FAKE_NPM
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
headers=""; output=""; url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-D) headers="$2"; shift 2 ;;
|
||||
-o) output="$2"; shift 2 ;;
|
||||
--max-filesize) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||
;;
|
||||
*/archive/*.tar.gz)
|
||||
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||
printf 'not-a-tarball\n' > "$output"
|
||||
else
|
||||
archive_root="$(mktemp -d)"
|
||||
mkdir -p "$archive_root/stack"
|
||||
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||
/bin/tar czf "$output" -C "$archive_root" stack
|
||||
rm -rf "$archive_root"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""; list=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
-*t*|t*) list=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[[ "$list" == true ]] && exit 0
|
||||
if [[ -z "$dest" ]]; then
|
||||
echo "fake tar missing -C destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
|
||||
FAKE_TAR
|
||||
chmod +x "$FAKE_BIN/tar"
|
||||
|
||||
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||
echo "pnpm $*" >> "$LOG"
|
||||
|
||||
if [[ "$1" == "pack" ]]; then
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pack-destination) out="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$out" ]]; then
|
||||
echo "fake pnpm pack missing destination" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
case "$PWD" in
|
||||
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||
echo "forced pnpm install failure" >&2
|
||||
exit 42
|
||||
fi
|
||||
|
||||
# Other install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
prefix_fingerprint() {
|
||||
if [[ ! -d "$PREFIX" ]]; then printf 'ABSENT\n'; return; fi
|
||||
(
|
||||
cd "$PREFIX"
|
||||
find . -mindepth 1 -printf '%P|%y|%m|%l\n' | LC_ALL=C sort
|
||||
find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum
|
||||
) | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
reset_state
|
||||
echo "[test] --next fast path pins resolved package versions"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||
echo "expected exact-version installs, found mutable @next install" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||
|
||||
reset_state
|
||||
echo "[test] fast path failure falls back to source build"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||
)"
|
||||
|
||||
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||
before_prefix="$(prefix_fingerprint)"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||
)"
|
||||
FAIL_STATUS=$?
|
||||
set -e
|
||||
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||
|
||||
reset_state
|
||||
echo "[test] --dev source install does not require registry version resolution"
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||
)"
|
||||
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
|
||||
reset_state
|
||||
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||
if grep -qF '@next version' "$LOG"; then
|
||||
echo "explicit ref should not query @next dist-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reset_state
|
||||
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||
set +e
|
||||
OUTPUT="$(
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||
MOSAIC_PREFIX="$PREFIX" \
|
||||
MOSAIC_NO_COLOR=1 \
|
||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||
MOSAIC_TEST_STATE="$STATE" \
|
||||
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||
)"
|
||||
CHECK_STATUS=$?
|
||||
set -e
|
||||
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
@@ -1,338 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first acceptance checks for #1050. This file is committed before the
|
||||
# installer implementation. Do not weaken these properties to make it green.
|
||||
|
||||
# pass_case always returns zero and fail_case records the aggregate failure;
|
||||
# the compact A&&pass||fail assertions are intentional.
|
||||
# shellcheck disable=SC2015
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
failures=0
|
||||
COMPAT_BIN="$TMP/compat-bin"
|
||||
mkdir -p "$COMPAT_BIN"
|
||||
cat > "$COMPAT_BIN/realpath" <<'REALPATH'
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m"
|
||||
if args and args[0] == "--":
|
||||
args.pop(0)
|
||||
if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])):
|
||||
raise SystemExit(1)
|
||||
print(os.path.realpath(args[0]))
|
||||
REALPATH
|
||||
chmod 0755 "$COMPAT_BIN/realpath"
|
||||
|
||||
fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); }
|
||||
pass_case() { printf '[test] PASS: %s\n' "$*"; }
|
||||
|
||||
fingerprint() {
|
||||
local dir="$1"
|
||||
if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi
|
||||
python3 - "$dir" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
root = os.path.abspath(sys.argv[1])
|
||||
rows = []
|
||||
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||
for name in dirs + files:
|
||||
path = os.path.join(current, name)
|
||||
rel = os.path.relpath(path, root)
|
||||
meta = os.lstat(path)
|
||||
target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ""
|
||||
digest = ""
|
||||
if stat.S_ISREG(meta.st_mode):
|
||||
with open(path, "rb") as handle:
|
||||
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||
rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest))
|
||||
payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode()
|
||||
print(hashlib.sha256(payload).hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
make_fake_npm() {
|
||||
local bin="$1"
|
||||
mkdir -p "$bin"
|
||||
cat > "$bin/npm" <<'FAKE'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
|
||||
case "${1:-} ${2:-} ${3:-}" in
|
||||
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
||||
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
||||
'view @mosaicstack/mosaic version') echo '0.0.49' ;;
|
||||
'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||
*) echo "unexpected fake npm command: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod 0755 "$bin/npm"
|
||||
}
|
||||
|
||||
printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n'
|
||||
check_home="$TMP/check-home"
|
||||
check_bin="$TMP/check-bin"
|
||||
mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin"
|
||||
printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md"
|
||||
printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md"
|
||||
cat > "$check_home/.npm-global/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$check_home/.npm-global/bin/mosaic"
|
||||
make_fake_npm "$check_bin"
|
||||
before="$(fingerprint "$check_home")"
|
||||
set +e
|
||||
HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1
|
||||
check_status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$check_home")"
|
||||
|
||||
[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \
|
||||
|| fail_case "--check mutated HOME (before=$before after=$after)"
|
||||
[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \
|
||||
|| fail_case '--check returned zero on the deliberately broken host'
|
||||
|
||||
phase_rows=0
|
||||
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do
|
||||
count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)"
|
||||
[[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count"
|
||||
phase_rows=$((phase_rows + count))
|
||||
done
|
||||
[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \
|
||||
|| fail_case "--check emitted $phase_rows canonical rows instead of 9"
|
||||
grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \
|
||||
&& pass_case 'P3 preserves the absolute-path exact-version discriminator' \
|
||||
|| fail_case 'P3 did not PASS with the exact resolved next-lane version'
|
||||
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \
|
||||
&& pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \
|
||||
|| fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED'
|
||||
for phase in P5 P8; do
|
||||
grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \
|
||||
&& pass_case "$phase remains an attributable expected RED" \
|
||||
|| fail_case "$phase did not report its own expected failure"
|
||||
done
|
||||
|
||||
printf '[test] case: --check discriminates a constructed good host without mutation\n'
|
||||
good_home="$TMP/good-home"
|
||||
good_bin="$TMP/good-bin"
|
||||
good_prefix="$good_home/.npm-global"
|
||||
good_mosaic="$good_home/.config/mosaic"
|
||||
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
|
||||
make_fake_npm "$good_bin"
|
||||
cp "$COMPAT_BIN/realpath" "$good_bin/realpath"
|
||||
cat > "$good_bin/id" <<'ID'
|
||||
#!/bin/bash
|
||||
case "${1:-}" in
|
||||
-u) echo 1001 ;;
|
||||
-g) echo 1001 ;;
|
||||
-un) echo fixture-user ;;
|
||||
*) exec /bin/id "$@" ;;
|
||||
esac
|
||||
ID
|
||||
cat > "$good_bin/stat" <<'STAT'
|
||||
#!/bin/bash
|
||||
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then echo 1001; exit 0; fi
|
||||
exec /bin/stat "$@"
|
||||
STAT
|
||||
cat > "$good_bin/curl" <<'CURL'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
CURL
|
||||
cat > "$good_bin/ldd" <<'LDD'
|
||||
#!/bin/bash
|
||||
echo 'ldd (GNU libc) 2.36'
|
||||
LDD
|
||||
chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd"
|
||||
cat > "$good_prefix/bin/mosaic" <<'CLI'
|
||||
#!/usr/bin/env bash
|
||||
printf '0.0.50-next.999\n'
|
||||
CLI
|
||||
chmod 0755 "$good_prefix/bin/mosaic"
|
||||
cat > "$good_bin/getent" <<GETENT
|
||||
#!/bin/bash
|
||||
printf '%s:x:%s:%s::%s:%s\\n' '$(id -un)' '$(id -u)' '$(id -g)' '$good_home' '$good_bin/bash'
|
||||
GETENT
|
||||
cat > "$good_bin/bash" <<SHELL
|
||||
#!/bin/bash
|
||||
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
|
||||
printf '%s\\n' '$good_prefix/bin/mosaic'
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/bash "\$@"
|
||||
SHELL
|
||||
chmod 0755 "$good_bin/getent" "$good_bin/bash"
|
||||
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
|
||||
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
|
||||
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
|
||||
---
|
||||
name: declared-skill
|
||||
description: Constructed loadable acceptance skill.
|
||||
---
|
||||
|
||||
# Declared skill
|
||||
SKILL
|
||||
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
before="$(fingerprint "$good_home")"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$good_home")"
|
||||
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
|
||||
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
|
||||
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
|
||||
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|
||||
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
|
||||
|
||||
printf '[test] case: persisted required-action failures remain blocking\n'
|
||||
for blocked_phase in P4 P6; do
|
||||
node -e '
|
||||
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
|
||||
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
|
||||
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
|
||||
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
|
||||
set +e
|
||||
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
|
||||
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
|
||||
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|
||||
|| fail_case "$blocked_phase persisted action failure was not attributed"
|
||||
done
|
||||
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||
|
||||
printf '[test] case: per-phase P2-P8 fault injection restores representative host mutations\n'
|
||||
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||
home="$TMP/fault-$phase/home"
|
||||
state="$TMP/fault-$phase/state"
|
||||
mkdir -p "$home/.config/mosaic" "$home/.npm-global/bin" "$home/.claude" "$state"
|
||||
printf 'operator-framework-sentinel\n' > "$home/.config/mosaic/operator.txt"
|
||||
printf '@scope:registry=https://pre.example.invalid/\n' > "$home/.npmrc"
|
||||
printf 'old-cli\n' > "$home/.npm-global/bin/mosaic"
|
||||
printf '{"hooks":{"safe":true}}\n' > "$home/.claude/settings.json"
|
||||
before="$(fingerprint "$home")"
|
||||
set +e
|
||||
HOME="$home" MOSAIC_HOME="$home/.config/mosaic" MOSAIC_PREFIX="$home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$state" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/fault-$phase.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$phase injected fault returned zero"
|
||||
grep -q "phase=$phase" "$TMP/fault-$phase.log" \
|
||||
|| fail_case "$phase fault transcript did not name the injected phase"
|
||||
[[ "$before" == "$after" ]] \
|
||||
&& pass_case "$phase rollback restored framework/npmrc/prefix/runtime representative state" \
|
||||
|| fail_case "$phase rollback mismatch (before=$before after=$after)"
|
||||
if find "$state" -type f -exec grep -l '"status"[[:space:]]*:[[:space:]]*"in-progress"' {} + 2>/dev/null | grep -q .; then
|
||||
fail_case "$phase left a journal in-progress"
|
||||
else
|
||||
pass_case "$phase left no journal falsely in-progress"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
|
||||
unsafe_home="$TMP/unsafe-home"
|
||||
mkdir -p "$unsafe_home"
|
||||
for case_name in root-target home-target overlap-target; do
|
||||
case "$case_name" in
|
||||
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
|
||||
esac
|
||||
before="$(fingerprint "$unsafe_home")"
|
||||
set +e
|
||||
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$unsafe_home")"
|
||||
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
|
||||
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
|
||||
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
|
||||
done
|
||||
|
||||
symlink_home="$TMP/symlink-home"
|
||||
symlink_outside="$TMP/symlink-outside"
|
||||
mkdir -p "$symlink_home" "$symlink_outside"
|
||||
ln -s "$symlink_outside" "$symlink_home/.config"
|
||||
set +e
|
||||
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
|
||||
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
|
||||
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
|
||||
&& pass_case 'symlinked rollback parent was rejected by P0' \
|
||||
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
|
||||
[[ -z "$(find "$symlink_outside" -mindepth 1 -print -quit)" ]] || fail_case 'symlink target was mutated'
|
||||
|
||||
printf '[test] case: stale in-progress projection does not impersonate a live OS lock\n'
|
||||
stale_home="$TMP/stale/home"
|
||||
stale_state="$TMP/stale/state"
|
||||
mkdir -p "$stale_home/.config/mosaic" "$stale_state"
|
||||
printf '{"status":"in-progress","journal":"%s"}\n' "$stale_state/dead-run/journal.ndjson" > "$stale_state/active.json"
|
||||
set +e
|
||||
HOME="$stale_home" MOSAIC_HOME="$stale_home/.config/mosaic" MOSAIC_PREFIX="$stale_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="$stale_state" MOSAIC_INSTALL_FAULT_AFTER=P2 MOSAIC_NO_COLOR=1 \
|
||||
PATH="$COMPAT_BIN:$PATH" bash "$ROOT/tools/install.sh" --state-machine-self-test >"$TMP/stale.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
[[ "$status" -eq 97 ]] || fail_case "stale projection recovery expected injected status 97, got $status"
|
||||
if find "$stale_state" -name prior-active.json -type f -print -quit | grep -q .; then
|
||||
pass_case 'stale projection was preserved and superseded after the free OS lock was acquired'
|
||||
else
|
||||
fail_case 'stale projection was not preserved for recovery evidence'
|
||||
fi
|
||||
[[ "$(node -p "require('$stale_state/active.json').status")" == "rolled-back" ]] \
|
||||
|| fail_case 'stale retry did not reach an honest rolled-back terminal state'
|
||||
|
||||
printf '[test] case: journal initialization failure is fatal before mutation\n'
|
||||
journal_home="$TMP/journal-failure/home"
|
||||
mkdir -p "$journal_home/.config/mosaic"
|
||||
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
|
||||
before="$(fingerprint "$journal_home")"
|
||||
set +e
|
||||
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
|
||||
MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||
MOSAIC_NO_COLOR=1 bash "$ROOT/tools/install.sh" --state-machine-self-test \
|
||||
>"$TMP/journal-failure.log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
after="$(fingerprint "$journal_home")"
|
||||
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|
||||
|| fail_case 'unwritable journal directory returned zero'
|
||||
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
|
||||
&& pass_case 'journal initialization failure was named' \
|
||||
|| fail_case 'journal initialization failure lacked a named diagnostic'
|
||||
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|
||||
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
||||
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] installer state-machine acceptance passed\n'
|
||||
+61
-1148
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that the detector found exactly the pinned C1 phase verdicts. The
|
||||
# fixture is expected to exit non-zero; this verifier is the green CI contract.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="${MOSAIC_EXPECTED_RED_MANIFEST:-$ROOT/tools/fixtures/greenfield-expected-red.tsv}"
|
||||
CASE="${1:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
LOG="${2:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||
|
||||
[[ -r "$MANIFEST" ]] || { echo "expected-RED manifest is unreadable: $MANIFEST" >&2; exit 2; }
|
||||
[[ -r "$LOG" ]] || { echo "fixture log is unreadable: $LOG" >&2; exit 2; }
|
||||
[[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; }
|
||||
|
||||
checks=0
|
||||
failures=0
|
||||
while IFS=$'\t' read -r case_name kind expectation; do
|
||||
[[ -n "$case_name" && "${case_name:0:1}" != "#" ]] || continue
|
||||
[[ "$case_name" == "$CASE" ]] || continue
|
||||
checks=$((checks + 1))
|
||||
case "$kind" in
|
||||
exit)
|
||||
if [[ "$FIXTURE_EXIT" != "$expectation" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE fixture_exit=$FIXTURE_EXIT expected=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
phase)
|
||||
phase="${expectation%%=*}"
|
||||
expected_verdict="${expectation#*=}"
|
||||
last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)"
|
||||
actual_verdict="$(printf '%s\n' "$last_row" | sed -n "s/^\[$phase\] \(PASS\|FAIL\):.*/\1/p")"
|
||||
if [[ "$actual_verdict" != "$expected_verdict" ]]; then
|
||||
echo "expected-RED mismatch: case=$CASE phase=$phase got=${actual_verdict:-missing} expected=$expected_verdict" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
require)
|
||||
if ! grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED missing required evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
forbid)
|
||||
if grep -Eq -- "$expectation" "$LOG"; then
|
||||
echo "expected-RED found forbidden evidence: case=$CASE regex=$expectation" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done < "$MANIFEST"
|
||||
|
||||
[[ "$checks" -gt 0 ]] || { echo "expected-RED manifest has no checks for case=$CASE" >&2; exit 2; }
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "expected-RED verification failed: case=$CASE failures=$failures checks=$checks" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'expected-RED verification passed: case=%s checks=%d\n' "$CASE" "$checks"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-expected-red-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
cat > "$TMP/match.log" <<'LOG'
|
||||
[fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999
|
||||
[fixture] installer_exit=1 done_claims=0
|
||||
[P0] PASS: supported context
|
||||
[P1] PASS: preflight complete
|
||||
[P2] PASS: pinned artifact
|
||||
[P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version
|
||||
[P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent
|
||||
[P5] FAIL: identity absent
|
||||
[P6] FAIL: activation unavailable
|
||||
[P7] PASS: no services requested
|
||||
[P8] FAIL: shell path absent
|
||||
[P9] FAIL: aggregate refusal
|
||||
LOG
|
||||
|
||||
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null
|
||||
printf '[test] PASS: matching detector findings make the CI verifier green\n'
|
||||
|
||||
sed 's/^\[P4\] FAIL:/[P4] PASS:/' "$TMP/match.log" > "$TMP/drift.log"
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/drift.log" 1 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: changed P4 verdict did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: changed phase verdict requires a deliberate manifest update\n'
|
||||
|
||||
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 0 >/dev/null 2>&1; then
|
||||
echo '[test] FAIL: unexpected fixture exit did not invalidate the pinned manifest' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '[test] PASS: unexpected fixture exit remains blocking\n'
|
||||
Reference in New Issue
Block a user