Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4748bd71 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 |
@@ -46,6 +46,10 @@ steps:
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
+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
|
||||
|
||||
@@ -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 | Build-from-source at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -371,9 +361,7 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||
bash tools/install.sh --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
|
||||
```
|
||||
|
||||
@@ -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,7 +443,6 @@ 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(
|
||||
@@ -455,7 +451,6 @@ export class CommandExecutorService {
|
||||
'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,15 +47,13 @@ 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) {
|
||||
// 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.
|
||||
const cutoff = new Date();
|
||||
|
||||
@@ -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,43 +108,22 @@ 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
@@ -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();
|
||||
|
||||
+62
@@ -146,6 +146,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
### Fleet git identity launch propagation (#1043)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||
tmux launch.
|
||||
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||
environment projection, including the roster-derived Git identity.
|
||||
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||
|
||||
### Framework shell assertion portability (#1098)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
||||
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
||||
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
||||
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
||||
make failures carry the observed records needed for diagnosis.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
||||
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
||||
can override a successful match.
|
||||
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
||||
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
||||
utility implementation.
|
||||
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
||||
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
||||
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
||||
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
||||
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
||||
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
||||
emit the indexed observed argv.
|
||||
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
||||
Woodpecker pipeline is terminal green before #1098 closes.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
|
||||
@@ -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.
|
||||
@@ -6,8 +6,8 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
## Launch chain
|
||||
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||
modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
1. Read and validate the canonical roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||
3. Parse optional <name>.env.local through a strict allowlist.
|
||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
|
||||
## File precedence and ownership
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||
this projection path.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,229 @@
|
||||
# #1043 — Fleet pane git-identity propagation
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||
|
||||
## Tracking
|
||||
|
||||
- External issue: `mosaicstack/stack#1043`
|
||||
- Branch: `fix/1043-pane-git-identity`
|
||||
- Coordinator: `tl-mosaic`
|
||||
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||
|
||||
## Constraints
|
||||
|
||||
- RED-first bug reproducer is mandatory.
|
||||
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||
- Assert launched-process environment, not source text.
|
||||
- One push only; do not poll CI after push.
|
||||
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||
- Do not modify a live host launcher or obtain/copy another credential.
|
||||
- Self-post the PR, verify provider attribution, then stop.
|
||||
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||
|
||||
## Scope inventory
|
||||
|
||||
Re-derived against `origin/main` at `85d2108e`:
|
||||
|
||||
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||
- Roster projection producers:
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||
- Contract and producer tests discovered by repository search.
|
||||
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||
|
||||
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add the process-environment set-comparison regression first and record RED.
|
||||
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||
4. Update affected projection tests and generated-environment docs.
|
||||
5. Run focused and baseline gates.
|
||||
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||
|
||||
## Evidence log
|
||||
|
||||
### TDD and mutation evidence
|
||||
|
||||
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- R7 restoration: restored that launch entry; the same test returned green.
|
||||
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||
|
||||
### Situational and focused tests
|
||||
|
||||
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||
- `bash -n` on changed shell files — green.
|
||||
- `git diff --check` — green.
|
||||
|
||||
### Baseline gates
|
||||
|
||||
- `pnpm typecheck` — 45/45 tasks green.
|
||||
- `pnpm lint` — 25/25 tasks green.
|
||||
- `pnpm format:check` — green.
|
||||
- `pnpm test:checkout` — green.
|
||||
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||
|
||||
### Acceptance criteria mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- PRD updated with #1043 requirements and acceptance criteria.
|
||||
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||
|
||||
## Round 2 — PR #1073 review 97 remediation
|
||||
|
||||
### Review blocker
|
||||
|
||||
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||
|
||||
### RED-first and canonical wiring
|
||||
|
||||
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||
|
||||
### Workflow-level R7
|
||||
|
||||
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||
|
||||
### Workflow population
|
||||
|
||||
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||
|
||||
### Independent remediation review
|
||||
|
||||
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||
|
||||
### Vitest denominator reconciliation
|
||||
|
||||
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||
|
||||
- generated environment boundary: 24
|
||||
- fleet documentation: 23
|
||||
- Tess service profile: 6
|
||||
- fleet regen command: 27
|
||||
- fleet agent CRUD command: 22
|
||||
- fleet command: 209
|
||||
- total: **311**
|
||||
|
||||
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||
|
||||
## Round 3 — Alpine stale-marker portability
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||
|
||||
### RED-first evidence
|
||||
|
||||
Before the fix, the canonical CI image command
|
||||
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
exited 1 at the stale-marker setup with exact BusyBox output
|
||||
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||
|
||||
### Root cause and fix
|
||||
|
||||
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||
|
||||
### Structural pattern
|
||||
|
||||
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||
|
||||
### GREEN and quality evidence
|
||||
|
||||
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||
|
||||
### Re-derived inventory and denominators
|
||||
|
||||
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||
|
||||
## Round 4 — bound stale-marker observation
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||
|
||||
### Root cause and bounded observation
|
||||
|
||||
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||
|
||||
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||
|
||||
### Two-mutant RED / restored GREEN
|
||||
|
||||
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||
|
||||
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||
|
||||
### Re-derived inventory
|
||||
|
||||
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||
- Canonical CI result is pending and will not be polled by this seat.
|
||||
@@ -0,0 +1,97 @@
|
||||
# #1098 — Framework shell portability / red main
|
||||
|
||||
## Objective
|
||||
|
||||
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
||||
|
||||
## Scope
|
||||
|
||||
- Tracking issue: `mosaicstack/stack#1098`
|
||||
- Branch: `fix/framework-shell-portability`
|
||||
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
||||
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
||||
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
||||
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
||||
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
||||
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
||||
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
||||
|
||||
## Budget
|
||||
|
||||
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
||||
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
||||
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
||||
|
||||
## Investigation
|
||||
|
||||
### First-hand CI evidence
|
||||
|
||||
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
||||
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
||||
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
||||
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
||||
|
||||
### Root cause
|
||||
|
||||
The assertion ends in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
||||
```
|
||||
|
||||
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
||||
|
||||
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
||||
|
||||
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
||||
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
||||
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
||||
- A full-reading `grep -xF` control remained 0 for every payload.
|
||||
|
||||
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
||||
|
||||
## TDD plan
|
||||
|
||||
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
||||
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
||||
3. Add negative controls for missing, detached, and reversed tokens.
|
||||
4. On failure, print indexed `%q` argv records before returning nonzero.
|
||||
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
||||
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
||||
- [x] CI log decoded directly.
|
||||
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
||||
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
||||
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
||||
- [x] Baseline/situational tests:
|
||||
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
||||
- paired `test-fleet-units.sh`: PASS;
|
||||
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
||||
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
||||
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
||||
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
||||
- [x] Independent review:
|
||||
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
||||
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
||||
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
||||
- Codex security review: risk none, 0 findings.
|
||||
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
||||
|
||||
## Documentation disposition
|
||||
|
||||
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
||||
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
||||
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
||||
|
||||
## Risks
|
||||
|
||||
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
||||
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Scratchpad — FED-M3-06 get verb
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
|
||||
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
|
||||
- Unit coverage for controller pipeline + query service RBAC guardrails
|
||||
- Register controller/service in `FederationModule`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service.
|
||||
2. Return one `_source: "local"` tagged item on success.
|
||||
3. Return federation error envelopes:
|
||||
- `404 not_found` when the resource id does not exist.
|
||||
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
|
||||
- `400 invalid_request` for malformed ids/scope requests.
|
||||
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
|
||||
- Review-change coverage added for comment 15971:
|
||||
- get note access now requires subject ownership AND authorized mission intersection.
|
||||
- missing federation context returns structured `401 unauthorized` envelope.
|
||||
- unsupported get resources fail closed with structured denial.
|
||||
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
|
||||
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
|
||||
- `pnpm build` — pass (23 successful tasks before review changes).
|
||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
|
||||
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
|
||||
- `pnpm format:check` — pass (re-run after review changes).
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
|
||||
@@ -1,82 +0,0 @@
|
||||
# B1 / @next Durable Publish Pipeline — Design
|
||||
|
||||
## Objective
|
||||
|
||||
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
|
||||
|
||||
Every merge to `next` publishes:
|
||||
|
||||
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
|
||||
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
|
||||
|
||||
The existing stable release behavior remains isolated to `main` / tags.
|
||||
|
||||
## Registry verification
|
||||
|
||||
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
|
||||
|
||||
Pre-implementation checks:
|
||||
|
||||
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
|
||||
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
|
||||
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
|
||||
|
||||
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
|
||||
|
||||
## Version scheme
|
||||
|
||||
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
|
||||
|
||||
For each non-private `@mosaicstack/*` package:
|
||||
|
||||
```text
|
||||
<target-stable>-next.<CI_PIPELINE_NUMBER>
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
|
||||
- `target-stable` is the package's current committed stable version with the patch component incremented.
|
||||
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
|
||||
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
|
||||
|
||||
Rationale:
|
||||
|
||||
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
|
||||
- The prerelease does not overtake the future stable `0.0.49`.
|
||||
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
|
||||
|
||||
## Branch and tag guardrails
|
||||
|
||||
| Pipeline path | Branch/event | Publishes | Forbidden |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
|
||||
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
|
||||
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
|
||||
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
|
||||
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
|
||||
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
|
||||
|
||||
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
|
||||
2. Keep existing `publish-npm` on `main` / tags only.
|
||||
3. Add `publish-next-npm` for `next` push/manual only:
|
||||
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
|
||||
- preflight registry dist-tag metadata;
|
||||
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
|
||||
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
|
||||
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
|
||||
4. Split image `when` anchors:
|
||||
- `image_build_when` includes `next` and is used by `build-gateway`;
|
||||
- `main_image_build_when` keeps appservice/web on main/tags only.
|
||||
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
|
||||
|
||||
## Risk controls
|
||||
|
||||
- Auth/registry failures are fatal.
|
||||
- No manual image build/push path is introduced.
|
||||
- No production `latest` tags are touched from `next`.
|
||||
- No `@latest` npm dist-tags are touched from `next`.
|
||||
- All changes live in CI config and docs; no runtime source behavior changes.
|
||||
@@ -1,34 +0,0 @@
|
||||
# B2 — Fresh-install skills sync path
|
||||
|
||||
## Problem
|
||||
|
||||
Greenfield wizard on `next` reported:
|
||||
|
||||
```text
|
||||
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
|
||||
Skills: install failed
|
||||
```
|
||||
|
||||
## Diagnosis
|
||||
|
||||
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/tools/_scripts/
|
||||
```
|
||||
|
||||
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
|
||||
|
||||
## Fix
|
||||
|
||||
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
|
||||
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
|
||||
- Point missing-script warnings at the current `tools/_scripts` layout.
|
||||
- Update the finalize skills test fixture to model the fresh framework layout.
|
||||
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
|
||||
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
|
||||
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
|
||||
@@ -1,36 +0,0 @@
|
||||
# B3 — Wizard completion ordering
|
||||
|
||||
## Problem
|
||||
|
||||
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
|
||||
|
||||
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
|
||||
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
|
||||
3. gateway config starts/waits for daemon health;
|
||||
4. gateway bootstrap runs.
|
||||
|
||||
The summary needed to be deferred until after the gateway readiness gates.
|
||||
|
||||
## Fix
|
||||
|
||||
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
|
||||
- Wizard/quick-start paths call finalize with `deferSummary: true`.
|
||||
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
|
||||
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
|
||||
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
|
||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
|
||||
- `pnpm format:check`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
- `pnpm build`
|
||||
- `pnpm test`
|
||||
- Codex code review: approve.
|
||||
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
|
||||
@@ -1,36 +0,0 @@
|
||||
# B4 — Wizard step deduplication
|
||||
|
||||
## Problem
|
||||
|
||||
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
|
||||
|
||||
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
|
||||
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
|
||||
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
|
||||
- provider step completed and user intentionally skipped the key, vs.
|
||||
- provider step was never run.
|
||||
|
||||
## Fix
|
||||
|
||||
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
|
||||
- Completed menu steps now log a skip message instead of re-running their stage.
|
||||
- Quick Start marks Providers and Skills complete after running them.
|
||||
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
|
||||
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
|
||||
|
||||
## Verification
|
||||
|
||||
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
|
||||
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
|
||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
|
||||
- `pnpm format:check`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
- `pnpm build`
|
||||
- `pnpm test`
|
||||
- Codex code review: approve.
|
||||
- Codex security review: no findings.
|
||||
@@ -1,60 +0,0 @@
|
||||
# FED-M3-10 — Federation M3 Integration Tests
|
||||
|
||||
## Objective
|
||||
|
||||
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
|
||||
|
||||
## Branch / base
|
||||
|
||||
- Branch: `feat/federation-m3-integration`
|
||||
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
|
||||
- PR base when unblocked: `next`
|
||||
|
||||
## Scope
|
||||
|
||||
- Real PostgreSQL via `@mosaicstack/db`.
|
||||
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
|
||||
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
|
||||
- Gated by `FEDERATED_INTEGRATION=1`.
|
||||
- No federation harness dependency.
|
||||
|
||||
## Fixture notes
|
||||
|
||||
Aligned with the B2 seed design vocabulary:
|
||||
|
||||
- `tasks` visibility uses personal `projects` + `missions` chain.
|
||||
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
|
||||
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
|
||||
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
|
||||
|
||||
## Current implementation
|
||||
|
||||
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
|
||||
|
||||
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
|
||||
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
|
||||
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
|
||||
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
|
||||
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/types build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/db build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/storage build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/brain build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/queue build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/config build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/auth build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
|
||||
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
|
||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
|
||||
|
||||
## Push / PR
|
||||
|
||||
- #683 landed in `next`; branch rebased onto `origin/next` before push.
|
||||
- CI is serialized; run queue guard before push.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Installer `--next` fast npm lane — 2026-06-25
|
||||
|
||||
## Scope
|
||||
|
||||
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
|
||||
|
||||
## Registry reality check
|
||||
|
||||
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
|
||||
|
||||
Verified before implementation:
|
||||
|
||||
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
|
||||
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
|
||||
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
|
||||
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
|
||||
|
||||
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `--next` with no explicit ref:
|
||||
1. framework archive from `next`;
|
||||
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
|
||||
3. require both resolved versions to share the same `next.<pipeline>` suffix;
|
||||
4. install the exact resolved package versions;
|
||||
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
|
||||
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
|
||||
- `--dev` remains pure source build.
|
||||
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
|
||||
|
||||
## Install detail
|
||||
|
||||
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
|
||||
|
||||
## Verification notes
|
||||
|
||||
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
|
||||
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
|
||||
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Scratchpad — installer `--next` lane
|
||||
|
||||
## Objective
|
||||
|
||||
Add a prerelease installer lane for the permanent `next` integration branch.
|
||||
|
||||
## Scope
|
||||
|
||||
- `tools/install.sh`
|
||||
- README/install documentation
|
||||
- Follow-up design note for future npm `@next` prerelease publishing
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
|
||||
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
|
||||
3. Update installer source display/help text.
|
||||
4. Document three lanes:
|
||||
- stable npm `@latest`
|
||||
- prerelease `--next`
|
||||
- contributor `--dev --ref X`
|
||||
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n tools/install.sh` — pass.
|
||||
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
|
||||
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
|
||||
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
|
||||
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
|
||||
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
|
||||
- `pnpm typecheck` — pass (41 successful tasks).
|
||||
- `pnpm lint` — pass (23 successful tasks).
|
||||
- `pnpm format:check` — pass.
|
||||
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
|
||||
@@ -1,93 +0,0 @@
|
||||
# W-B — Measure Pi's real tool registry
|
||||
|
||||
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
|
||||
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
|
||||
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
|
||||
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
|
||||
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
|
||||
|
||||
## Method
|
||||
|
||||
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
|
||||
|
||||
```text
|
||||
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
|
||||
--no-context-files --no-skills --no-prompt-templates --no-extensions \
|
||||
-e /tmp/measure-pi-tool-registry.ts \
|
||||
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
|
||||
```
|
||||
|
||||
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
|
||||
|
||||
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
|
||||
|
||||
## Exact distinct built-in set
|
||||
|
||||
The installed Pi built-in registry is exactly:
|
||||
|
||||
```text
|
||||
{bash, edit, find, grep, ls, read, write}
|
||||
```
|
||||
|
||||
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
|
||||
| --- | --- | --- | --- |
|
||||
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
|
||||
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
|
||||
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
|
||||
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
|
||||
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
|
||||
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
|
||||
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
|
||||
|
||||
The raw distinct `event.toolName` result was:
|
||||
|
||||
```json
|
||||
["bash", "edit", "find", "grep", "ls", "read", "write"]
|
||||
```
|
||||
|
||||
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
|
||||
|
||||
## Positive control
|
||||
|
||||
The known `read` tool was the control. The method surfaced it twice:
|
||||
|
||||
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
|
||||
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
|
||||
|
||||
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
|
||||
|
||||
## Carve-out comparison and collision result
|
||||
|
||||
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
|
||||
|
||||
- `read`: real built-in.
|
||||
- `grep`: real built-in.
|
||||
- `find`: real built-in.
|
||||
- `ls`: real built-in.
|
||||
|
||||
All four carve-out names are exact, case-sensitive Pi tool names.
|
||||
|
||||
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
|
||||
|
||||
```text
|
||||
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
|
||||
```
|
||||
|
||||
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
|
||||
|
||||
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- `pi --version` → `0.84.1`.
|
||||
- Isolated probe exit → `0`.
|
||||
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
|
||||
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
|
||||
- Hook counts → exactly one call for each of the seven names.
|
||||
- Mutation artifact after `write` then `edit` → exact content `after`.
|
||||
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
|
||||
|
||||
## Risks / limitations
|
||||
|
||||
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
|
||||
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
|
||||
+1
-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-next-lane.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.
|
||||
@@ -197,7 +185,6 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
|
||||
|
||||
```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
|
||||
@@ -211,7 +198,7 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
||||
|
||||
```bash
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||
```
|
||||
|
||||
## MCP Registration
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.
|
||||
@@ -32,18 +32,6 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -93,8 +81,8 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||
"timeout": 3
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -112,6 +112,7 @@ EOF
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
|
||||
@@ -153,24 +153,7 @@ if [[ $link_only -eq 1 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
||||
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
||||
# commands/launch.js):
|
||||
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
||||
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
||||
# codex CODEX_HOME -> <home>/skills
|
||||
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
||||
link_targets=(
|
||||
"$MOSAIC_HOME/.claude/skills"
|
||||
"$MOSAIC_HOME/.codex/skills"
|
||||
"$MOSAIC_HOME/.opencode/opencode/skills"
|
||||
"$MOSAIC_HOME/.pi/skills"
|
||||
)
|
||||
|
||||
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
||||
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
||||
# they persist and make a "clean" base install look mosaic-managed.
|
||||
legacy_link_targets=(
|
||||
"$HOME/.claude/skills"
|
||||
"$HOME/.codex/skills"
|
||||
"$HOME/.config/opencode/skills"
|
||||
@@ -262,72 +245,13 @@ prune_stale_links_in_target() {
|
||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||
# is unproven and the link must be preserved.
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
|
||||
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
|
||||
# matches every absolute path. Combined with the is_mosaic_skill_name skip
|
||||
# above, that inverts the function precisely — it would delete exactly the
|
||||
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
|
||||
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
}
|
||||
|
||||
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
||||
#
|
||||
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
||||
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
||||
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
||||
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
||||
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
||||
#
|
||||
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
||||
# missing, and an empty dir is the correct end state, not an absent one.
|
||||
cleanup_legacy_target() {
|
||||
local target_dir="$1"
|
||||
local removed=0 kept=0
|
||||
|
||||
[[ -d "$target_dir" ]] || return 0
|
||||
|
||||
while IFS= read -r -d '' link_path; do
|
||||
local resolved owned=0
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
|
||||
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
||||
# match every absolute path and delete foreign links.
|
||||
if [[ -n "$resolved" ]]; then
|
||||
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
owned=1
|
||||
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
||||
owned=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $owned -eq 1 ]]; then
|
||||
rm -f "$link_path"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
kept=$((kept + 1))
|
||||
fi
|
||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||
|
||||
if [[ $removed -gt 0 ]]; then
|
||||
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
||||
fi
|
||||
}
|
||||
|
||||
for legacy in "${legacy_link_targets[@]}"; do
|
||||
# Skip anything that is also a current target, so isolation can never
|
||||
# self-destruct if the two lists ever overlap.
|
||||
skip=0
|
||||
for target in "${link_targets[@]}"; do
|
||||
[[ "$legacy" == "$target" ]] && skip=1
|
||||
done
|
||||
[[ $skip -eq 1 ]] && continue
|
||||
cleanup_legacy_target "$legacy"
|
||||
done
|
||||
|
||||
for target in "${link_targets[@]}"; do
|
||||
mkdir -p "$target"
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ is_sensitive_key() {
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -114,6 +114,7 @@ validate_generated_value() {
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
@@ -175,7 +176,7 @@ load_environment_file() {
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
|
||||
@@ -14,6 +14,82 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pane_command_clears_environment() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
||||
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_pane_argv() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local bytes index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
bytes=$(wc -c < "$calls_file")
|
||||
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
||||
for ((index = 0; index < ${#argv[@]}; index++)); do
|
||||
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
||||
done
|
||||
}
|
||||
|
||||
check_pane_environment_boundary() {
|
||||
local calls_file="$1"
|
||||
if pane_command_clears_environment "$calls_file"; then
|
||||
return 0
|
||||
fi
|
||||
print_pane_argv "$calls_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
contains_literal() {
|
||||
grep -F -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
contains_line() {
|
||||
grep -xF -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
||||
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
||||
# pipeline could report failure after a successful match when an upstream
|
||||
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
||||
# covered without making stream size part of the semantic contract.
|
||||
PORTABILITY_CALLS="$ROOT/portability-calls"
|
||||
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
||||
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
||||
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
||||
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
||||
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
||||
|
||||
assert_pane_boundary_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_records="$2"
|
||||
local diagnostic
|
||||
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
||||
fail "pane boundary accepted invalid $case_name fixture"
|
||||
fi
|
||||
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
||||
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
||||
contains_literal "$diagnostic" '[000]' || \
|
||||
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
||||
}
|
||||
|
||||
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-env 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-i 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected non-adjacent-i 3
|
||||
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected reversed-boundary 2
|
||||
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -62,6 +138,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
|
||||
# Formatting reads still use the real BusyBox/POSIX date implementation.
|
||||
cat > "$FAKE_BIN/date" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
|
||||
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/date "$@"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/date"
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
@@ -71,6 +160,7 @@ write_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
@@ -88,6 +178,7 @@ run_start() {
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
@@ -100,19 +191,55 @@ AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||
if contains_literal "$valid_args" 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
fi
|
||||
|
||||
# The pane must start through an absolute clean-environment boundary. Its
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
|
||||
# Git identity is generated authority, not an optional or independently mutable
|
||||
# local value. Each invalid form must fail before fake tmux receives a call.
|
||||
assert_git_identity_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_code="$2"
|
||||
local home="$ROOT/git-identity-$case_name"
|
||||
local agent="coder-git-identity-$case_name"
|
||||
local generated="$home/fleet/agents/$agent.env.generated"
|
||||
write_generated "$home" "$agent"
|
||||
|
||||
case "$case_name" in
|
||||
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
||||
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
||||
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
||||
local-shadow)
|
||||
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
;;
|
||||
*) fail "unknown Git identity rejection case: $case_name" ;;
|
||||
esac
|
||||
chmod 600 "$generated"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "Git identity case $case_name was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
||||
contains_literal "$output" "code=$expected_code" || \
|
||||
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
||||
}
|
||||
|
||||
assert_git_identity_rejected missing missing-key
|
||||
assert_git_identity_rejected unsafe unsafe-git-identity
|
||||
assert_git_identity_rejected mismatch git-identity-mismatch
|
||||
assert_git_identity_rejected local-shadow generated-key-shadow
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
@@ -125,7 +252,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
@@ -136,7 +263,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
@@ -174,8 +301,8 @@ assert_managed_ancestor_rejected() {
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
@@ -196,9 +323,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if contains_literal "$output" codex; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
@@ -214,9 +341,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
@@ -230,7 +357,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
@@ -260,25 +387,35 @@ PATH="$PANE_STALE_PATH" \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
# Exercise the repository launcher at $START, not the independently installed
|
||||
# host copy. Set-compare every declared generated projection entry with the
|
||||
# launched process environment so a newly declared identity cannot be omitted
|
||||
# by a hand-maintained per-variable assertion.
|
||||
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
||||
missing_or_changed_generated_environment=$(comm -23 \
|
||||
<(printf '%s\n' "$declared_generated_environment") \
|
||||
<(printf '%s\n' "$pane_environment" | sort))
|
||||
if [ -n "$missing_or_changed_generated_environment" ]; then
|
||||
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
||||
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
||||
fi
|
||||
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
write_interaction_generated() {
|
||||
@@ -290,6 +427,7 @@ write_interaction_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
@@ -352,8 +490,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
touch -t 200001010000.00 "$STALE_HB.native"
|
||||
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
@@ -374,22 +516,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
contains_literal "$interaction_policy_args" new-session && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
@@ -402,10 +544,10 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if contains_literal "$stop_args" ambient-socket; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
||||
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
||||
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
||||
# The old call therefore always failed, was unchecked, and the script
|
||||
# closed the issue anyway, losing the record of WHY.
|
||||
#
|
||||
# Use `tea comment` rather than the API helper so the comment and the
|
||||
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
||||
# comment through the token-authenticated helper here would attribute the
|
||||
# comment to the token holder and the close to the tea login -- two
|
||||
# principals for one operation.
|
||||
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
gitea_issue_comment_api
|
||||
# Fail closed here too: an unchecked comment lets the issue close without its
|
||||
# audit trail, which is the same defect as the tea path above.
|
||||
gitea_issue_comment_api || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
gitea_issue_close_api
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
||||
# be posted, and comment+close must be made by ONE principal.
|
||||
#
|
||||
# Guards two defects fixed together (see #1081):
|
||||
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
||||
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
||||
# anyway, losing the record of WHY it was closed.
|
||||
# 2. Routing the comment through the token-authenticated API helper while the close
|
||||
# used --login would attribute one operation to two principals.
|
||||
#
|
||||
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
||||
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
||||
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
||||
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
||||
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
||||
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
||||
set -euo pipefail
|
||||
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
||||
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
||||
# list and silently terminates the script with no message. Every must-not-appear check
|
||||
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
||||
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
||||
|
||||
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
||||
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||
cleanup() { rm -rf "$SANDBOX"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET="$SCRIPT_DIR/issue-close.sh"
|
||||
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
||||
# reason legible instead of a bare non-zero exit.
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||
git init -q || fail "setup: git init failed"
|
||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||
export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||
#!/bin/bash
|
||||
method=GET; url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) method="$2"; shift 2 ;;
|
||||
http*|https*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
||||
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/curl"
|
||||
|
||||
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
||||
local rc="$1" login="${2-}"
|
||||
cat > "$MOCK_BIN/tea" <<EOF
|
||||
#!/bin/bash
|
||||
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||
if [[ "\$*" == *"login list"* ]]; then
|
||||
printf '%s\n' '${login}'; exit 0
|
||||
fi
|
||||
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
||||
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
||||
# a setup assertion.
|
||||
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
}
|
||||
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
|
||||
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||
# to the real tea/curl and the "test" mutates the real provider.
|
||||
assert_mocked() {
|
||||
local w
|
||||
for w in tea curl; do
|
||||
p=$(command -v "$w" || true)
|
||||
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
||||
case "$p" in
|
||||
"$MOCK_BIN"/*) : ;;
|
||||
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_target() { # never let a target failure abort the test; we assert on rc
|
||||
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
||||
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
||||
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
||||
}
|
||||
|
||||
# ── tea path ────────────────────────────────────────────────────────────────────────
|
||||
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
||||
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
||||
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
||||
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
||||
|
||||
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
||||
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
||||
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
||||
|
||||
# 3. must use top-level `tea comment`, never `tea issue comment`
|
||||
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
||||
|
||||
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
||||
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||
[ -n "$c" ] || fail "comment carried no --login"
|
||||
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
||||
|
||||
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
||||
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
||||
# reached it -- replacing the whole fallback contract with an unconditional close still
|
||||
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
||||
# not merely that a command ran.
|
||||
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
||||
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
||||
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
||||
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
||||
|
||||
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
||||
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
||||
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
||||
|
||||
# 7. no login + NO comment => PATCH only, never a POST
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
||||
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
||||
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
||||
|
||||
echo "issue-close.sh fail-closed + single-principal regression passed"
|
||||
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
|
||||
MAX_STATE: Final = 4 * 1024 * 1024
|
||||
MAX_PENDING_TOKENS: Final = 256
|
||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||
MAX_LEASE_TTL_SECONDS: Final = 3600
|
||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||
STATE_VERSION: Final = 1
|
||||
READ_DEADLINE_SECONDS: Final = 1.0
|
||||
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
|
||||
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||
LEASE_VERIFIED: Final = "VERIFIED"
|
||||
READ_ONLY_TOOLS: Final = {
|
||||
"claude": frozenset({"Read", "Grep", "Glob"}),
|
||||
"pi": frozenset({"read", "ls"}),
|
||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||
}
|
||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -55,48 +53,6 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
||||
return value
|
||||
|
||||
|
||||
def _self_starttime() -> str | None:
|
||||
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
||||
|
||||
Read past the comm field's parens, since a process name may contain them.
|
||||
"""
|
||||
try:
|
||||
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
||||
return raw.rsplit(")", 1)[1].split()[19]
|
||||
except (OSError, IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
||||
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
||||
|
||||
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
||||
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
||||
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
||||
asserts.
|
||||
|
||||
Never raises: a launch must not be denied over bookkeeping. But it also never
|
||||
fails silently — a missing record is exactly the kind of gap that made the
|
||||
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
||||
"""
|
||||
try:
|
||||
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
||||
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
framed = {
|
||||
"seq": time.time_ns() // 1_000_000,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
**record,
|
||||
}
|
||||
path = directory / "events.ndjson"
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
with os.fdopen(descriptor, "w") as handle:
|
||||
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
@@ -138,9 +94,8 @@ def main(
|
||||
# silent pass and never folded into the generic registration-failure
|
||||
# branch.
|
||||
try:
|
||||
activation_capability = probe_activation_capability(source_environment)
|
||||
assert_activation_capability_matches(
|
||||
activation_capability,
|
||||
probe_activation_capability(source_environment),
|
||||
expected_activation_capability,
|
||||
)
|
||||
except VersionCouplingError as version_error:
|
||||
@@ -173,32 +128,6 @@ def main(
|
||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
||||
# config/provenance it knows; only this process knows the broker session id
|
||||
# and the activation capability it just asserted. os.execvpe preserves the
|
||||
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
||||
# record. Never fatal — bookkeeping must not deny a launch — but never
|
||||
# silent either.
|
||||
_append_launch_record(
|
||||
source_environment,
|
||||
{
|
||||
"kind": "lease.register",
|
||||
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
||||
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
||||
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
||||
# preserves it), which is a separate and still-useful fact.
|
||||
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
||||
"pid": os.getpid(),
|
||||
"runtime": arguments.runtime,
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"generation_file": str(generation_file),
|
||||
"anchor_starttime": _self_starttime(),
|
||||
"activation_capability": activation_capability,
|
||||
"command": Path(command[0]).name,
|
||||
},
|
||||
)
|
||||
|
||||
environment = dict(source_environment)
|
||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lease promotion client — the half the enforcement toolkit never shipped.
|
||||
|
||||
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
|
||||
promotion half has no production caller anywhere in the package: as of 0.0.48,
|
||||
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
|
||||
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
|
||||
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
|
||||
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
|
||||
gate nothing can satisfy.
|
||||
|
||||
THE PROTOCOL (``daemon.py:578-754``)
|
||||
------------------------------------
|
||||
1. ``begin_verification`` — broker revokes, mints a challenge, and returns the
|
||||
exact ``receipt`` text the MODEL must emit
|
||||
2. *the model emits that text verbatim as its ENTIRE latest message*
|
||||
3. the runtime adapter ships that message to the daemon-owned observer socket
|
||||
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
|
||||
5. ``promote_lease`` -> ``VERIFIED``
|
||||
|
||||
THIS MODULE IMPLEMENTS 1, 4 AND 5 — NEVER 2
|
||||
-------------------------------------------
|
||||
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
|
||||
``hmac.compare_digest`` against the exact minted string — explicitly "not a
|
||||
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
|
||||
live model that received the challenge in its context and echoed it exactly.
|
||||
|
||||
``receipt-observer-client.py`` will post ANY string as the latest assistant
|
||||
message. A promotion client that posted its own receipt would satisfy the broker
|
||||
while proving nothing — a gate-disabler indistinguishable from a working fix
|
||||
unless someone looks for it. **This module never posts a receipt.** Emitting it
|
||||
belongs to the runtime adapter, where a real model turn happens.
|
||||
|
||||
The construction binds the exact normative source bytes. ``h_source`` /
|
||||
``h_payload`` are derived by the framework's own
|
||||
``normative_fragments.build_payload`` rather than reimplemented: the broker
|
||||
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
|
||||
There must be exactly one implementation.
|
||||
|
||||
WHAT THE BINDING DOES *NOT* PROVE
|
||||
---------------------------------
|
||||
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
|
||||
**It does not mean that**, and writing it down that way is how the belief spread.
|
||||
The broker holds no reference copy of any normative source and never opens one;
|
||||
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
|
||||
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
|
||||
Both sides of that comparison originate here, so it detects corruption in
|
||||
transit and nothing else. What the binding actually asserts is "the client
|
||||
claims these bytes, self-consistently".
|
||||
|
||||
Making it mean the stronger thing requires the broker to re-read the on-disk
|
||||
sources itself, against a manifest the agent cannot rewrite — i.e. broker code
|
||||
attestation under its own uid. Until then, do not cite a VERIFIED lease as
|
||||
evidence of law integrity.
|
||||
|
||||
Usage
|
||||
-----
|
||||
lease_promote.py --begin # prints the receipt the MODEL must emit
|
||||
lease_promote.py --complete <challenge> # after the adapter observed it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
# Isolated (`python -I`) adapter invocations must still import co-located
|
||||
# framework modules; never depend on the caller's PYTHONPATH.
|
||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||
if _MODULE_DIRECTORY not in sys.path:
|
||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||
|
||||
from normative_fragments import NormativeFragment, build_payload # noqa: E402
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 3.0
|
||||
SCHEMA_VERSION: Final = 1
|
||||
MANIFEST_VERSION: Final = 1
|
||||
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
|
||||
DEFAULT_TTL_SECONDS: Final = 3600
|
||||
|
||||
# Normative sources whose exact bytes bind the lease, in binding order. Order is
|
||||
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
|
||||
# the derivation. Never fabricate a source that is not on disk.
|
||||
FRAGMENT_SOURCES: Final = (
|
||||
"CONSTITUTION.md",
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"STANDARDS.md",
|
||||
"TOOLS.md",
|
||||
)
|
||||
|
||||
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
|
||||
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
|
||||
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
|
||||
# missing one of these is broken, not minimal, so their absence is refused rather
|
||||
# than silently dropped from the binding.
|
||||
#
|
||||
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
|
||||
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
|
||||
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
|
||||
# only. Absence of those three is reported, not fatal.
|
||||
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
|
||||
|
||||
|
||||
class IncompleteBinding(RuntimeError):
|
||||
"""A source that must bind this lease could not be read.
|
||||
|
||||
**Never downgrade this to a skip.** The broker recomputes the hashes from the
|
||||
fragments it is sent, so an omitted fragment is internally consistent and
|
||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like
|
||||
a complete one, and nothing downstream can tell the difference. Dropping an
|
||||
unreadable source therefore does not degrade the binding, it forges a smaller
|
||||
one. Fail here, where the omission is still visible.
|
||||
"""
|
||||
|
||||
|
||||
def mosaic_home() -> Path:
|
||||
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
|
||||
|
||||
|
||||
def broker_socket() -> Path:
|
||||
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
|
||||
if value:
|
||||
return Path(value)
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if runtime_dir:
|
||||
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
|
||||
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
|
||||
|
||||
|
||||
def session_identity() -> tuple[str, int, str]:
|
||||
"""Session id, CURRENT generation, runtime.
|
||||
|
||||
The generation file wins over the env var, matching ``lease_generation.py``.
|
||||
Sending a generation HIGHER than the broker's would revoke this session's own
|
||||
authority (``daemon.py:342-344``), so this never guesses.
|
||||
"""
|
||||
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
|
||||
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
|
||||
if state_file:
|
||||
try:
|
||||
return session_id, int(Path(state_file).read_text().strip()), runtime
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
|
||||
|
||||
|
||||
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
|
||||
"""Assemble the wire construction and derive its hashes with the sole builder."""
|
||||
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
|
||||
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
|
||||
required = REQUIRED_SOURCES | {runtime_contract}
|
||||
wire_fragments: list[dict[str, str]] = []
|
||||
objects: list[NormativeFragment] = []
|
||||
absent: list[str] = []
|
||||
|
||||
for source_id in sources:
|
||||
try:
|
||||
content = (mosaic_home() / source_id).read_bytes()
|
||||
except FileNotFoundError:
|
||||
# Genuinely not on disk. Legitimate only for operator-owned sources.
|
||||
if source_id in required:
|
||||
raise IncompleteBinding(
|
||||
f"required normative source is absent: {source_id}"
|
||||
) from None
|
||||
absent.append(source_id)
|
||||
continue
|
||||
except OSError as exc:
|
||||
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
|
||||
# That is an anomaly for EVERY source, optional ones included: an
|
||||
# unreadable file is not an un-configured one, and treating it as
|
||||
# absent is what lets a permission change quietly shrink the law.
|
||||
raise IncompleteBinding(
|
||||
f"normative source is present but unreadable: {source_id} "
|
||||
f"({type(exc).__name__})"
|
||||
) from exc
|
||||
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
wire_fragments.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"expected_sha256": digest,
|
||||
}
|
||||
)
|
||||
objects.append(NormativeFragment(source_id, content, digest))
|
||||
|
||||
if not wire_fragments:
|
||||
raise IncompleteBinding("no normative sources found — refusing an empty binding")
|
||||
|
||||
# Absence is legitimate here but never invisible. The omission is already
|
||||
# baked into h_source (the framed source sequence differs), but nothing
|
||||
# compares h_source to an expected value, so this line is the only place a
|
||||
# human learns the binding was narrower than the full set.
|
||||
if absent:
|
||||
print(
|
||||
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
result = build_payload(
|
||||
manifest_version=MANIFEST_VERSION,
|
||||
generator_version=GENERATOR_VERSION,
|
||||
fragments=objects,
|
||||
)
|
||||
if result.injectionDecision != "ACCEPTED" or not result.promotion:
|
||||
raise RuntimeError(f"construction refused locally: {result.source_reason}")
|
||||
|
||||
return (
|
||||
{
|
||||
"manifest_version": MANIFEST_VERSION,
|
||||
"generator_version": GENERATOR_VERSION,
|
||||
"fragments": wire_fragments,
|
||||
},
|
||||
result,
|
||||
)
|
||||
|
||||
|
||||
def broker_request(payload: dict[str, object]) -> dict[str, object]:
|
||||
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError(
|
||||
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
|
||||
)
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(broker_socket()))
|
||||
connection.sendall(raw)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid broker reply")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid broker reply")
|
||||
return value
|
||||
|
||||
|
||||
def begin(
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
compaction_epoch: int = 0,
|
||||
request_epoch: int = 0,
|
||||
) -> dict[str, object]:
|
||||
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
|
||||
session_id, generation, runtime = session_identity()
|
||||
construction, derived = build_construction(runtime)
|
||||
return broker_request(
|
||||
{
|
||||
"action": "begin_verification",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"runtime": runtime,
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"binding": {
|
||||
"compaction_epoch": compaction_epoch,
|
||||
"request_epoch": request_epoch,
|
||||
"h_source": derived.h_source,
|
||||
"h_payload": derived.h_payload,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
},
|
||||
"construction": construction,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def complete(challenge: str) -> dict[str, object]:
|
||||
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
|
||||
shipped it to the observer socket."""
|
||||
session_id, generation, _ = session_identity()
|
||||
observed = broker_request(
|
||||
{
|
||||
"action": "observe_receipt",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"receipt_challenge": challenge,
|
||||
}
|
||||
)
|
||||
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
|
||||
return {"stage": "observe_receipt", **observed}
|
||||
promoted = broker_request(
|
||||
{
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"receipt_challenge": challenge,
|
||||
}
|
||||
)
|
||||
return {"stage": "promote_lease", **promoted}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--begin",
|
||||
action="store_true",
|
||||
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
|
||||
)
|
||||
group.add_argument(
|
||||
"--complete",
|
||||
metavar="CHALLENGE",
|
||||
help="observe the emitted receipt and promote the lease",
|
||||
)
|
||||
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if arguments.begin:
|
||||
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
|
||||
else:
|
||||
print(json.dumps(complete(arguments.complete), indent=2))
|
||||
except KeyError as exc:
|
||||
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
|
||||
return 2
|
||||
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,333 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final, TextIO
|
||||
|
||||
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||
if _MODULE_DIRECTORY not in sys.path:
|
||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||
|
||||
from receipt_challenge import receipt_for # noqa: E402
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
|
||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||
PROMOTION_PROMPT: Final = "/mosaic-promote"
|
||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||
LOCK_FILE: Final = "promotion.lock"
|
||||
EXPECTED_BEGIN_KEYS: Final = frozenset(
|
||||
{"ok", "state", "receipt_challenge", "receipt", "binding"}
|
||||
)
|
||||
EXPECTED_BINDING_KEYS: Final = frozenset(
|
||||
{
|
||||
"compaction_epoch",
|
||||
"request_epoch",
|
||||
"h_source",
|
||||
"h_payload",
|
||||
"runtime_generation",
|
||||
"schema_version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PromotionAlreadyInProgress(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate promoter JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def read_hook_input(stream: object) -> dict[str, object]:
|
||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||
raise ValueError("invalid UserPromptSubmit input")
|
||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid UserPromptSubmit input")
|
||||
return value
|
||||
|
||||
|
||||
def emit_context(stream: TextIO, message: str) -> None:
|
||||
json.dump(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": message,
|
||||
}
|
||||
},
|
||||
stream,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
if not runtime_dir.is_absolute():
|
||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||
raise ValueError("invalid lease session id")
|
||||
return runtime_dir, f"pending-{session_id}"
|
||||
|
||||
|
||||
def open_pending_directory(runtime_dir: Path) -> int:
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||
try:
|
||||
runtime_metadata = os.fstat(runtime_descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||
or runtime_metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||
):
|
||||
raise ValueError("unsafe XDG runtime directory")
|
||||
try:
|
||||
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
|
||||
except FileExistsError:
|
||||
pass
|
||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||
finally:
|
||||
os.close(runtime_descriptor)
|
||||
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion pending directory")
|
||||
return descriptor
|
||||
|
||||
|
||||
def acquire_lock(directory_descriptor: int) -> int:
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion lock file")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
os.close(descriptor)
|
||||
raise PromotionAlreadyInProgress() from error
|
||||
return descriptor
|
||||
|
||||
|
||||
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
|
||||
cutoff = current_time - PENDING_MAX_AGE_SECONDS
|
||||
removed = False
|
||||
with os.scandir(directory_descriptor) as entries:
|
||||
for candidate in entries:
|
||||
if not (
|
||||
candidate.name.startswith("pending-")
|
||||
or candidate.name.startswith(".pending-")
|
||||
):
|
||||
continue
|
||||
try:
|
||||
metadata = candidate.stat(follow_symlinks=False)
|
||||
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
|
||||
os.unlink(candidate.name, dir_fd=directory_descriptor)
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if removed:
|
||||
os.fsync(directory_descriptor)
|
||||
|
||||
|
||||
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
|
||||
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
|
||||
flags = (
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||
stream.write(challenge)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(
|
||||
temporary,
|
||||
name,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
os.fsync(directory_descriptor)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def parse_begin_reply(
|
||||
completed: subprocess.CompletedProcess[str],
|
||||
) -> tuple[str, dict[str, object] | None]:
|
||||
if completed.returncode != 0:
|
||||
return f"PROMOTER_EXIT_{completed.returncode}", None
|
||||
try:
|
||||
value = json.loads(
|
||||
completed.stdout,
|
||||
object_pairs_hook=reject_duplicate_json_keys,
|
||||
)
|
||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not isinstance(value, dict):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if value.get("ok") is False and set(value) == {"ok", "code"}:
|
||||
code = value.get("code")
|
||||
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
|
||||
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if value.get("state") != "PENDING_VERIFICATION":
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
challenge = value.get("receipt_challenge")
|
||||
receipt = value.get("receipt")
|
||||
binding = value.get("binding")
|
||||
if (
|
||||
not isinstance(challenge, str)
|
||||
or len(challenge) != 64
|
||||
or any(character not in "0123456789abcdef" for character in challenge)
|
||||
or not isinstance(receipt, str)
|
||||
or not isinstance(binding, dict)
|
||||
or set(binding) != EXPECTED_BINDING_KEYS
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
integer_fields = (
|
||||
"compaction_epoch",
|
||||
"request_epoch",
|
||||
"runtime_generation",
|
||||
"schema_version",
|
||||
)
|
||||
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not all(
|
||||
isinstance(binding.get(field), str)
|
||||
and len(binding[field]) == 64
|
||||
and all(character in "0123456789abcdef" for character in binding[field])
|
||||
for field in ("h_source", "h_payload")
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
if not secrets.compare_digest(
|
||||
receipt.encode("utf-8"),
|
||||
receipt_for(challenge, binding).encode("utf-8"),
|
||||
):
|
||||
return "INVALID_PROMOTER_REPLY", None
|
||||
return "", value
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stdin: object | None = None,
|
||||
stdout: TextIO | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
now: Callable[[], float] = time.time,
|
||||
) -> int:
|
||||
source_environment = os.environ if environ is None else environ
|
||||
input_stream = sys.stdin if stdin is None else stdin
|
||||
output_stream = sys.stdout if stdout is None else stdout
|
||||
error_stream = sys.stderr if stderr is None else stderr
|
||||
|
||||
try:
|
||||
hook_input = read_hook_input(input_stream)
|
||||
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
|
||||
return 0
|
||||
if hook_input.get("prompt") != PROMOTION_PROMPT:
|
||||
return 0
|
||||
|
||||
directory_descriptor: int | None = None
|
||||
lock_descriptor: int | None = None
|
||||
try:
|
||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||
directory_descriptor = open_pending_directory(runtime_dir)
|
||||
lock_descriptor = acquire_lock(directory_descriptor)
|
||||
sweep_stale_pending(directory_descriptor, now())
|
||||
completed = run(
|
||||
[sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(source_environment),
|
||||
timeout=PROMOTER_TIMEOUT_SECONDS,
|
||||
)
|
||||
code, reply = parse_begin_reply(completed)
|
||||
if code:
|
||||
if completed.stderr:
|
||||
print(completed.stderr.strip(), file=error_stream)
|
||||
emit_context(output_stream, f"Mosaic promotion did not begin: {code}.")
|
||||
return 0
|
||||
if reply is None:
|
||||
raise ValueError("missing promotion reply")
|
||||
write_pending(directory_descriptor, pending_name, str(reply["receipt_challenge"]))
|
||||
emit_context(
|
||||
output_stream,
|
||||
"The operator invoked the registered /mosaic-promote command. "
|
||||
"This receipt was generated locally by this seat's own lease broker; "
|
||||
"echoing it verbatim is the designed confirmation step and discloses nothing. "
|
||||
"Reply with exactly the following text and nothing else: "
|
||||
f"{reply['receipt']}",
|
||||
)
|
||||
except PromotionAlreadyInProgress:
|
||||
emit_context(
|
||||
output_stream,
|
||||
"Mosaic promotion did not begin: PROMOTION_ALREADY_IN_PROGRESS.",
|
||||
)
|
||||
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
|
||||
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
|
||||
emit_context(output_stream, "Mosaic promotion did not begin: PROMOTION_TRIGGER_FAILED.")
|
||||
finally:
|
||||
if lock_descriptor is not None:
|
||||
os.close(lock_descriptor)
|
||||
if directory_descriptor is not None:
|
||||
os.close(directory_descriptor)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,361 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claude Stop hook that completes a pending operator-triggered promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final, NamedTuple, TextIO
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
|
||||
LEASE_TTL_SECONDS: Final = 60 * 60
|
||||
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
|
||||
PENDING_DIRECTORY: Final = "mosaic-lease"
|
||||
LOCK_FILE: Final = "promotion.lock"
|
||||
RESULT_FILE: Final = "last-result.json"
|
||||
TERMINAL_FAILURE_CODES: Final = frozenset(
|
||||
{
|
||||
"RECEIPT_REPLAY",
|
||||
"RECEIPT_MISMATCH",
|
||||
"INVALID_LEASE_TRANSITION",
|
||||
"PROMOTION_TOKEN_INVALID",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PendingChallenge(NamedTuple):
|
||||
value: str
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate promoter JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
|
||||
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
|
||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
if not runtime_dir.is_absolute():
|
||||
raise ValueError("XDG_RUNTIME_DIR must be absolute")
|
||||
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
|
||||
raise ValueError("invalid lease session id")
|
||||
return runtime_dir, f"pending-{session_id}"
|
||||
|
||||
|
||||
def open_pending_directory(runtime_dir: Path) -> int | None:
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
try:
|
||||
runtime_descriptor = os.open(runtime_dir, directory_flags)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
runtime_metadata = os.fstat(runtime_descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(runtime_metadata.st_mode)
|
||||
or runtime_metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
|
||||
):
|
||||
raise ValueError("unsafe XDG runtime directory")
|
||||
try:
|
||||
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
finally:
|
||||
os.close(runtime_descriptor)
|
||||
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o700
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion pending directory")
|
||||
return descriptor
|
||||
|
||||
|
||||
def acquire_lock(directory_descriptor: int) -> int:
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise ValueError("unsafe promotion lock file")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
return descriptor
|
||||
|
||||
|
||||
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid()
|
||||
or stat.S_IMODE(metadata.st_mode) != 0o600
|
||||
or metadata.st_size <= 0
|
||||
or metadata.st_size > MAX_FRAME
|
||||
):
|
||||
raise ValueError("unsafe promotion pending file")
|
||||
raw = os.read(descriptor, MAX_FRAME + 1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError("oversized promotion challenge")
|
||||
challenge = raw.decode("utf-8")
|
||||
if (
|
||||
len(challenge) != 64
|
||||
or any(character not in "0123456789abcdef" for character in challenge)
|
||||
):
|
||||
raise ValueError("invalid promotion challenge")
|
||||
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
|
||||
|
||||
|
||||
def write_result(
|
||||
directory_descriptor: int,
|
||||
attempt_id: str,
|
||||
verified: bool,
|
||||
reason: str | None,
|
||||
session_id: str,
|
||||
wall_clock: float,
|
||||
) -> None:
|
||||
result = {
|
||||
"attempt_id": attempt_id,
|
||||
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
|
||||
"reason": reason,
|
||||
"session_id": session_id,
|
||||
"ts": wall_clock,
|
||||
"verified": verified,
|
||||
}
|
||||
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
|
||||
flags = (
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
|
||||
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(
|
||||
temporary,
|
||||
RESULT_FILE,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
os.fsync(directory_descriptor)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory_descriptor)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def delete_pending_if_unchanged(
|
||||
directory_descriptor: int,
|
||||
name: str,
|
||||
pending: PendingChallenge,
|
||||
error_stream: TextIO,
|
||||
) -> None:
|
||||
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
|
||||
try:
|
||||
os.rename(
|
||||
name,
|
||||
quarantine,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as error:
|
||||
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
|
||||
return
|
||||
|
||||
try:
|
||||
moved = os.stat(
|
||||
quarantine,
|
||||
dir_fd=directory_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
|
||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||
os.fsync(directory_descriptor)
|
||||
return
|
||||
|
||||
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
|
||||
try:
|
||||
os.link(
|
||||
quarantine,
|
||||
name,
|
||||
src_dir_fd=directory_descriptor,
|
||||
dst_dir_fd=directory_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileExistsError:
|
||||
print(
|
||||
f"Mosaic promotion preserved replacement as {quarantine}.",
|
||||
file=error_stream,
|
||||
)
|
||||
else:
|
||||
os.unlink(quarantine, dir_fd=directory_descriptor)
|
||||
os.fsync(directory_descriptor)
|
||||
except OSError as error:
|
||||
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
|
||||
|
||||
|
||||
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(
|
||||
completed.stdout,
|
||||
object_pairs_hook=reject_duplicate_json_keys,
|
||||
)
|
||||
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if set(value) == {"stage", "ok", "state"}:
|
||||
if (
|
||||
value.get("stage") == "promote_lease"
|
||||
and value.get("ok") is True
|
||||
and value.get("state") == "VERIFIED"
|
||||
):
|
||||
return value
|
||||
return None
|
||||
if set(value) == {"stage", "ok", "code"}:
|
||||
if (
|
||||
value.get("stage") in {"observe_receipt", "promote_lease"}
|
||||
and value.get("ok") is False
|
||||
and isinstance(value.get("code"), str)
|
||||
and value.get("code")
|
||||
):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
now: Callable[[], float] = time.time,
|
||||
) -> int:
|
||||
source_environment = os.environ if environ is None else environ
|
||||
error_stream = sys.stderr if stderr is None else stderr
|
||||
directory_descriptor: int | None = None
|
||||
lock_descriptor: int | None = None
|
||||
|
||||
try:
|
||||
runtime_dir, pending_name = session_pending_name(source_environment)
|
||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||
directory_descriptor = open_pending_directory(runtime_dir)
|
||||
if directory_descriptor is None:
|
||||
return 0
|
||||
try:
|
||||
lock_descriptor = acquire_lock(directory_descriptor)
|
||||
except (BlockingIOError, FileNotFoundError):
|
||||
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
|
||||
return 0
|
||||
pending = read_pending(directory_descriptor, pending_name)
|
||||
if pending is None:
|
||||
return 0
|
||||
completed = run(
|
||||
[
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-S",
|
||||
"-B",
|
||||
str(PROMOTER),
|
||||
"--complete",
|
||||
pending.value,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=dict(source_environment),
|
||||
timeout=PROMOTER_TIMEOUT_SECONDS,
|
||||
)
|
||||
reply = parse_reply(completed)
|
||||
if reply is not None and reply.get("ok") is True:
|
||||
write_result(directory_descriptor, pending.value, True, None, session_id, now())
|
||||
delete_pending_if_unchanged(
|
||||
directory_descriptor,
|
||||
pending_name,
|
||||
pending,
|
||||
error_stream,
|
||||
)
|
||||
print("Mosaic lease promotion completed.", file=error_stream)
|
||||
return 0
|
||||
|
||||
if reply is not None:
|
||||
code = str(reply["code"])
|
||||
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
|
||||
if code in TERMINAL_FAILURE_CODES:
|
||||
write_result(directory_descriptor, pending.value, False, code, session_id, now())
|
||||
delete_pending_if_unchanged(
|
||||
directory_descriptor,
|
||||
pending_name,
|
||||
pending,
|
||||
error_stream,
|
||||
)
|
||||
else:
|
||||
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
|
||||
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
|
||||
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
|
||||
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
|
||||
finally:
|
||||
if lock_descriptor is not None:
|
||||
os.close(lock_descriptor)
|
||||
if directory_descriptor is not None:
|
||||
os.close(directory_descriptor)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -22,23 +22,13 @@ from typing import Final
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
||||
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
|
||||
|
||||
|
||||
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
value: dict[str, object] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("duplicate observer JSON key")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def read_json(stream: object) -> dict[str, object]:
|
||||
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||
raise ValueError("invalid observer input")
|
||||
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid observer input")
|
||||
return value
|
||||
@@ -110,9 +100,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid observer reply")
|
||||
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid observer reply")
|
||||
return value
|
||||
@@ -129,11 +119,6 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
||||
if arguments.runtime == "claude":
|
||||
if not arguments.latest_entry:
|
||||
raise ValueError("Claude observer requires --latest-entry")
|
||||
if "last_assistant_message" in source:
|
||||
message = source["last_assistant_message"]
|
||||
if not isinstance(message, str):
|
||||
raise ValueError("invalid Claude observer input")
|
||||
else:
|
||||
message = claude_latest_entry(source)
|
||||
else:
|
||||
if arguments.latest_entry:
|
||||
@@ -148,18 +133,10 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
|
||||
"runtime": arguments.runtime,
|
||||
"latest_assistant_message": message,
|
||||
})
|
||||
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
|
||||
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||
return 2
|
||||
if set(reply) == {"ok"} and reply.get("ok") is True:
|
||||
return 0
|
||||
if (
|
||||
set(reply) == {"ok", "code"}
|
||||
and reply.get("ok") is False
|
||||
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
|
||||
):
|
||||
return 0
|
||||
return 2
|
||||
return 0 if reply == {"ok": True} else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
|
||||
"""Require the exact one current-cycle receipt, not a transcript substring."""
|
||||
|
||||
expected = receipt_for(challenge, binding)
|
||||
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
|
||||
return hmac.compare_digest(message, expected)
|
||||
|
||||
|
||||
def latest_assistant_digest(message: str) -> str:
|
||||
|
||||
@@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||
|
||||
# --- single-suite directories: unmeasured in CI ---
|
||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
||||
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
||||
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
||||
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
||||
|
||||
@@ -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/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/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:*",
|
||||
|
||||
@@ -15,7 +15,6 @@ import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerPromoteCommand } from './commands/promote.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
import { registerRestoreCommand } from './commands/restore.js';
|
||||
@@ -371,7 +370,6 @@ registerInteractionCommand(program);
|
||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerFleetCommand(program);
|
||||
registerPromoteCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
|
||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||
expect(agent).toBeDefined();
|
||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||
expect(values).toEqual({
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||
|
||||
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_GIT_IDENTITY=coder0',
|
||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
|
||||
'utf8',
|
||||
);
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||
});
|
||||
|
||||
|
||||
@@ -484,6 +484,7 @@ function generateAgentEnvValues(
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
appendFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
@@ -44,163 +42,6 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||
pi: 'Pi',
|
||||
};
|
||||
|
||||
// ─── Harness home isolation ──────────────────────────────────────────────────
|
||||
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
||||
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
||||
// keeps its own config AND its own auth, and stays a working break-glass no
|
||||
// matter what mosaic does to its own tree.
|
||||
//
|
||||
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
||||
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
||||
// neither overwrite nor prune them. Overwrite-mode install still would.
|
||||
//
|
||||
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
||||
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
||||
// three: it also relocates XDG lookups for anything opencode spawns.
|
||||
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
||||
claude: 'CLAUDE_CONFIG_DIR',
|
||||
pi: 'PI_CODING_AGENT_DIR',
|
||||
codex: 'CODEX_HOME',
|
||||
opencode: 'XDG_CONFIG_HOME',
|
||||
};
|
||||
|
||||
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||
function harnessHome(runtime: RuntimeName): string {
|
||||
return join(MOSAIC_HOME, `.${runtime}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
||||
* created on demand so a first launch does not fail on a missing path.
|
||||
*/
|
||||
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
||||
const key = HARNESS_HOME_ENV[runtime];
|
||||
if (!key) return {};
|
||||
const home = harnessHome(runtime);
|
||||
mkdirSync(home, { recursive: true });
|
||||
return { [key]: home };
|
||||
}
|
||||
|
||||
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
||||
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
||||
// actually launched with, written before exec. No model involvement, no opt-out.
|
||||
//
|
||||
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
||||
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
||||
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
||||
// disproved only by the parent process's argv and only because the parent had
|
||||
// not yet exited. A record written before exec is the only place this survives.
|
||||
//
|
||||
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
||||
// operator-classified in framework-manifest.txt and already covered by
|
||||
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
||||
// it.
|
||||
//
|
||||
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
||||
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
||||
// launch-runtime.py appends the matching `lease.register` event.
|
||||
//
|
||||
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
||||
// oversized argv values (the composed system prompt) become a digest + length.
|
||||
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
||||
|
||||
const CLI_VERSION: string | null = (() => {
|
||||
try {
|
||||
// Resolved RELATIVELY: the package `exports` map does not expose
|
||||
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
||||
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
||||
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
interface NormativeFragmentDigest {
|
||||
source_id: string;
|
||||
sha256: string | null;
|
||||
bytes: number | null;
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
function sha256Of(value: string | Buffer): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the normative sources injected into the agent. This is "what the agent
|
||||
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
||||
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||
* a matter of judgement.
|
||||
*/
|
||||
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
||||
const candidates: Array<[string, string]> = [
|
||||
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
||||
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
||||
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
||||
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
||||
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
||||
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
||||
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
||||
];
|
||||
return candidates.map(([sourceId, path]) => {
|
||||
try {
|
||||
const bytes = readFileSync(path);
|
||||
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
||||
} catch {
|
||||
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** argv with oversized values replaced by a digest, so the record stays small
|
||||
* and never inlines injected content verbatim. */
|
||||
function redactArgv(argv: string[]): string[] {
|
||||
return argv.map((a) =>
|
||||
typeof a === 'string' && a.length > 256
|
||||
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
||||
: a,
|
||||
);
|
||||
}
|
||||
|
||||
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||
try {
|
||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||
// Correlation id for the lease.register half. Set into process.env so it
|
||||
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||
const record = {
|
||||
seq: Date.now(),
|
||||
kind: 'session.launch',
|
||||
launch_id: launchId,
|
||||
ts: new Date().toISOString(),
|
||||
host: hostname(),
|
||||
pid: process.pid,
|
||||
runtime,
|
||||
mode: yolo ? 'yolo' : 'normal',
|
||||
cwd: process.cwd(),
|
||||
cli_version: CLI_VERSION,
|
||||
config_home: harnessHome(runtime),
|
||||
config_home_isolated: true,
|
||||
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||
argv: redactArgv(cliArgs),
|
||||
normative_fragments: normativeFragmentDigests(runtime),
|
||||
// names only — values are never recorded
|
||||
mosaic_env_present: Object.keys(process.env)
|
||||
.filter((k) => k.startsWith('MOSAIC_'))
|
||||
.sort(),
|
||||
};
|
||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (err) {
|
||||
// Never block a launch on bookkeeping — but never fail silently either.
|
||||
console.error(
|
||||
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||
|
||||
function checkMosaicHome(): void {
|
||||
@@ -264,11 +105,11 @@ interface SettingsAudit {
|
||||
|
||||
function auditClaudeSettings(): SettingsAudit {
|
||||
const warnings: string[] = [];
|
||||
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
if (!settings) {
|
||||
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
||||
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
@@ -720,9 +561,7 @@ function skillRealPath(dir: string): string {
|
||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||
* skills dir and the project-local one relative to the launch cwd. */
|
||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
||||
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
||||
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
||||
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
||||
}
|
||||
|
||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||
@@ -925,13 +764,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
recordLaunch('claude', cliArgs, yolo);
|
||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex': {
|
||||
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
@@ -939,17 +777,14 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
recordLaunch('codex', cliArgs, yolo);
|
||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||
execRuntime('codex', cliArgs);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||
recordLaunch('opencode', args, yolo);
|
||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||
execRuntime('opencode', args);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -964,7 +799,6 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
recordLaunch('pi', cliArgs, yolo);
|
||||
execLeaseGatedRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
@@ -1001,7 +835,6 @@ function execLeaseGatedRuntime(
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
{
|
||||
...baseEnv,
|
||||
...harnessEnv(runtime),
|
||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||
},
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FleetRoster } from './fleet.js';
|
||||
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
|
||||
import {
|
||||
promoteSeat,
|
||||
registerPromoteCommand,
|
||||
type PromotionBreadcrumbStore,
|
||||
type PromotionResult,
|
||||
type PromotionTransport,
|
||||
} from './promote.js';
|
||||
|
||||
const attemptId = 'a'.repeat(64);
|
||||
const target = {
|
||||
bundle: 'local',
|
||||
seat: 'claude-seat',
|
||||
sessionId: 'b'.repeat(64),
|
||||
};
|
||||
|
||||
function transport(): PromotionTransport {
|
||||
return {
|
||||
resolve: vi.fn(async () => target),
|
||||
sendPromotion: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('mosaic promote', () => {
|
||||
it('accepts only a fresh result correlated to this attempt', async () => {
|
||||
const promotionTransport = transport();
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||
readResult: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
attempt_id: 'c'.repeat(64),
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: 1_001,
|
||||
verified: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
attempt_id: attemptId,
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: 1_001,
|
||||
verified: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await promoteSeat('claude-seat', {
|
||||
clock: () => 1_000,
|
||||
sleep: async () => {},
|
||||
store,
|
||||
timeoutMs: 1,
|
||||
transport: promotionTransport,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bundle: 'local',
|
||||
expiresAtWallclock: 4_600,
|
||||
reason: null,
|
||||
seat: 'claude-seat',
|
||||
sessionId: 'b'.repeat(64),
|
||||
status: 'VERIFIED',
|
||||
});
|
||||
expect(promotionTransport.sendPromotion).toHaveBeenCalledWith(target);
|
||||
expect(store.readResult).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts a fresh result for this session when completion consumed the pending nonce', async () => {
|
||||
const promotionTransport = transport();
|
||||
let now = 1_000;
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => ({
|
||||
attempt_id: attemptId,
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: 1_001,
|
||||
verified: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await promoteSeat('claude-seat', {
|
||||
clock: () => now,
|
||||
sleep: async () => {
|
||||
now += 10;
|
||||
},
|
||||
store,
|
||||
timeoutMs: 10,
|
||||
transport: promotionTransport,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('VERIFIED');
|
||||
});
|
||||
|
||||
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
|
||||
vi.useFakeTimers();
|
||||
const roster: FleetRoster = {
|
||||
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||
defaults: { workingDirectory: '~/src' },
|
||||
runtimes: {},
|
||||
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||
transport: 'tmux',
|
||||
version: 1,
|
||||
};
|
||||
const promotionTransport = new TmuxPromotionTransport({
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner: async () => new Promise(() => {}),
|
||||
});
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
try {
|
||||
let observedResult: PromotionResult | undefined;
|
||||
void promoteSeat('claude-seat', {
|
||||
store,
|
||||
transport: promotionTransport,
|
||||
}).then((result) => {
|
||||
observedResult = result;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(observedResult).toMatchObject({
|
||||
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
|
||||
seat: 'claude-seat',
|
||||
status: 'UNVERIFIED',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
|
||||
const promotionTransport = transport();
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||
readResult: vi.fn(async () => ({
|
||||
attempt_id: attemptId,
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: Number.MAX_SAFE_INTEGER,
|
||||
verified: true,
|
||||
})),
|
||||
};
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerPromoteCommand(program, { store, transport: promotionTransport });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
|
||||
expect(output).toHaveBeenCalledWith(
|
||||
`VERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=1970-01-01T01:16:40.000Z`,
|
||||
);
|
||||
expect(process.exitCode).not.toBe(1);
|
||||
} finally {
|
||||
output.mockRestore();
|
||||
process.exitCode = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
|
||||
const promotionTransport: PromotionTransport = {
|
||||
resolve: vi.fn(async () => target),
|
||||
sendPromotion: vi.fn(async () => {
|
||||
throw new Error('tmux unavailable');
|
||||
}),
|
||||
};
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => null),
|
||||
};
|
||||
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const program = new Command().exitOverride();
|
||||
registerPromoteCommand(program, { store, transport: promotionTransport });
|
||||
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
|
||||
expect(output).toHaveBeenCalledWith(
|
||||
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
output.mockRestore();
|
||||
process.exitCode = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a stale result even when its nonce matches', async () => {
|
||||
const promotionTransport = transport();
|
||||
let now = 1_000;
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
|
||||
readResult: vi.fn(async () => ({
|
||||
attempt_id: attemptId,
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: 1_000,
|
||||
verified: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await promoteSeat('claude-seat', {
|
||||
clock: () => now,
|
||||
sleep: async () => {
|
||||
now += 10;
|
||||
},
|
||||
store,
|
||||
timeoutMs: 10,
|
||||
transport: promotionTransport,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bundle: 'local',
|
||||
expiresAtWallclock: null,
|
||||
reason: 'PROMOTION_TIMEOUT',
|
||||
seat: 'claude-seat',
|
||||
sessionId: 'b'.repeat(64),
|
||||
status: 'UNVERIFIED',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not accept a result for a pending attempt that existed before send', async () => {
|
||||
const promotionTransport = transport();
|
||||
let now = 1_000;
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => attemptId),
|
||||
readResult: vi.fn(async () => ({
|
||||
attempt_id: attemptId,
|
||||
expires_at_wallclock: 4_600,
|
||||
reason: null,
|
||||
session_id: target.sessionId,
|
||||
ts: 1_001,
|
||||
verified: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await promoteSeat('claude-seat', {
|
||||
clock: () => now,
|
||||
sleep: async () => {
|
||||
now += 10;
|
||||
},
|
||||
store,
|
||||
timeoutMs: 10,
|
||||
transport: promotionTransport,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('UNVERIFIED');
|
||||
expect(store.readResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns UNVERIFIED after a bounded timeout without reading stdin', async () => {
|
||||
const promotionTransport = transport();
|
||||
let now = 1_000;
|
||||
const store: PromotionBreadcrumbStore = {
|
||||
readAttemptId: vi.fn(async () => null),
|
||||
readResult: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const result = await promoteSeat('claude-seat', {
|
||||
clock: () => now,
|
||||
sleep: async () => {
|
||||
now += 10;
|
||||
},
|
||||
store,
|
||||
timeoutMs: 10,
|
||||
transport: promotionTransport,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bundle: 'local',
|
||||
expiresAtWallclock: null,
|
||||
reason: 'PROMOTION_TIMEOUT',
|
||||
seat: 'claude-seat',
|
||||
sessionId: 'b'.repeat(64),
|
||||
status: 'UNVERIFIED',
|
||||
});
|
||||
expect(promotionTransport.sendPromotion).toHaveBeenCalledOnce();
|
||||
expect(store.readResult).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
TmuxPromotionTransport,
|
||||
type PromotionTarget,
|
||||
type PromotionTransport,
|
||||
} from '../fleet/promotion-transport.js';
|
||||
|
||||
export type { PromotionTransport } from '../fleet/promotion-transport.js';
|
||||
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
|
||||
|
||||
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 250;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const SUBPROCESS_TIMEOUT_MS = 4_500;
|
||||
const PENDING_DIRECTORY = 'mosaic-lease';
|
||||
const RESULT_FILE = 'last-result.json';
|
||||
|
||||
export interface PromotionBreadcrumb {
|
||||
attempt_id: string;
|
||||
expires_at_wallclock: number | null;
|
||||
reason: string | null;
|
||||
session_id: string;
|
||||
ts: number;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface PromotionBreadcrumbStore {
|
||||
readAttemptId(sessionId: string): Promise<string | null>;
|
||||
readResult(): Promise<PromotionBreadcrumb | null>;
|
||||
}
|
||||
|
||||
export interface PromotionResult {
|
||||
bundle: string;
|
||||
expiresAtWallclock: number | null;
|
||||
reason: string | null;
|
||||
seat: string;
|
||||
sessionId: string;
|
||||
status: 'VERIFIED' | 'UNVERIFIED';
|
||||
}
|
||||
|
||||
export interface PromoteSeatOptions {
|
||||
clock?: () => number;
|
||||
pollIntervalMs?: number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
store: PromotionBreadcrumbStore;
|
||||
timeoutMs?: number;
|
||||
transport: PromotionTransport;
|
||||
}
|
||||
|
||||
export interface PromoteCommandDeps {
|
||||
mosaicHome?: string;
|
||||
runner?: CommandRunner;
|
||||
store?: PromotionBreadcrumbStore;
|
||||
transport?: PromotionTransport;
|
||||
}
|
||||
|
||||
/** Private, local result store shared with the in-seat completion hook. */
|
||||
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
|
||||
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
|
||||
|
||||
async readAttemptId(sessionId: string): Promise<string | null> {
|
||||
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
|
||||
const content = await readPrivateFile(
|
||||
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
|
||||
);
|
||||
const attemptId = content?.trim();
|
||||
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
|
||||
}
|
||||
|
||||
async readResult(): Promise<PromotionBreadcrumb | null> {
|
||||
const content = await readPrivateFile(
|
||||
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
|
||||
);
|
||||
if (content === null) return null;
|
||||
try {
|
||||
return parseBreadcrumb(JSON.parse(content) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drives one bounded, non-interactive in-seat promotion attempt. */
|
||||
export async function promoteSeat(
|
||||
seat: string,
|
||||
options: PromoteSeatOptions,
|
||||
): Promise<PromotionResult> {
|
||||
const clock = options.clock ?? wallClockSeconds;
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
||||
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
|
||||
let target: PromotionTarget;
|
||||
try {
|
||||
target = await options.transport.resolve(seat);
|
||||
} catch (error: unknown) {
|
||||
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
|
||||
}
|
||||
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
|
||||
const preSendTimestamp = clock();
|
||||
try {
|
||||
await options.transport.sendPromotion(target);
|
||||
} catch (error: unknown) {
|
||||
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
const deadline = preSendTimestamp + timeoutMs / 1_000;
|
||||
let attemptId: string | null = null;
|
||||
while (true) {
|
||||
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
|
||||
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
|
||||
attemptId = currentAttemptId;
|
||||
}
|
||||
if (attemptId !== null || previousAttemptId === null) {
|
||||
// Completion can consume a first attempt's nonce before this poll observes it.
|
||||
// In that branch, correlation degrades to session_id + fresh timestamp, which
|
||||
// is acceptable for this 0600, same-UID local trust boundary.
|
||||
const breadcrumb = await options.store.readResult();
|
||||
if (
|
||||
breadcrumb !== null &&
|
||||
breadcrumb.session_id === target.sessionId &&
|
||||
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
|
||||
breadcrumb.ts > preSendTimestamp
|
||||
) {
|
||||
return {
|
||||
bundle: target.bundle,
|
||||
expiresAtWallclock: breadcrumb.expires_at_wallclock,
|
||||
reason: breadcrumb.reason,
|
||||
seat: target.seat,
|
||||
sessionId: target.sessionId,
|
||||
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
|
||||
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
|
||||
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
|
||||
const transport =
|
||||
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
|
||||
const store = deps.store ?? new FilePromotionBreadcrumbStore();
|
||||
|
||||
program
|
||||
.command('promote <seat>')
|
||||
.description('Promote a Claude fleet seat and report the correlated lease result')
|
||||
.option(
|
||||
'--timeout <ms>',
|
||||
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
|
||||
)
|
||||
.action(async (seat: string, opts: { timeout?: string }) => {
|
||||
const result = await promoteSeat(seat, {
|
||||
store,
|
||||
timeoutMs: parseOptionTimeout(opts.timeout),
|
||||
transport,
|
||||
});
|
||||
const expiry =
|
||||
result.expiresAtWallclock === null
|
||||
? 'none'
|
||||
: new Date(result.expiresAtWallclock * 1_000).toISOString();
|
||||
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
|
||||
console.log(
|
||||
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
|
||||
);
|
||||
if (result.status === 'UNVERIFIED') process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
function defaultRuntimeDirectory(): string {
|
||||
const configured = process.env['XDG_RUNTIME_DIR'];
|
||||
if (configured) return configured;
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
return `/run/user/${uid}`;
|
||||
}
|
||||
|
||||
async function readPrivateFile(path: string): Promise<string | null> {
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const metadata = await handle.stat();
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
|
||||
(metadata.mode & 0o077) !== 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return handle.readFile({ encoding: 'utf8' });
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
|
||||
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
|
||||
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
|
||||
if (
|
||||
typeof attempt_id !== 'string' ||
|
||||
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
|
||||
typeof verified !== 'boolean' ||
|
||||
typeof session_id !== 'string' ||
|
||||
!ATTEMPT_ID_PATTERN.test(session_id) ||
|
||||
typeof ts !== 'number' ||
|
||||
!Number.isFinite(ts) ||
|
||||
(expires_at_wallclock !== null &&
|
||||
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
|
||||
(reason !== null && typeof reason !== 'string')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeTimeout(value: number | undefined): number {
|
||||
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function normalizePollInterval(value: number | undefined): number {
|
||||
return value !== undefined && Number.isFinite(value)
|
||||
? Math.max(1, value)
|
||||
: DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function parseOptionTimeout(value: string | undefined): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
|
||||
return {
|
||||
bundle: 'unresolved',
|
||||
expiresAtWallclock: null,
|
||||
reason,
|
||||
seat,
|
||||
sessionId: 'unresolved',
|
||||
status: 'UNVERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
function unverified(target: PromotionTarget, reason: string): PromotionResult {
|
||||
return {
|
||||
bundle: target.bundle,
|
||||
expiresAtWallclock: null,
|
||||
reason,
|
||||
seat: target.seat,
|
||||
sessionId: target.sessionId,
|
||||
status: 'UNVERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function wallClockSeconds(): number {
|
||||
return Date.now() / 1_000;
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
): Promise<{
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish({
|
||||
exitCode: 124,
|
||||
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
|
||||
stdout,
|
||||
});
|
||||
}, SUBPROCESS_TIMEOUT_MS);
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
finish({ exitCode: 127, stderr: error.message, stdout });
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
finish({ exitCode: code ?? 1, stderr, stdout });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -358,6 +358,7 @@ function generatedValues(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
||||
|
||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||
'DATA.DOTENV.FLEET_LAUNCH':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||
'DATA.JSON.MUTATION_RESULT':
|
||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||
'DATA.DOTENV.GENERATED_ENV':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.YAML.ROSTER_FIELDS':
|
||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||
};
|
||||
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
|
||||
);
|
||||
expect(
|
||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||
).toHaveLength(858);
|
||||
expect(surfaces).toHaveLength(882);
|
||||
).toHaveLength(863);
|
||||
expect(surfaces).toHaveLength(887);
|
||||
|
||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||
const auxiliary: CodeSurface = {
|
||||
|
||||
@@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
const generatedValues = {
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
|
||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_GIT_IDENTITY=coder0',
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=pi',
|
||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
|
||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsafe-git-identity', 'other/identity'],
|
||||
['git-identity-mismatch', 'reviewer0'],
|
||||
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_GIT_IDENTITY: identity,
|
||||
});
|
||||
}).toThrow(
|
||||
expect.objectContaining({
|
||||
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
|
||||
@@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error {
|
||||
|
||||
export const GENERATED_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_GIT_IDENTITY',
|
||||
'MOSAIC_AGENT_CLASS',
|
||||
'MOSAIC_AGENT_RUNTIME',
|
||||
'MOSAIC_AGENT_MODEL',
|
||||
@@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
}
|
||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||
@@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
|
||||
if (!AGENT_NAME.test(name))
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||
if (!AGENT_NAME.test(gitIdentity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (gitIdentity !== name) {
|
||||
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (!POLICY_NAME.test(className)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
|
||||
import { TmuxPromotionTransport } from './promotion-transport.js';
|
||||
|
||||
const sessionId = 'a'.repeat(64);
|
||||
const roster: FleetRoster = {
|
||||
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||
defaults: { workingDirectory: '~/src' },
|
||||
runtimes: {},
|
||||
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||
transport: 'tmux',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
|
||||
return { exitCode, stderr, stdout };
|
||||
}
|
||||
|
||||
describe('TmuxPromotionTransport', () => {
|
||||
it('resolves the exact roster seat and sends the registered command literally', async () => {
|
||||
const runner = vi
|
||||
.fn<CommandRunner>()
|
||||
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
|
||||
.mockResolvedValueOnce(result())
|
||||
.mockResolvedValueOnce(result());
|
||||
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
const target = await transport.resolve('claude-seat');
|
||||
await transport.sendPromotion(target);
|
||||
|
||||
expect(target).toEqual({
|
||||
bundle: 'mosaic-fleet',
|
||||
seat: 'claude-seat',
|
||||
sessionId,
|
||||
});
|
||||
expect(environmentReader).toHaveBeenCalledWith(1234);
|
||||
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'send-keys',
|
||||
'-t',
|
||||
'=claude-seat:0.0',
|
||||
'-l',
|
||||
'/mosaic-promote',
|
||||
]);
|
||||
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'send-keys',
|
||||
'-t',
|
||||
'=claude-seat:0.0',
|
||||
'Enter',
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
|
||||
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
|
||||
// transport must WALK the subtree. This test exercises the real walk (no
|
||||
// full mock of the resolution) — the seam the original unit test hid.
|
||||
it('walks the pane subtree to the claude child that carries the lease id', async () => {
|
||||
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
|
||||
const environmentReader = vi.fn(async (pid: number) =>
|
||||
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
|
||||
);
|
||||
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
childrenReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
const target = await transport.resolve('claude-seat');
|
||||
|
||||
expect(target.sessionId).toBe(sessionId);
|
||||
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
|
||||
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
|
||||
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
|
||||
});
|
||||
|
||||
it('fails closed when no process in the pane subtree carries a lease id', async () => {
|
||||
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||
const environmentReader = vi.fn(async () => `FOO=bar\0`);
|
||||
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
childrenReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
|
||||
expect(childrenReader).toHaveBeenCalledWith(1234);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
buildTmuxListPanesCommand,
|
||||
getRosterAgent,
|
||||
parseTmuxListPanes,
|
||||
resolveFleetPaths,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
type FleetRoster,
|
||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||
socketArgs,
|
||||
} from '../commands/fleet.js';
|
||||
import { loadFleetRoster } from './fleet-roster-v1.js';
|
||||
|
||||
const PROMOTION_COMMAND = '/mosaic-promote';
|
||||
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface PromotionTarget {
|
||||
bundle: string;
|
||||
seat: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface PromotionTransport {
|
||||
resolve(seat: string): Promise<PromotionTarget>;
|
||||
sendPromotion(target: PromotionTarget): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TmuxPromotionTransportOptions {
|
||||
environmentReader?: (pid: number) => Promise<string>;
|
||||
childrenReader?: (pid: number) => Promise<number[]>;
|
||||
mosaicHome: string;
|
||||
rosterLoader?: () => Promise<FleetRoster>;
|
||||
runner: CommandRunner;
|
||||
}
|
||||
|
||||
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
|
||||
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
|
||||
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
|
||||
// pid. Bound the descendant search so a hung/large process tree can't stall it.
|
||||
const MAX_SUBTREE_PIDS = 128;
|
||||
|
||||
/** Local, roster-bound transport for the in-seat promotion command. */
|
||||
export class TmuxPromotionTransport implements PromotionTransport {
|
||||
private readonly environmentReader: (pid: number) => Promise<string>;
|
||||
private readonly childrenReader: (pid: number) => Promise<number[]>;
|
||||
private readonly rosterLoader: () => Promise<FleetRoster>;
|
||||
|
||||
constructor(private readonly options: TmuxPromotionTransportOptions) {
|
||||
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
|
||||
this.childrenReader = options.childrenReader ?? readChildPids;
|
||||
this.rosterLoader =
|
||||
options.rosterLoader ??
|
||||
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
|
||||
}
|
||||
|
||||
async resolve(seat: string): Promise<PromotionTarget> {
|
||||
const roster = await this.rosterLoader();
|
||||
const agent = getRosterAgent(roster, seat);
|
||||
if (agent.runtime !== 'claude') {
|
||||
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
|
||||
}
|
||||
const paneResult = await this.run(
|
||||
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
|
||||
);
|
||||
if (paneResult.exitCode !== 0) {
|
||||
throw new Error(`Promotion seat is unavailable: ${seat}.`);
|
||||
}
|
||||
const pane = parseTmuxListPanes(paneResult.stdout);
|
||||
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
|
||||
if (
|
||||
pane.dead ||
|
||||
pane.pid === null ||
|
||||
pane.command === null ||
|
||||
allowedCommands === undefined ||
|
||||
!allowedCommands.includes(pane.command)
|
||||
) {
|
||||
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
|
||||
}
|
||||
const sessionId = await this.resolveLeaseSessionId(pane.pid);
|
||||
if (sessionId === null) {
|
||||
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
|
||||
}
|
||||
return {
|
||||
bundle: roster.tmux.socketName || 'default',
|
||||
seat: agent.name,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the lease session id in the pane's process subtree. The pane's root pid
|
||||
* is node(mosaic), which has no lease env; the id lives on the claude child.
|
||||
* BFS from the root, bounded, returning the first descendant that carries a
|
||||
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
|
||||
*/
|
||||
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
|
||||
const queue: number[] = [rootPid];
|
||||
const seen = new Set<number>();
|
||||
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
|
||||
const pid = queue.shift()!;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
let sessionId: string | null = null;
|
||||
try {
|
||||
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
|
||||
} catch {
|
||||
sessionId = null;
|
||||
}
|
||||
if (sessionId !== null) return sessionId;
|
||||
let children: number[] = [];
|
||||
try {
|
||||
children = await this.childrenReader(pid);
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
for (const child of children) {
|
||||
if (!seen.has(child)) queue.push(child);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendPromotion(target: PromotionTarget): Promise<void> {
|
||||
const targetPane = `=${target.seat}:0.0`;
|
||||
const socketName = target.bundle === 'default' ? '' : target.bundle;
|
||||
// Registered Claude commands must arrive as their exact literal text; the
|
||||
// fleet agent sender prepends an identity envelope, so it cannot carry this
|
||||
// command without preventing the UserPromptSubmit matcher from recognizing it.
|
||||
await this.runPromotionCommand([
|
||||
'tmux',
|
||||
...socketArgs(socketName),
|
||||
'send-keys',
|
||||
'-t',
|
||||
targetPane,
|
||||
'-l',
|
||||
PROMOTION_COMMAND,
|
||||
]);
|
||||
await this.runPromotionCommand([
|
||||
'tmux',
|
||||
...socketArgs(socketName),
|
||||
'send-keys',
|
||||
'-t',
|
||||
targetPane,
|
||||
'Enter',
|
||||
]);
|
||||
}
|
||||
|
||||
private async runPromotionCommand(command: string[]): Promise<void> {
|
||||
const result = await this.run(command);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Promotion command delivery failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private async run(command: string[]): Promise<CommandResult> {
|
||||
const [executable, ...args] = command;
|
||||
if (executable === undefined) {
|
||||
throw new Error('Promotion transport command is empty.');
|
||||
}
|
||||
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
|
||||
}, timeoutMs);
|
||||
void operation.then(
|
||||
(value) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readPaneEnvironment(pid: number): Promise<string> {
|
||||
return readFile(`/proc/${pid}/environ`, 'utf8');
|
||||
}
|
||||
|
||||
async function readChildPids(pid: number): Promise<number[]> {
|
||||
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
|
||||
try {
|
||||
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseLeaseSessionId(environment: string): string | null {
|
||||
const value = environment
|
||||
.split('\0')
|
||||
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
|
||||
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
|
||||
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
@@ -1405,6 +1405,7 @@ function generatedValues(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Invariant R: a read-only carve-out can neither disappear nor be shadowed.
|
||||
|
||||
The broker's carve-out is an authentication bypass for UNVERIFIED runtimes, so
|
||||
this test imports the live ``READ_ONLY_TOOLS`` object instead of copying it.
|
||||
Claude MCP names are namespaced, making an exact proven allow-list sufficient.
|
||||
Pi extensions are unnamespaced and may override built-ins, so the Pi half boots
|
||||
the installed runtime and requires every carve-out winner to retain built-in
|
||||
provenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
||||
LEASE_BROKER = FRAMEWORK / "tools/lease-broker"
|
||||
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||
sys.path.insert(0, str(LEASE_BROKER))
|
||||
daemon = importlib.import_module("daemon")
|
||||
READ_ONLY_TOOLS = daemon.READ_ONLY_TOOLS
|
||||
|
||||
# Claude Code's measured, bare built-ins that are both registered and incapable
|
||||
# of filesystem mutation or subprocess execution. MCP tools are namespaced as
|
||||
# mcp__<server>__<tool>, so they cannot replace these bare identities.
|
||||
CLAUDE_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"Read", "Grep", "Glob"})
|
||||
|
||||
# W-B measured Pi 0.84.1 through getAllTools(), observed every tool_call name,
|
||||
# and cross-checked dist/core/tools/index.js:18. Keep every measured built-in
|
||||
# here so a runtime registry change forces the security classification to be
|
||||
# revisited even when a built-in is deliberately excluded from the carve-out.
|
||||
PI_VERSION: Final = "0.84.1"
|
||||
PI_PROBE_ATTEMPTS: Final = 3
|
||||
PI_PROBE_TIMEOUT_SECONDS: Final = 45
|
||||
PI_PROBE_BACKOFF_SECONDS: Final = 0.25
|
||||
PI_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"read", "ls"})
|
||||
PI_SUBPROCESS_TOOLS: Final = frozenset({"grep", "find"})
|
||||
PI_MUTATING_TOOLS: Final = frozenset({"bash", "edit", "write"})
|
||||
PI_MEASURED_BUILTINS: Final = (
|
||||
PI_PROVEN_READ_ONLY_TOOLS | PI_SUBPROCESS_TOOLS | PI_MUTATING_TOOLS
|
||||
)
|
||||
|
||||
# Pi 0.84.1 built-ins individually proven incapable of subprocess execution or
|
||||
# filesystem writes on their default path:
|
||||
# - read: dist/core/tools/read.js:26-29 dispatches only read/access operations.
|
||||
# - ls: dist/core/tools/ls.js:19-22 dispatches only exists/stat/readdir operations.
|
||||
# grep and find are deliberately absent: grep.js:99/148 and find.js:161/203
|
||||
# reach ensureTool(..., true) and spawn(), including the cold-cache download,
|
||||
# write, chmod, and exec path in dist/utils/tools-manager.js:285-313.
|
||||
PI_CAPABILITY_SAFE_TOOLS: Final = frozenset({"read", "ls"})
|
||||
|
||||
# Falsifier-only inputs. They are intentionally undocumented outside this test:
|
||||
# normal CI leaves them unset; the W-A evidence run uses them to prove that the
|
||||
# suite turns red for a nonexistent Claude carve-out or a Pi built-in override.
|
||||
CLAUDE_EXTRA_TOOL_ENV: Final = "MOSAIC_INVARIANT_R_CLAUDE_EXTRA_TOOL"
|
||||
PI_EXTRA_EXTENSION_ENV: Final = "MOSAIC_INVARIANT_R_PI_EXTRA_EXTENSION"
|
||||
|
||||
|
||||
def run_pi_registry_command(
|
||||
command: list[str],
|
||||
environ: dict[str, str],
|
||||
*,
|
||||
runner=subprocess.run,
|
||||
sleeper=time.sleep,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
|
||||
|
||||
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
|
||||
try:
|
||||
return runner(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environ,
|
||||
timeout=PI_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if attempt == PI_PROBE_ATTEMPTS:
|
||||
raise AssertionError(
|
||||
"Pi registry probe could not complete after "
|
||||
f"{PI_PROBE_ATTEMPTS} attempts (concurrent pi?); this is a "
|
||||
"probe/infra failure, NOT an Invariant R violation"
|
||||
) from error
|
||||
sleeper(PI_PROBE_BACKOFF_SECONDS * attempt)
|
||||
|
||||
raise AssertionError("unreachable Pi registry retry state")
|
||||
|
||||
|
||||
def probe_pi_registry() -> list[dict[str, object]]:
|
||||
"""Boot Pi's real registry and return the final winning tool definitions."""
|
||||
|
||||
pi = shutil.which("pi")
|
||||
if pi is None:
|
||||
raise AssertionError("installed Pi runtime is required for Invariant R")
|
||||
|
||||
version = subprocess.run(
|
||||
[pi, "--version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if version.returncode != 0:
|
||||
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
|
||||
if version.stdout.strip() != PI_VERSION:
|
||||
raise AssertionError(
|
||||
f"Pi runtime changed from measured {PI_VERSION} to {version.stdout.strip()!r}; "
|
||||
"remeasure its registry before updating Invariant R"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
output = root / "registry.json"
|
||||
observer = root / "registry-observer.ts"
|
||||
observer.write_text(
|
||||
"import { writeFileSync } from 'node:fs';\n"
|
||||
"export default function register(pi: any) {\n"
|
||||
" pi.on('session_start', () => {\n"
|
||||
f" writeFileSync({json.dumps(str(output))}, JSON.stringify(pi.getAllTools()));\n"
|
||||
" process.exit(0);\n"
|
||||
" });\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
command = [
|
||||
pi,
|
||||
"--mode",
|
||||
"text",
|
||||
"--no-session",
|
||||
"--no-approve",
|
||||
"--no-context-files",
|
||||
"--no-skills",
|
||||
"--no-prompt-templates",
|
||||
"--no-extensions",
|
||||
"-e",
|
||||
str(observer),
|
||||
"-e",
|
||||
str(PI_EXTENSION),
|
||||
]
|
||||
extra_extension = os.environ.get(PI_EXTRA_EXTENSION_ENV)
|
||||
if extra_extension:
|
||||
command.extend(("-e", extra_extension))
|
||||
command.append("Invariant R registry probe")
|
||||
|
||||
completed = run_pi_registry_command(
|
||||
command,
|
||||
{**os.environ, "PI_OFFLINE": "1"},
|
||||
)
|
||||
if completed.returncode != 0 or not output.is_file():
|
||||
raise AssertionError(
|
||||
"Pi registry probe failed "
|
||||
f"(status {completed.returncode}): {completed.stderr.strip()}"
|
||||
)
|
||||
value = json.loads(output.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, list) or not value:
|
||||
raise AssertionError("Pi registry probe returned no tools; control failed")
|
||||
return value
|
||||
|
||||
|
||||
class InvariantRTest(unittest.TestCase):
|
||||
def test_live_carve_out_has_only_supported_runtimes(self) -> None:
|
||||
self.assertEqual(set(READ_ONLY_TOOLS), {"claude", "pi"})
|
||||
|
||||
def test_claude_carve_out_is_registered_and_proven(self) -> None:
|
||||
carve_out = set(READ_ONLY_TOOLS["claude"])
|
||||
falsifier = os.environ.get(CLAUDE_EXTRA_TOOL_ENV)
|
||||
if falsifier:
|
||||
carve_out.add(falsifier)
|
||||
|
||||
self.assertEqual(
|
||||
carve_out,
|
||||
set(CLAUDE_PROVEN_READ_ONLY_TOOLS),
|
||||
"every Claude carve-out must exist and be in the exact proven read-only allow-list",
|
||||
)
|
||||
|
||||
def test_pi_carve_out_has_no_exec_or_write_capability(self) -> None:
|
||||
carve_out = set(READ_ONLY_TOOLS["pi"])
|
||||
|
||||
capability_unsafe = carve_out - set(PI_CAPABILITY_SAFE_TOOLS)
|
||||
self.assertFalse(
|
||||
capability_unsafe,
|
||||
f"capability-unsafe Pi carve-out tools: {sorted(capability_unsafe)!r}; "
|
||||
"Pi 0.84.1 grep.js:99/148 and find.js:161/203 reach "
|
||||
"ensureTool(..., true) and spawn(), whose cold-cache path downloads, "
|
||||
"writes, chmods, and execs",
|
||||
)
|
||||
|
||||
def test_pi_carve_out_resolves_to_real_unshadowed_builtins(self) -> None:
|
||||
carve_out = set(READ_ONLY_TOOLS["pi"])
|
||||
self.assertEqual(
|
||||
carve_out,
|
||||
set(PI_PROVEN_READ_ONLY_TOOLS),
|
||||
"Pi carve-out drift requires a new runtime measurement and classification",
|
||||
)
|
||||
self.assertTrue(carve_out.isdisjoint(PI_MUTATING_TOOLS))
|
||||
|
||||
registry = probe_pi_registry()
|
||||
by_name: dict[str, dict[str, object]] = {}
|
||||
for entry in registry:
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str):
|
||||
self.fail(f"Pi registry entry has no string name: {entry!r}")
|
||||
by_name[name] = entry
|
||||
|
||||
builtin_names = {
|
||||
name
|
||||
for name, entry in by_name.items()
|
||||
if isinstance(entry.get("sourceInfo"), dict)
|
||||
and entry["sourceInfo"].get("source") == "builtin"
|
||||
}
|
||||
self.assertEqual(
|
||||
builtin_names,
|
||||
set(PI_MEASURED_BUILTINS),
|
||||
"Pi's real built-in registry drifted from the positive-control W-B measurement",
|
||||
)
|
||||
|
||||
for name in sorted(carve_out):
|
||||
with self.subTest(tool=name):
|
||||
self.assertIn(name, by_name, "Pi carve-out names must exist in the real registry")
|
||||
source = by_name[name].get("sourceInfo")
|
||||
self.assertIsInstance(source, dict)
|
||||
if isinstance(source, dict):
|
||||
self.assertEqual(
|
||||
source.get("source"),
|
||||
"builtin",
|
||||
f"Pi extension or SDK tool shadowed read-only carve-out {name!r}",
|
||||
)
|
||||
self.assertEqual(source.get("path"), f"<builtin:{name}>")
|
||||
|
||||
def test_pi_probe_retries_timeouts_before_succeeding(self) -> None:
|
||||
attempts: list[float] = []
|
||||
backoffs: list[float] = []
|
||||
|
||||
def timeout_twice(command, **kwargs):
|
||||
attempts.append(kwargs["timeout"])
|
||||
if len(attempts) < 3:
|
||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
completed = run_pi_registry_command(
|
||||
["pi", "probe"],
|
||||
{},
|
||||
runner=timeout_twice,
|
||||
sleeper=backoffs.append,
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertEqual(attempts, [45, 45, 45])
|
||||
self.assertEqual(backoffs, [0.25, 0.5])
|
||||
|
||||
def test_pi_probe_labels_exhausted_timeouts_as_infrastructure_failure(self) -> None:
|
||||
attempts = 0
|
||||
|
||||
def always_timeout(command, **kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
AssertionError,
|
||||
"Pi registry probe could not complete .* NOT an Invariant R violation",
|
||||
) as caught:
|
||||
run_pi_registry_command(
|
||||
["pi", "probe"],
|
||||
{},
|
||||
runner=always_timeout,
|
||||
sleeper=lambda _delay: None,
|
||||
)
|
||||
|
||||
self.assertEqual(attempts, 3)
|
||||
self.assertIsInstance(caught.exception.__cause__, subprocess.TimeoutExpired)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The promotion client must never build a binding narrower than it claims.
|
||||
|
||||
RED-first against a real defect: ``build_construction`` skipped any normative
|
||||
source it could not read (``except OSError: continue``) and promoted whatever
|
||||
remained. That is not a degraded binding, it is a forged smaller one — the
|
||||
broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent*
|
||||
(``daemon.py:602-616``), so an omitted fragment is internally consistent and
|
||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only
|
||||
``USER.md`` readable (964 bytes on the live host), the client produced a
|
||||
one-fragment construction with ``promotion=True``.
|
||||
|
||||
The classification under test mirrors the framework's own file ownership, and
|
||||
must keep mirroring it:
|
||||
|
||||
* framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED /
|
||||
``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime
|
||||
contract — absence is a broken deployment, so it is REFUSED;
|
||||
* ``SOUL.md`` / ``USER.md`` — install.sh deliberately does not seed them
|
||||
("generated by `mosaic init`"), so absence is legitimate and ALLOWED.
|
||||
|
||||
Unreadable is treated separately from absent for *every* source, optional ones
|
||||
included: a file that will not open is not a file that was never configured, and
|
||||
collapsing the two is what let a permission change quietly shrink the law.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import lease_promote # noqa: E402
|
||||
|
||||
RUNTIME = "pi"
|
||||
RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md"
|
||||
ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT)
|
||||
REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT}
|
||||
# Derived, never listed: a hand-kept second copy is exactly the drift this file
|
||||
# exists to catch.
|
||||
OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED)
|
||||
|
||||
# chmod 0o000 does not deny root (CAP_DAC_OVERRIDE), so the unreadable
|
||||
# simulations would fail spuriously in a root container.
|
||||
runs_unprivileged = unittest.skipIf(
|
||||
os.geteuid() == 0, "chmod 0o000 cannot make a file unreadable to root"
|
||||
)
|
||||
|
||||
|
||||
class PromotionBindingTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._previous_home = os.environ.get("MOSAIC_HOME")
|
||||
self._temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._temporary.name)
|
||||
for source_id in ALL_SOURCES:
|
||||
path = self.root / source_id
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"# {source_id}\nnormative bytes\n".encode())
|
||||
os.environ["MOSAIC_HOME"] = str(self.root)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for path in self.root.rglob("*"):
|
||||
if path.is_file():
|
||||
path.chmod(0o644)
|
||||
self._temporary.cleanup()
|
||||
if self._previous_home is None:
|
||||
os.environ.pop("MOSAIC_HOME", None)
|
||||
else:
|
||||
os.environ["MOSAIC_HOME"] = self._previous_home
|
||||
|
||||
def reset_home(self) -> None:
|
||||
"""Discard the current home and seed a fresh complete one.
|
||||
|
||||
Each subTest mutates the tree destructively, so it needs a clean start —
|
||||
and the old one must be released, not orphaned.
|
||||
"""
|
||||
self.tearDown()
|
||||
self.setUp()
|
||||
|
||||
def build(self):
|
||||
return lease_promote.build_construction(RUNTIME)
|
||||
|
||||
def source_ids(self) -> list[str]:
|
||||
construction, _ = self.build()
|
||||
return [f["source_id"] for f in construction["fragments"]]
|
||||
|
||||
# --- the binding is complete when the deployment is complete -------------
|
||||
|
||||
def test_complete_deployment_binds_every_source(self) -> None:
|
||||
construction, result = self.build()
|
||||
self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES))
|
||||
self.assertTrue(result.promotion)
|
||||
|
||||
# --- absence: refused for framework-owned, allowed for operator-owned ----
|
||||
|
||||
def test_absent_required_source_is_refused(self) -> None:
|
||||
for source_id in sorted(REQUIRED):
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).unlink()
|
||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||
self.build()
|
||||
self.assertIn(source_id, str(caught.exception))
|
||||
|
||||
def test_absent_operator_source_still_binds_the_rest(self) -> None:
|
||||
for source_id in OPTIONAL:
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).unlink()
|
||||
notice = io.StringIO()
|
||||
with contextlib.redirect_stderr(notice):
|
||||
bound = self.source_ids()
|
||||
self.assertNotIn(source_id, bound)
|
||||
for required in lease_promote.REQUIRED_SOURCES:
|
||||
self.assertIn(required, bound)
|
||||
# A silent omission is the original defect in miniature: the
|
||||
# narrower binding must announce itself.
|
||||
self.assertIn(source_id, notice.getvalue())
|
||||
|
||||
# --- unreadable is never the same as absent -----------------------------
|
||||
|
||||
@runs_unprivileged
|
||||
def test_unreadable_source_is_refused_even_when_optional(self) -> None:
|
||||
for source_id in ALL_SOURCES:
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||
self.build()
|
||||
self.assertIn(source_id, str(caught.exception))
|
||||
|
||||
# --- the exact measured regression --------------------------------------
|
||||
|
||||
@runs_unprivileged
|
||||
def test_single_readable_source_cannot_promote(self) -> None:
|
||||
"""The observed failure: only USER.md readable produced a valid binding."""
|
||||
for source_id in ALL_SOURCES:
|
||||
if source_id != "USER.md":
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||
self.build()
|
||||
|
||||
@runs_unprivileged
|
||||
def test_no_source_readable_cannot_promote(self) -> None:
|
||||
for source_id in ALL_SOURCES:
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||
self.build()
|
||||
|
||||
# --- the classification must not drift from the framework's -------------
|
||||
|
||||
def test_required_set_excludes_only_the_unseeded_sources(self) -> None:
|
||||
"""`install.sh` decides which files exist; this list must follow it.
|
||||
|
||||
If a source moves between framework-owned and operator-generated
|
||||
upstream, this fails and forces the classification to be re-read rather
|
||||
than silently inherited.
|
||||
"""
|
||||
self.assertEqual(
|
||||
set(lease_promote.REQUIRED_SOURCES),
|
||||
{"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"},
|
||||
"REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and "
|
||||
"config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it",
|
||||
)
|
||||
self.assertTrue(
|
||||
set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES),
|
||||
"a required source is not in the binding order",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,609 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RED-first contracts for the operator-triggered Claude promotion hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
||||
TOOLS = FRAMEWORK / "tools/lease-broker"
|
||||
BEGIN_PATH = TOOLS / "promote-begin.py"
|
||||
COMPLETE_PATH = TOOLS / "promote-complete.py"
|
||||
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
||||
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
|
||||
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
|
||||
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
|
||||
SESSION_ID = "a" * 64
|
||||
CHALLENGE = "b" * 64
|
||||
H_PAYLOAD = "c" * 64
|
||||
RECEIPT = (
|
||||
f"MOSAIC-RECEIPT{{challenge={CHALLENGE}; H_payload={H_PAYLOAD}; gen=1; cep=0}}"
|
||||
)
|
||||
NOW = 10_000.0
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
if not path.is_file():
|
||||
raise AssertionError(f"shipped module is missing: {path}")
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"unable to load {name}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class PromotionHookFixture(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.begin = load_module("promotion_begin_test", BEGIN_PATH)
|
||||
cls.complete = load_module("promotion_complete_test", COMPLETE_PATH)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.runtime_dir = Path(self.temporary.name)
|
||||
self.environment = {
|
||||
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
||||
"MOSAIC_LEASE_SESSION_ID": SESSION_ID,
|
||||
}
|
||||
self.pending_dir = self.runtime_dir / "mosaic-lease"
|
||||
self.pending_file = self.pending_dir / f"pending-{SESSION_ID}"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def completed(payload: dict[str, object], returncode: int = 0, stderr: str = ""):
|
||||
return subprocess.CompletedProcess(
|
||||
["lease_promote.py"],
|
||||
returncode,
|
||||
json.dumps(payload),
|
||||
stderr,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def successful_begin_reply(cls, **extra: object) -> dict[str, object]:
|
||||
return {
|
||||
"ok": True,
|
||||
"state": "PENDING_VERIFICATION",
|
||||
"receipt_challenge": CHALLENGE,
|
||||
"receipt": RECEIPT,
|
||||
"binding": {
|
||||
"compaction_epoch": 0,
|
||||
"request_epoch": 0,
|
||||
"h_source": "d" * 64,
|
||||
"h_payload": H_PAYLOAD,
|
||||
"runtime_generation": 1,
|
||||
"schema_version": 1,
|
||||
},
|
||||
**extra,
|
||||
}
|
||||
|
||||
def run_begin(
|
||||
self,
|
||||
prompt: str,
|
||||
runner: mock.Mock,
|
||||
) -> tuple[int, str, str]:
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
result = self.begin.main(
|
||||
environ=self.environment,
|
||||
stdin=io.BytesIO(json.dumps({"prompt": prompt}).encode()),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
run=runner,
|
||||
now=lambda: NOW,
|
||||
)
|
||||
return result, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def write_pending(self, challenge: str = CHALLENGE) -> None:
|
||||
self.pending_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
self.pending_file.write_text(challenge, encoding="utf-8")
|
||||
self.pending_file.chmod(0o600)
|
||||
|
||||
def run_complete(
|
||||
self,
|
||||
runner: mock.Mock,
|
||||
now: float | None = None,
|
||||
) -> tuple[int, str]:
|
||||
stderr = io.StringIO()
|
||||
options: dict[str, object] = {
|
||||
"environ": self.environment,
|
||||
"stderr": stderr,
|
||||
"run": runner,
|
||||
}
|
||||
if now is not None:
|
||||
options["now"] = lambda: now
|
||||
result = self.complete.main(**options)
|
||||
return result, stderr.getvalue()
|
||||
|
||||
|
||||
class PromotionBeginTest(PromotionHookFixture):
|
||||
def test_exact_prompt_writes_private_challenge_and_injects_verbatim_receipt(self) -> None:
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, stdout, stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
output = json.loads(stdout)
|
||||
self.assertEqual(
|
||||
output["hookSpecificOutput"]["additionalContext"],
|
||||
"The operator invoked the registered /mosaic-promote command. "
|
||||
"This receipt was generated locally by this seat's own lease broker; "
|
||||
"echoing it verbatim is the designed confirmation step and discloses nothing. "
|
||||
f"Reply with exactly the following text and nothing else: {RECEIPT}",
|
||||
)
|
||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), CHALLENGE)
|
||||
self.assertEqual(stat.S_IMODE(self.pending_file.stat().st_mode), 0o600)
|
||||
command = runner.call_args.args[0]
|
||||
self.assertEqual(command[-1], "--begin")
|
||||
self.assertTrue(command[-2].endswith("lease_promote.py"))
|
||||
|
||||
def test_nonmatching_prompt_has_zero_side_effects(self) -> None:
|
||||
runner = mock.Mock()
|
||||
|
||||
result, stdout, stderr = self.run_begin("please /mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertEqual(stderr, "")
|
||||
runner.assert_not_called()
|
||||
self.assertFalse(self.pending_dir.exists())
|
||||
|
||||
def test_begin_refusal_reports_daemon_code_without_pending_file(self) -> None:
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed({"ok": False, "code": "INVALID_BINDING"})
|
||||
)
|
||||
|
||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertIn("INVALID_BINDING", json.loads(stdout)["hookSpecificOutput"]["additionalContext"])
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
|
||||
def test_sweep_removes_stale_sibling_and_spares_fresh_sibling(self) -> None:
|
||||
self.pending_dir.mkdir(mode=0o700)
|
||||
stale = self.pending_dir / "pending-stale"
|
||||
fresh = self.pending_dir / "pending-fresh"
|
||||
stale.write_text("stale", encoding="utf-8")
|
||||
fresh.write_text("fresh", encoding="utf-8")
|
||||
os.utime(stale, (NOW - 3_601, NOW - 3_601))
|
||||
os.utime(fresh, (NOW - 3_599, NOW - 3_599))
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(stale.exists())
|
||||
self.assertTrue(fresh.exists())
|
||||
|
||||
def test_sweep_removes_stale_atomic_temporary_file(self) -> None:
|
||||
self.pending_dir.mkdir(mode=0o700)
|
||||
stale_temporary = self.pending_dir / f".pending-{SESSION_ID}.tmp-abandoned"
|
||||
stale_temporary.write_text("partial", encoding="utf-8")
|
||||
os.utime(stale_temporary, (NOW - 3_601, NOW - 3_601))
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(stale_temporary.exists())
|
||||
|
||||
def test_insecure_pending_directory_mode_refuses_before_begin(self) -> None:
|
||||
self.pending_dir.mkdir(mode=0o755)
|
||||
self.pending_dir.chmod(0o755)
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
|
||||
|
||||
def test_insecure_runtime_directory_mode_refuses_before_begin(self) -> None:
|
||||
self.runtime_dir.chmod(0o755)
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertIn("PROMOTION_TRIGGER_FAILED", stdout)
|
||||
|
||||
def test_parent_symlink_cannot_redirect_pending_write(self) -> None:
|
||||
outside = self.runtime_dir / "outside"
|
||||
outside.mkdir(mode=0o700)
|
||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
||||
runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
|
||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertFalse((outside / f"pending-{SESSION_ID}").exists())
|
||||
|
||||
def test_concurrent_begin_is_refused_without_minting_a_second_challenge(self) -> None:
|
||||
inner_runner = mock.Mock(return_value=self.completed(self.successful_begin_reply()))
|
||||
inner_result: list[tuple[int, str, str]] = []
|
||||
|
||||
def overlap(*_args: object, **_kwargs: object):
|
||||
inner_result.append(self.run_begin("/mosaic-promote", inner_runner))
|
||||
return self.completed(self.successful_begin_reply())
|
||||
|
||||
outer_runner = mock.Mock(side_effect=overlap)
|
||||
|
||||
result, _stdout, _stderr = self.run_begin("/mosaic-promote", outer_runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
inner_runner.assert_not_called()
|
||||
self.assertEqual(inner_result[0][0], 0)
|
||||
self.assertIn("PROMOTION_ALREADY_IN_PROGRESS", inner_result[0][1])
|
||||
|
||||
def test_non_ascii_receipt_reply_is_rejected_without_crashing_hook(self) -> None:
|
||||
reply = self.successful_begin_reply()
|
||||
reply["receipt"] = "MOSAIC—RECEIPT"
|
||||
runner = mock.Mock(return_value=self.completed(reply))
|
||||
|
||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
||||
|
||||
def test_success_shaped_reply_with_extra_fields_is_rejected(self) -> None:
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(self.successful_begin_reply(unexpected=True))
|
||||
)
|
||||
|
||||
result, stdout, _stderr = self.run_begin("/mosaic-promote", runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertIn("INVALID_PROMOTER_REPLY", stdout)
|
||||
|
||||
|
||||
class PromotionCompleteTest(PromotionHookFixture):
|
||||
def test_no_pending_file_is_zero_cost_success(self) -> None:
|
||||
runner = mock.Mock()
|
||||
|
||||
result, stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_success_deletes_pending_file(self) -> None:
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertEqual(runner.call_args.args[0][-2:], ["--complete", CHALLENGE])
|
||||
|
||||
def test_success_atomically_writes_a_private_correlated_result_with_wall_clock_expiry(self) -> None:
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
with mock.patch.object(self.complete.os, "replace", wraps=os.replace) as replace:
|
||||
result, _stderr = self.run_complete(runner, now=12_345.0)
|
||||
|
||||
result_file = self.pending_dir / "last-result.json"
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
|
||||
self.assertEqual(
|
||||
json.loads(result_file.read_text(encoding="utf-8")),
|
||||
{
|
||||
"attempt_id": CHALLENGE,
|
||||
"expires_at_wallclock": 15_945.0,
|
||||
"reason": None,
|
||||
"session_id": SESSION_ID,
|
||||
"ts": 12_345.0,
|
||||
"verified": True,
|
||||
},
|
||||
)
|
||||
temporary, destination = replace.call_args.args
|
||||
self.assertRegex(temporary, r"^\.last-result\.json\.tmp-[0-9a-f]+$")
|
||||
self.assertEqual(destination, "last-result.json")
|
||||
self.assertFalse(any(path.name.startswith(".last-result.json.tmp-") for path in self.pending_dir.iterdir()))
|
||||
|
||||
def test_terminal_failure_writes_a_private_correlated_unverified_result(self) -> None:
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "observe_receipt", "ok": False, "code": "RECEIPT_MISMATCH"}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner, now=12_345.0)
|
||||
|
||||
result_file = self.pending_dir / "last-result.json"
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertEqual(stat.S_IMODE(result_file.stat().st_mode), 0o600)
|
||||
self.assertEqual(
|
||||
json.loads(result_file.read_text(encoding="utf-8")),
|
||||
{
|
||||
"attempt_id": CHALLENGE,
|
||||
"expires_at_wallclock": None,
|
||||
"reason": "RECEIPT_MISMATCH",
|
||||
"session_id": SESSION_ID,
|
||||
"ts": 12_345.0,
|
||||
"verified": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_each_terminal_failure_deletes_pending_file(self) -> None:
|
||||
terminal_codes = (
|
||||
"RECEIPT_REPLAY",
|
||||
"RECEIPT_MISMATCH",
|
||||
"INVALID_LEASE_TRANSITION",
|
||||
"PROMOTION_TOKEN_INVALID",
|
||||
)
|
||||
for code in terminal_codes:
|
||||
with self.subTest(code=code):
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
||||
)
|
||||
)
|
||||
|
||||
result, stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertFalse(self.pending_file.exists())
|
||||
self.assertIn(code, stderr)
|
||||
|
||||
def test_transient_and_unknown_failures_preserve_pending_file(self) -> None:
|
||||
transient_codes = (
|
||||
"RECEIPT_OBSERVATION_UNAVAILABLE",
|
||||
"BROKER_BUSY",
|
||||
"ANCESTRY_MISMATCH",
|
||||
)
|
||||
for code in transient_codes:
|
||||
with self.subTest(code=code):
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "observe_receipt", "ok": False, "code": code}
|
||||
)
|
||||
)
|
||||
|
||||
result, stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertTrue(self.pending_file.exists())
|
||||
self.assertIn(code, stderr)
|
||||
|
||||
def test_transport_failure_preserves_pending_file_and_exits_zero(self) -> None:
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed({}, returncode=2, stderr="ConnectionRefusedError")
|
||||
)
|
||||
|
||||
result, stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertTrue(self.pending_file.exists())
|
||||
self.assertIn("ConnectionRefusedError", stderr)
|
||||
|
||||
def test_result_write_failure_exits_zero(self) -> None:
|
||||
self.write_pending()
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
with mock.patch.object(self.complete, "write_result", side_effect=OSError("disk full")):
|
||||
result, stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertIn("OSError", stderr)
|
||||
|
||||
def test_missing_lease_session_id_exits_zero(self) -> None:
|
||||
self.write_pending()
|
||||
environment = dict(self.environment)
|
||||
del environment["MOSAIC_LEASE_SESSION_ID"]
|
||||
runner = mock.Mock()
|
||||
stderr = io.StringIO()
|
||||
|
||||
result = self.complete.main(environ=environment, stderr=stderr, run=runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertIn("KeyError", stderr.getvalue())
|
||||
|
||||
def test_insecure_runtime_directory_mode_preserves_pending(self) -> None:
|
||||
self.write_pending(CHALLENGE)
|
||||
self.runtime_dir.chmod(0o755)
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertTrue(self.pending_file.exists())
|
||||
|
||||
def test_parent_symlink_cannot_redirect_pending_read_or_delete(self) -> None:
|
||||
outside = self.runtime_dir / "outside"
|
||||
outside.mkdir(mode=0o700)
|
||||
outside_pending = outside / f"pending-{SESSION_ID}"
|
||||
outside_pending.write_text(CHALLENGE, encoding="utf-8")
|
||||
outside_pending.chmod(0o600)
|
||||
self.pending_dir.symlink_to(outside, target_is_directory=True)
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertTrue(outside_pending.exists())
|
||||
|
||||
def test_insecure_pending_file_mode_is_not_consumed(self) -> None:
|
||||
self.write_pending(CHALLENGE)
|
||||
self.pending_file.chmod(0o644)
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
runner.assert_not_called()
|
||||
self.assertTrue(self.pending_file.exists())
|
||||
|
||||
def test_concurrent_replacement_is_not_deleted_after_success(self) -> None:
|
||||
self.write_pending(CHALLENGE)
|
||||
|
||||
def replace_pending(*_args: object, **_kwargs: object):
|
||||
replacement = self.pending_dir / "replacement"
|
||||
replacement.write_text("replacement", encoding="utf-8")
|
||||
replacement.chmod(0o600)
|
||||
os.replace(replacement, self.pending_file)
|
||||
return self.completed(
|
||||
{"stage": "promote_lease", "ok": True, "state": "VERIFIED"}
|
||||
)
|
||||
|
||||
runner = mock.Mock(side_effect=replace_pending)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(self.pending_file.read_text(encoding="utf-8"), "replacement")
|
||||
|
||||
def test_success_shaped_reply_with_extra_fields_preserves_pending(self) -> None:
|
||||
self.write_pending(CHALLENGE)
|
||||
runner = mock.Mock(
|
||||
return_value=self.completed(
|
||||
{
|
||||
"stage": "promote_lease",
|
||||
"ok": True,
|
||||
"state": "VERIFIED",
|
||||
"unexpected": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result, _stderr = self.run_complete(runner)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertTrue(self.pending_file.exists())
|
||||
|
||||
|
||||
class PromotionTemplateWiringTest(unittest.TestCase):
|
||||
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
|
||||
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
||||
hooks = settings["hooks"]
|
||||
submit_commands = [
|
||||
hook["command"]
|
||||
for group in hooks["UserPromptSubmit"]
|
||||
for hook in group["hooks"]
|
||||
]
|
||||
self.assertEqual(
|
||||
submit_commands,
|
||||
["python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[group.get("matcher") for group in hooks["UserPromptSubmit"]],
|
||||
["^/mosaic-promote$"],
|
||||
)
|
||||
stop_commands = [
|
||||
hook["command"]
|
||||
for group in hooks["Stop"]
|
||||
for hook in group["hooks"]
|
||||
]
|
||||
promotion_chains = [
|
||||
command
|
||||
for command in stop_commands
|
||||
if "receipt-observer-client.py" in command and "promote-complete.py" in command
|
||||
]
|
||||
self.assertEqual(len(promotion_chains), 1)
|
||||
chain = promotion_chains[0]
|
||||
self.assertLess(
|
||||
chain.index("receipt-observer-client.py"),
|
||||
chain.index("promote-complete.py"),
|
||||
)
|
||||
self.assertIn("observer_status=$?", chain)
|
||||
self.assertTrue(chain.endswith("exit $observer_status"))
|
||||
|
||||
def test_registered_command_is_one_line_and_defers_to_injected_instruction(self) -> None:
|
||||
body = CLAUDE_COMMAND.read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
body,
|
||||
"I invoked this registered command to authorize lease promotion; follow the local seat broker's injected receipt confirmation instruction exactly.\n",
|
||||
)
|
||||
|
||||
|
||||
class PromotionVerbatimToleranceTest(unittest.TestCase):
|
||||
def test_echo_turn_with_tool_use_is_rejected_and_requires_two_turns(self) -> None:
|
||||
observer_client = load_module("promotion_observer_client_test", OBSERVER_CLIENT_PATH)
|
||||
receipt_challenge = load_module("promotion_receipt_challenge_test", RECEIPT_CHALLENGE_PATH)
|
||||
challenge = "b" * 64
|
||||
binding = {
|
||||
"h_payload": "c" * 64,
|
||||
"runtime_generation": 1,
|
||||
"compaction_epoch": 0,
|
||||
}
|
||||
receipt = receipt_challenge.receipt_for(challenge, binding)
|
||||
real_claude_entry = {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": receipt},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool-1",
|
||||
"name": "mcp__discord__reply",
|
||||
"input": {"message": "promoted"},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
extracted = observer_client.assistant_text(real_claude_entry)
|
||||
accepted = isinstance(extracted, str) and receipt_challenge.is_verbatim_receipt(
|
||||
extracted,
|
||||
challenge,
|
||||
binding,
|
||||
)
|
||||
|
||||
self.assertIsNone(extracted)
|
||||
self.assertFalse(accepted)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -207,24 +207,6 @@ class ReceiptObserverTest(BrokerFixture):
|
||||
with self.assertRaisesRegex(DAEMON.BrokerFailure, "INVALID_LEASE_TRANSITION"):
|
||||
self.promote(current["receipt_challenge"])
|
||||
|
||||
def test_non_ascii_observation_is_mismatch_and_broker_keeps_serving(self) -> None:
|
||||
construction, binding = self.construction()
|
||||
cycle = self.begin(binding, construction)
|
||||
self.record("I refuse—this is not the receipt")
|
||||
|
||||
with self.assertRaisesRegex(DAEMON.BrokerFailure, "RECEIPT_MISMATCH"):
|
||||
self.observe(cycle["receipt_challenge"])
|
||||
|
||||
denied = self.broker.handle(self.peer, {
|
||||
"action": "authorize_tool",
|
||||
"session_id": self.session_id,
|
||||
"runtime_generation": 7,
|
||||
"runtime": "pi",
|
||||
"tool_name": "bash",
|
||||
})
|
||||
self.assertEqual(denied["decision"], "deny")
|
||||
self.assertEqual(denied["state"], DAEMON.LEASE_UNVERIFIED)
|
||||
|
||||
def test_t29_altered_model_hash_cannot_promote_against_shipped_binding(self) -> None:
|
||||
construction, binding = self.construction()
|
||||
cycle = self.begin(binding, construction)
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exit-semantics tests for the receipt observer Stop-hook client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
||||
|
||||
|
||||
def load_client():
|
||||
spec = importlib.util.spec_from_file_location("receipt_observer_client_test", CLIENT_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("unable to load receipt-observer-client.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
CLIENT = load_client()
|
||||
VALID_INPUT = json.dumps({"latest_assistant_message": "ordinary turn"}).encode()
|
||||
DEEPLY_NESTED_JSON = b"[" * 2_000 + b"0" + b"]" * 2_000
|
||||
ENVIRONMENT = {
|
||||
"MOSAIC_RECEIPT_OBSERVER_SOCKET": "/unused/observer.sock",
|
||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
}
|
||||
|
||||
|
||||
class FakeObserverSocket:
|
||||
def __init__(self, response: bytes) -> None:
|
||||
self.response = response
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def settimeout(self, _timeout: float) -> None:
|
||||
return None
|
||||
|
||||
def connect(self, _path: str) -> None:
|
||||
return None
|
||||
|
||||
def sendall(self, _payload: bytes) -> None:
|
||||
return None
|
||||
|
||||
def shutdown(self, _how: int) -> None:
|
||||
return None
|
||||
|
||||
def recv(self, _size: int) -> bytes:
|
||||
response, self.response = self.response, b""
|
||||
return response
|
||||
|
||||
|
||||
class ReceiptObserverClientExitSemanticsTest(unittest.TestCase):
|
||||
def run_client(
|
||||
self,
|
||||
*,
|
||||
input_bytes: bytes = VALID_INPUT,
|
||||
reply: dict[str, object] | None = None,
|
||||
transport_error: OSError | None = None,
|
||||
runtime: str = "pi",
|
||||
) -> tuple[int, str, mock.Mock]:
|
||||
request = mock.Mock(return_value=reply)
|
||||
if transport_error is not None:
|
||||
request.side_effect = transport_error
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(input_bytes)),
|
||||
mock.patch.object(CLIENT, "observer_request", request),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
arguments = ["--runtime", runtime]
|
||||
if runtime == "claude":
|
||||
arguments.append("--latest-entry")
|
||||
result = CLIENT.main(arguments, environ=ENVIRONMENT)
|
||||
return result, stderr.getvalue(), request
|
||||
|
||||
def test_claude_prefers_inline_last_assistant_message(self) -> None:
|
||||
inline = "the just-finished assistant message"
|
||||
input_bytes = json.dumps({
|
||||
"last_assistant_message": inline,
|
||||
"transcript_path": "/must/not/be/opened.jsonl",
|
||||
}).encode()
|
||||
|
||||
result, stderr, request = self.run_client(
|
||||
input_bytes=input_bytes,
|
||||
reply={"ok": True},
|
||||
runtime="claude",
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(
|
||||
request.call_args.args[1]["latest_assistant_message"],
|
||||
inline,
|
||||
)
|
||||
|
||||
def test_claude_falls_back_to_transcript_when_inline_field_is_absent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
transcript = Path(directory) / "transcript.jsonl"
|
||||
transcript.write_text(
|
||||
json.dumps({
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "fallback message"}],
|
||||
}
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
input_bytes = json.dumps({"transcript_path": str(transcript)}).encode()
|
||||
|
||||
result, stderr, request = self.run_client(
|
||||
input_bytes=input_bytes,
|
||||
reply={"ok": True},
|
||||
runtime="claude",
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(
|
||||
request.call_args.args[1]["latest_assistant_message"],
|
||||
"fallback message",
|
||||
)
|
||||
|
||||
def test_claude_present_invalid_inline_field_fails_without_fallback(self) -> None:
|
||||
fallback = mock.Mock(return_value="must not be used")
|
||||
input_bytes = json.dumps({
|
||||
"last_assistant_message": None,
|
||||
"transcript_path": "/unused/transcript.jsonl",
|
||||
}).encode()
|
||||
|
||||
with mock.patch.object(CLIENT, "claude_latest_entry", fallback):
|
||||
result, stderr, request = self.run_client(
|
||||
input_bytes=input_bytes,
|
||||
reply={"ok": True},
|
||||
runtime="claude",
|
||||
)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
||||
request.assert_not_called()
|
||||
fallback.assert_not_called()
|
||||
|
||||
def test_claude_inline_message_size_guard_stays_enforced(self) -> None:
|
||||
request = mock.Mock(return_value={"ok": True})
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(b"{}")),
|
||||
mock.patch.object(
|
||||
CLIENT,
|
||||
"read_json",
|
||||
return_value={"last_assistant_message": "x" * (CLIENT.MAX_FRAME + 1)},
|
||||
),
|
||||
mock.patch.object(CLIENT, "observer_request", request),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = CLIENT.main(
|
||||
["--runtime", "claude", "--latest-entry"],
|
||||
environ=ENVIRONMENT,
|
||||
)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
||||
request.assert_not_called()
|
||||
|
||||
def test_pi_still_posts_only_its_runtime_message(self) -> None:
|
||||
result, stderr, request = self.run_client(reply={"ok": True})
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(
|
||||
request.call_args.args[1]["latest_assistant_message"],
|
||||
"ordinary turn",
|
||||
)
|
||||
|
||||
def test_nothing_pending_observation_refusal_is_benign(self) -> None:
|
||||
result, stderr, request = self.run_client(
|
||||
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE"}
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
request.assert_called_once()
|
||||
|
||||
def test_success_reply_remains_successful(self) -> None:
|
||||
result, stderr, _request = self.run_client(reply={"ok": True})
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
|
||||
def test_pending_cycle_auth_failure_stays_fail_closed(self) -> None:
|
||||
result, _stderr, _request = self.run_client(
|
||||
reply={"ok": False, "code": "ANCESTRY_MISMATCH"}
|
||||
)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
def test_transport_failure_stays_fail_closed(self) -> None:
|
||||
result, stderr, _request = self.run_client(
|
||||
transport_error=ConnectionRefusedError("observer unavailable")
|
||||
)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
||||
|
||||
def test_parse_failure_stays_fail_closed(self) -> None:
|
||||
result, stderr, request = self.run_client(input_bytes=b"{")
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
||||
request.assert_not_called()
|
||||
|
||||
def test_malformed_wire_replies_stay_fail_closed(self) -> None:
|
||||
for response in (
|
||||
b"not-json\n",
|
||||
b'{"ok":true}',
|
||||
b"{}\n{}\n",
|
||||
b'{"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n\n',
|
||||
b'{"ok":true,"ok":false,"code":"OBSERVATION_UNAVAILABLE"}\n',
|
||||
DEEPLY_NESTED_JSON + b"\n",
|
||||
b"x" * (CLIENT.MAX_FRAME + 1),
|
||||
):
|
||||
with self.subTest(response=response):
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
|
||||
mock.patch.object(
|
||||
CLIENT.socket,
|
||||
"socket",
|
||||
return_value=FakeObserverSocket(response),
|
||||
),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
||||
|
||||
def test_oversized_input_stays_fail_closed(self) -> None:
|
||||
result, stderr, request = self.run_client(input_bytes=b"x" * (CLIENT.MAX_FRAME + 1))
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
||||
request.assert_not_called()
|
||||
|
||||
def test_deeply_nested_input_stays_fail_closed(self) -> None:
|
||||
result, stderr, request = self.run_client(input_bytes=DEEPLY_NESTED_JSON)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr)
|
||||
request.assert_not_called()
|
||||
|
||||
def test_json_recursion_failure_stays_fail_closed(self) -> None:
|
||||
request = mock.Mock()
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(CLIENT.sys, "stdin", io.BytesIO(VALID_INPUT)),
|
||||
mock.patch.object(CLIENT, "observer_request", request),
|
||||
mock.patch.object(
|
||||
CLIENT.json,
|
||||
"loads",
|
||||
side_effect=RecursionError("maximum JSON nesting exceeded"),
|
||||
),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = CLIENT.main(["--runtime", "pi"], environ=ENVIRONMENT)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("Mosaic receipt observer refused", stderr.getvalue())
|
||||
request.assert_not_called()
|
||||
|
||||
def test_observation_unavailable_with_unexpected_fields_stays_fail_closed(self) -> None:
|
||||
result, _stderr, _request = self.run_client(
|
||||
reply={"ok": False, "code": "OBSERVATION_UNAVAILABLE", "unexpected": True}
|
||||
)
|
||||
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
def test_non_boolean_ok_values_stay_fail_closed(self) -> None:
|
||||
for reply in (
|
||||
{"ok": 1},
|
||||
{"ok": 0, "code": "OBSERVATION_UNAVAILABLE"},
|
||||
):
|
||||
with self.subTest(reply=reply):
|
||||
result, _stderr, _request = self.run_client(reply=reply)
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -388,12 +388,8 @@ describe('whole mutator-class lease gate', () => {
|
||||
['claude', 'Write'],
|
||||
['claude', 'NotebookEdit'],
|
||||
['claude', 'mcp__provider__close_issue'],
|
||||
['claude', 'Ls'],
|
||||
['claude', 'Find'],
|
||||
['pi', 'bash'],
|
||||
['pi', 'edit'],
|
||||
['pi', 'grep'],
|
||||
['pi', 'find'],
|
||||
['pi', 'write'],
|
||||
['pi', 'deploy'],
|
||||
['pi', 'unknown_custom_tool'],
|
||||
@@ -413,9 +409,8 @@ describe('whole mutator-class lease gate', () => {
|
||||
for (const [runtime, toolName] of [
|
||||
['claude', 'Read'],
|
||||
['claude', 'Grep'],
|
||||
['claude', 'Glob'],
|
||||
['pi', 'read'],
|
||||
['pi', 'ls'],
|
||||
['pi', 'grep'],
|
||||
['pi', 'mosaic_context_recover'],
|
||||
] as const) {
|
||||
expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({
|
||||
|
||||
@@ -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,12 +269,7 @@ export async function finalizeStage(
|
||||
// 7. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
let summaryShown = false;
|
||||
const showSummary = () => {
|
||||
if (summaryShown) return;
|
||||
summaryShown = true;
|
||||
|
||||
// 7. Summary
|
||||
// 8. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
@@ -318,7 +294,7 @@ export async function finalizeStage(
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
// 9. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
@@ -333,11 +309,4 @@ export async function finalizeStage(
|
||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
};
|
||||
|
||||
if (!options.deferSummary) {
|
||||
showSummary();
|
||||
}
|
||||
|
||||
return { showSummary };
|
||||
}
|
||||
|
||||
@@ -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,9 +80,7 @@ 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,
|
||||
@@ -95,14 +88,11 @@ export async function quickStartPath(
|
||||
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,15 +386,13 @@ 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,
|
||||
@@ -432,15 +400,12 @@ async function runHeadlessPath(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,222 +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="$TMP/prefix"
|
||||
MOSAIC_HOME="$TMP/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" == "view" ]]; then
|
||||
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]"*)
|
||||
echo "0.0.49-next.999" > "$STATE/mosaic"
|
||||
;;
|
||||
*"@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"*)
|
||||
echo "0.0.0-source" > "$STATE/mosaic"
|
||||
;;
|
||||
*"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
|
||||
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
|
||||
exit 0
|
||||
FAKE_CURL
|
||||
chmod +x "$FAKE_BIN/curl"
|
||||
|
||||
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dest=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-C) dest="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
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
|
||||
|
||||
# install/build commands are no-ops in this harness.
|
||||
exit 0
|
||||
FAKE_PNPM
|
||||
chmod +x "$FAKE_BIN/pnpm"
|
||||
|
||||
reset_state() {
|
||||
: > "$LOG"
|
||||
rm -f "$STATE"/*
|
||||
}
|
||||
|
||||
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 from next' <<<"$OUTPUT"; then
|
||||
echo "fast path unexpectedly fell back to source" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 from next' <<<"$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] explicit --ref keeps source lane and avoids @next lookup"
|
||||
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
|
||||
)"
|
||||
|
||||
grep -qF 'explicit ref wins, build-from-source' <<<"$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 warns on mismatched prerelease pipeline suffixes"
|
||||
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
|
||||
)"
|
||||
|
||||
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
|
||||
|
||||
echo "[test] installer next lane tests passed"
|
||||
+11
-192
@@ -16,10 +16,6 @@
|
||||
# --framework Install/upgrade framework only (skip npm CLI)
|
||||
# --cli Install/upgrade npm CLI only (skip framework)
|
||||
# --ref <branch> Git ref for framework archive (default: main)
|
||||
# --next Prerelease lane: try fast npm @next install for CLI +
|
||||
# gateway from the Gitea registry, then fall back to a
|
||||
# source build at next if unavailable. Explicit
|
||||
# --ref/MOSAIC_REF wins and uses the source path.
|
||||
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
|
||||
# registry @latest. Zero registry writes — packs local
|
||||
# tarballs and installs them globally. Use to test a branch
|
||||
@@ -35,7 +31,6 @@
|
||||
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
||||
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
||||
# MOSAIC_REF — git ref for framework (default: main)
|
||||
# MOSAIC_NEXT — equivalent to --next (set to 1)
|
||||
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
||||
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -54,12 +49,7 @@ FLAG_NO_AUTO_LAUNCH=false
|
||||
FLAG_YES=false
|
||||
FLAG_UNINSTALL=false
|
||||
FLAG_DEV=false
|
||||
FLAG_NEXT=false
|
||||
GIT_REF="${MOSAIC_REF:-main}"
|
||||
GIT_REF_EXPLICIT=false
|
||||
if [[ -n "${MOSAIC_REF:-}" ]]; then
|
||||
GIT_REF_EXPLICIT=true
|
||||
fi
|
||||
|
||||
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
||||
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
|
||||
@@ -71,18 +61,8 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
||||
FLAG_DEV=true
|
||||
fi
|
||||
|
||||
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
|
||||
# source fallback from the permanent next integration branch unless
|
||||
# MOSAIC_REF/--ref explicitly wins.
|
||||
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
|
||||
FLAG_NEXT=true
|
||||
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
|
||||
GIT_REF="next"
|
||||
fi
|
||||
fi
|
||||
|
||||
installer_usage() {
|
||||
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -102,11 +82,9 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 2
|
||||
fi
|
||||
GIT_REF="$2"
|
||||
GIT_REF_EXPLICIT=true
|
||||
shift 2
|
||||
;;
|
||||
--dev) FLAG_DEV=true; shift ;;
|
||||
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
||||
--yes|-y) FLAG_YES=true; shift ;;
|
||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||
@@ -118,24 +96,12 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Explicit refs represent a request for that exact source tree. Keep --next as
|
||||
# a lane selector, but do not install the registry @next package for a different
|
||||
# ref than the permanent next branch.
|
||||
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
|
||||
FLAG_DEV=true
|
||||
fi
|
||||
|
||||
if [[ "$FLAG_YES" == "true" ]]; then
|
||||
export MOSAIC_ASSUME_YES=1
|
||||
fi
|
||||
|
||||
# ─── constants ────────────────────────────────────────────────────────────────
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
|
||||
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||
CLI_PKG="${SCOPE}/mosaic"
|
||||
GATEWAY_PKG="${SCOPE}/gateway"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
|
||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||
|
||||
@@ -150,20 +116,6 @@ fi
|
||||
WORK_DIR=""
|
||||
EXTRACTED_DIR=""
|
||||
|
||||
newest_matching_file() {
|
||||
local dir="$1"
|
||||
local pattern="$2"
|
||||
local matches=()
|
||||
[[ -d "$dir" ]] || return 0
|
||||
shopt -s nullglob
|
||||
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
|
||||
matches=("$dir"/$pattern)
|
||||
shopt -u nullglob
|
||||
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
||||
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
|
||||
ls -1t "${matches[@]}" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||
# Shell-level uninstall for when the CLI is broken or not available.
|
||||
# Handles: framework directory, npm CLI package, npmrc scope line.
|
||||
@@ -227,7 +179,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
|
||||
# Find most recent backup
|
||||
backup=""
|
||||
if [[ -d "$dir" ]]; then
|
||||
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||
backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||
cp "$backup" "$dest"
|
||||
@@ -283,22 +235,6 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||
dim() { echo "${DIM}$*${RESET}"; }
|
||||
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||
|
||||
is_next_registry_lane() {
|
||||
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
|
||||
}
|
||||
|
||||
source_ref_details() {
|
||||
if is_next_registry_lane; then
|
||||
echo "ref: next, --next prerelease lane"
|
||||
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
|
||||
echo "ref: next, --next prerelease lane (build-from-source)"
|
||||
elif [[ "$FLAG_NEXT" == "true" ]]; then
|
||||
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
|
||||
else
|
||||
echo "ref: ${GIT_REF}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
require_cmd() {
|
||||
@@ -321,43 +257,10 @@ installed_cli_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
installed_gateway_version() {
|
||||
local json
|
||||
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
||||
if [[ -n "$json" ]]; then
|
||||
node -e "
|
||||
const d = JSON.parse(process.argv[1]);
|
||||
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
|
||||
process.stdout.write(v);
|
||||
" "$json" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
latest_cli_version() {
|
||||
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_cli_version() {
|
||||
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_gateway_version() {
|
||||
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
|
||||
}
|
||||
|
||||
next_pipeline_suffix() {
|
||||
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
|
||||
}
|
||||
|
||||
next_versions_share_pipeline() {
|
||||
local cli_next="$1"
|
||||
local gateway_next="$2"
|
||||
local cli_pipeline gateway_pipeline
|
||||
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
|
||||
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
|
||||
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
|
||||
}
|
||||
|
||||
version_lt() {
|
||||
node -e "
|
||||
const a=process.argv[1], b=process.argv[2];
|
||||
@@ -450,8 +353,8 @@ install_cli_from_source() {
|
||||
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||
|
||||
local cli_tgz gw_tgz
|
||||
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
|
||||
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
|
||||
|
||||
if [[ ! -f "$cli_tgz" ]]; then
|
||||
fail "CLI tarball was not produced by pnpm pack."
|
||||
@@ -473,49 +376,6 @@ install_cli_from_source() {
|
||||
ok "Installed from source: CLI $(installed_cli_version)"
|
||||
}
|
||||
|
||||
install_next_cli_from_registry() {
|
||||
local cli_next gateway_next
|
||||
cli_next="$(next_cli_version)"
|
||||
gateway_next="$(next_gateway_version)"
|
||||
|
||||
if [[ -z "$cli_next" ]]; then
|
||||
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$gateway_next" ]]; then
|
||||
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
|
||||
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Installing ${CLI_PKG}@${cli_next} from registry…"
|
||||
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
|
||||
warn "Fast CLI @next install failed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
|
||||
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
|
||||
warn "Fast gateway @next install failed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local installed_cli installed_gateway
|
||||
installed_cli="$(installed_cli_version)"
|
||||
installed_gateway="$(installed_gateway_version)"
|
||||
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
|
||||
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
|
||||
return 1
|
||||
fi
|
||||
|
||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
||||
}
|
||||
|
||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||
|
||||
require_cmd node
|
||||
@@ -549,7 +409,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
||||
else
|
||||
dim " Installed: (none)"
|
||||
fi
|
||||
dim " Source: ${REPO_BASE} ($(source_ref_details))"
|
||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
|
||||
echo ""
|
||||
|
||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||
@@ -616,12 +476,8 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
fi
|
||||
|
||||
CURRENT="$(installed_cli_version)"
|
||||
NEXT_GATEWAY=""
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
LATEST=""
|
||||
elif is_next_registry_lane; then
|
||||
LATEST="$(next_cli_version)"
|
||||
NEXT_GATEWAY="$(next_gateway_version)"
|
||||
else
|
||||
LATEST="$(latest_cli_version)"
|
||||
fi
|
||||
@@ -633,19 +489,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)"
|
||||
elif is_next_registry_lane; then
|
||||
if [[ -n "$LATEST" ]]; then
|
||||
dim " Next CLI: ${CLI_PKG}@${LATEST}"
|
||||
else
|
||||
dim " Next CLI: (registry @next unreachable)"
|
||||
fi
|
||||
if [[ -n "$NEXT_GATEWAY" ]]; then
|
||||
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
|
||||
else
|
||||
dim " Next GW: (registry @next unreachable)"
|
||||
fi
|
||||
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
|
||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
|
||||
elif [[ -n "$LATEST" ]]; then
|
||||
dim " Latest: ${CLI_PKG}@${LATEST}"
|
||||
else
|
||||
@@ -656,12 +500,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
|
||||
elif is_next_registry_lane; then
|
||||
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
|
||||
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
|
||||
else
|
||||
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
|
||||
fi
|
||||
elif [[ -z "$LATEST" ]]; then
|
||||
warn "Could not reach registry."
|
||||
elif [[ -z "$CURRENT" ]]; then
|
||||
@@ -678,23 +516,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
ensure_monorepo
|
||||
install_cli_from_source
|
||||
|
||||
# PATH check for npm prefix
|
||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||
warn "$PREFIX/bin is not on your PATH"
|
||||
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||
fi
|
||||
elif is_next_registry_lane; then
|
||||
info "Next mode — trying fast npm @next install from ${REGISTRY}…"
|
||||
if install_next_cli_from_registry; then
|
||||
:
|
||||
else
|
||||
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
|
||||
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
|
||||
ensure_monorepo
|
||||
install_cli_from_source
|
||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||
fi
|
||||
|
||||
# PATH check for npm prefix
|
||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||
warn "$PREFIX/bin is not on your PATH"
|
||||
@@ -803,7 +624,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
local base dir backup_path backup_val
|
||||
base="$(basename "$dest")"
|
||||
dir="$(dirname "$dest")"
|
||||
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||
backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$backup_path" ]]; then
|
||||
backup_val="\"$backup_path\""
|
||||
else
|
||||
@@ -828,7 +649,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
||||
fi
|
||||
|
||||
if node -e "
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const p = process.argv[1];
|
||||
@@ -853,11 +674,9 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
"$MANIFEST_CLI_VERSION" \
|
||||
"$MANIFEST_FW_VERSION" \
|
||||
"$NPMRC_LINES_JSON" \
|
||||
"$RUNTIME_COPIES" 2>/dev/null; then
|
||||
ok "Install manifest written: $MANIFEST_PATH"
|
||||
else
|
||||
warn "Could not write install manifest (non-fatal)"
|
||||
fi
|
||||
"$RUNTIME_COPIES" 2>/dev/null \
|
||||
&& ok "Install manifest written: $MANIFEST_PATH" \
|
||||
|| warn "Could not write install manifest (non-fatal)"
|
||||
|
||||
echo ""
|
||||
ok "Done."
|
||||
|
||||
Reference in New Issue
Block a user