Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d86e392b | ||
|
|
4b8eba95a3 | ||
|
|
3d0a882a63 | ||
|
|
1c3e79a9ed | ||
|
|
46f52eedfe | ||
|
|
df83a9eec2 | ||
|
|
d99ff57e14 | ||
|
|
06046f7675 | ||
|
|
f8d04d1bf4 | ||
|
|
029af418f0 | ||
|
|
51746f44eb | ||
|
|
1bfd0ddd71 | ||
|
|
a3cacac7fb | ||
|
|
b4578dcd0a | ||
|
|
b1254f52f3 | ||
|
|
2a2a87251a | ||
|
|
e6a881a795 | ||
|
|
7962e4302f | ||
|
|
8a901cc19a | ||
|
|
8b7ac5b51e | ||
|
|
96609bdade | ||
|
|
e3a0ee87b3 | ||
|
|
ec260e678f | ||
|
|
b590a5c3d8 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
722163671f | ||
|
|
f158be8003 | ||
|
|
b0f7d26dd9 | ||
|
|
3a1203b2f8 | ||
|
|
df4c591ab4 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc |
@@ -46,6 +46,28 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
||||||
|
# is undiscoverable from inside a session, and an agent that cannot learn a
|
||||||
|
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
||||||
|
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.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
|
||||||
|
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||||
|
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||||
|
# endpoints and ordinary commands through. Both directions are asserted —
|
||||||
|
# a guard that over-blocks gets routed around, which fails just as hard.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
|
||||||
|
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
|
||||||
|
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
|
||||||
|
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
|
||||||
|
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
|
||||||
|
# output. A repo only reaches that once it has enough worktrees, so the
|
||||||
|
# stub supplies the scale instead of the host's own checkout.
|
||||||
|
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# 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
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,21 +23,9 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
- &main_image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -115,84 +103,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
publish-next-npm:
|
|
||||||
image: *node_image
|
|
||||||
# Durable @next integration-line publish. Runs only on next; never writes
|
|
||||||
# the latest dist-tag and never commits the computed prerelease versions.
|
|
||||||
when:
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: next
|
|
||||||
environment:
|
|
||||||
NPM_TOKEN:
|
|
||||||
from_secret: gitea_token
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
|
||||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
|
||||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
|
||||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
|
||||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
|
||||||
const updated = [];
|
|
||||||
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
|
||||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
|
||||||
walk(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePackage(packagePath) {
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
||||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
|
||||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
|
||||||
if (!stableMatch) {
|
|
||||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
|
||||||
}
|
|
||||||
const [, major, minor, patch] = stableMatch;
|
|
||||||
const oldVersion = manifest.version;
|
|
||||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
|
||||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
||||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of roots) walk(root);
|
|
||||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
|
||||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
|
||||||
for (const line of updated) console.log('[publish-next] ' + line);
|
|
||||||
NODE
|
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
|
||||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
|
||||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
|
||||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
|
||||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -224,17 +134,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next gateway publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -245,7 +146,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -271,7 +172,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -30,16 +30,6 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
### Install lanes
|
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
|
||||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
||||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
|
||||||
|
|
||||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -70,6 +60,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||||
|
|
||||||
|
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||||
|
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||||
|
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||||
|
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||||
|
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||||
|
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||||
|
into `~/.pi/agent/extensions/`.
|
||||||
|
|
||||||
### TUI & Gateway
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -371,9 +369,7 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md M3 acceptance:
|
|
||||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
|
||||||
* - #7: max_rows_per_query caps list results.
|
|
||||||
*
|
|
||||||
* Strategy:
|
|
||||||
* - Real PostgreSQL via @mosaicstack/db.
|
|
||||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
|
||||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
|
||||||
*
|
|
||||||
* Run:
|
|
||||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
|
||||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'reflect-metadata';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import type { ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Test, type TestingModule } from '@nestjs/testing';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
createDb,
|
|
||||||
eq,
|
|
||||||
federationGrants,
|
|
||||||
federationPeers,
|
|
||||||
inArray,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
teams,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
users,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { GrantsService } from '../../federation/grants.service.js';
|
|
||||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
|
||||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
|
||||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
|
||||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
|
||||||
import {
|
|
||||||
makeMosaicIssuedCert,
|
|
||||||
makeSelfSignedCert,
|
|
||||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
|
||||||
|
|
||||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
|
||||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
|
||||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
|
||||||
|
|
||||||
interface TestIds {
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly otherUserId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly revokedPeerId: string;
|
|
||||||
readonly activeGrantId: string;
|
|
||||||
readonly revokedGrantId: string;
|
|
||||||
readonly subjectProjectId: string;
|
|
||||||
readonly subjectMissionId: string;
|
|
||||||
readonly otherProjectId: string;
|
|
||||||
readonly teamId: string;
|
|
||||||
readonly unauthorizedTeamId: string;
|
|
||||||
readonly teamProjectId: string;
|
|
||||||
readonly taskIds: readonly string[];
|
|
||||||
readonly excludedTaskIds: readonly string[];
|
|
||||||
readonly subjectNoteId: string;
|
|
||||||
readonly otherUserNoteId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pemToDer(pem: string): Buffer {
|
|
||||||
return Buffer.from(
|
|
||||||
pem
|
|
||||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
|
||||||
.replace(/-----END CERTIFICATE-----/, '')
|
|
||||||
.replace(/\s+/g, ''),
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
|
||||||
return {
|
|
||||||
raw: {
|
|
||||||
socket: {
|
|
||||||
getPeerCertificate: () => ({
|
|
||||||
raw: pemToDer(certPem),
|
|
||||||
serialNumber: CERT_SERIAL_HEX,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGuardContext(request: FastifyRequest): {
|
|
||||||
readonly context: ExecutionContext;
|
|
||||||
readonly sent: { statusCode?: number; payload?: unknown };
|
|
||||||
} {
|
|
||||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
|
||||||
const reply = {
|
|
||||||
status: (statusCode: number) => {
|
|
||||||
sent.statusCode = statusCode;
|
|
||||||
return {
|
|
||||||
header: () => ({
|
|
||||||
send: (payload: unknown) => {
|
|
||||||
sent.payload = payload;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
} as unknown as FastifyReply;
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => request,
|
|
||||||
getResponse: () => reply,
|
|
||||||
}),
|
|
||||||
} as unknown as ExecutionContext;
|
|
||||||
|
|
||||||
return { context, sent };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
|
||||||
await db.insert(users).values({
|
|
||||||
id,
|
|
||||||
name: `${RUN_ID}-${label}`,
|
|
||||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
|
||||||
emailVerified: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
|
||||||
const subjectUserId = `${RUN_ID}-subject`;
|
|
||||||
const otherUserId = `${RUN_ID}-other`;
|
|
||||||
const peerId = crypto.randomUUID();
|
|
||||||
const revokedPeerId = crypto.randomUUID();
|
|
||||||
const activeGrantId = crypto.randomUUID();
|
|
||||||
const revokedGrantId = crypto.randomUUID();
|
|
||||||
const subjectProjectId = crypto.randomUUID();
|
|
||||||
const subjectMissionId = crypto.randomUUID();
|
|
||||||
const otherProjectId = crypto.randomUUID();
|
|
||||||
const teamId = crypto.randomUUID();
|
|
||||||
const unauthorizedTeamId = crypto.randomUUID();
|
|
||||||
const teamProjectId = crypto.randomUUID();
|
|
||||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
|
||||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
|
||||||
const subjectNoteId = crypto.randomUUID();
|
|
||||||
const otherUserNoteId = crypto.randomUUID();
|
|
||||||
|
|
||||||
await insertUser(db, subjectUserId, 'subject');
|
|
||||||
await insertUser(db, otherUserId, 'other');
|
|
||||||
|
|
||||||
await db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: teamId,
|
|
||||||
name: `${RUN_ID} allowed team`,
|
|
||||||
slug: `${RUN_ID}-allowed-team`,
|
|
||||||
ownerId: subjectUserId,
|
|
||||||
managerId: subjectUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: unauthorizedTeamId,
|
|
||||||
name: `${RUN_ID} unauthorized team`,
|
|
||||||
slug: `${RUN_ID}-unauthorized-team`,
|
|
||||||
ownerId: otherUserId,
|
|
||||||
managerId: otherUserId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(teamMembers).values([
|
|
||||||
{ teamId, userId: subjectUserId, role: 'member' },
|
|
||||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: subjectProjectId,
|
|
||||||
name: `${RUN_ID} subject personal project`,
|
|
||||||
ownerType: 'user',
|
|
||||||
ownerId: subjectUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: otherProjectId,
|
|
||||||
name: `${RUN_ID} other personal project`,
|
|
||||||
ownerType: 'user',
|
|
||||||
ownerId: otherUserId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: teamProjectId,
|
|
||||||
name: `${RUN_ID} unauthorized team project`,
|
|
||||||
ownerType: 'team',
|
|
||||||
teamId: unauthorizedTeamId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(missions).values({
|
|
||||||
id: subjectMissionId,
|
|
||||||
name: `${RUN_ID} subject mission`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
userId: subjectUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(tasks).values([
|
|
||||||
{
|
|
||||||
id: taskIds[0],
|
|
||||||
title: `${RUN_ID} visible task 1`,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: taskIds[1],
|
|
||||||
title: `${RUN_ID} visible task 2`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: taskIds[2],
|
|
||||||
title: `${RUN_ID} visible task 3`,
|
|
||||||
projectId: subjectProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: excludedTaskIds[0],
|
|
||||||
title: `${RUN_ID} other user task`,
|
|
||||||
projectId: otherProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: excludedTaskIds[1],
|
|
||||||
title: `${RUN_ID} unauthorized team task`,
|
|
||||||
projectId: teamProjectId,
|
|
||||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: subjectNoteId,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
userId: subjectUserId,
|
|
||||||
notes: `${RUN_ID} subject visible note`,
|
|
||||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: otherUserNoteId,
|
|
||||||
missionId: subjectMissionId,
|
|
||||||
userId: otherUserId,
|
|
||||||
notes: `${RUN_ID} other user note on subject mission`,
|
|
||||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(federationPeers).values([
|
|
||||||
{
|
|
||||||
id: peerId,
|
|
||||||
commonName: `${RUN_ID}-active-peer`,
|
|
||||||
displayName: `${RUN_ID} Active Peer`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: CERT_SERIAL_HEX,
|
|
||||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
|
||||||
state: 'active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: revokedPeerId,
|
|
||||||
commonName: `${RUN_ID}-revoked-peer`,
|
|
||||||
displayName: `${RUN_ID} Revoked Peer`,
|
|
||||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
|
||||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
|
||||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
|
||||||
state: 'active',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.insert(federationGrants).values([
|
|
||||||
{
|
|
||||||
id: activeGrantId,
|
|
||||||
peerId,
|
|
||||||
subjectUserId,
|
|
||||||
status: 'active',
|
|
||||||
scope: {
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: [],
|
|
||||||
filters: {
|
|
||||||
tasks: { include_personal: true, include_teams: [] },
|
|
||||||
notes: { include_personal: true, include_teams: [] },
|
|
||||||
},
|
|
||||||
max_rows_per_query: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: revokedGrantId,
|
|
||||||
peerId,
|
|
||||||
subjectUserId,
|
|
||||||
status: 'revoked',
|
|
||||||
revokedAt: new Date(),
|
|
||||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
|
||||||
scope: {
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subjectUserId,
|
|
||||||
otherUserId,
|
|
||||||
peerId,
|
|
||||||
revokedPeerId,
|
|
||||||
activeGrantId,
|
|
||||||
revokedGrantId,
|
|
||||||
subjectProjectId,
|
|
||||||
subjectMissionId,
|
|
||||||
otherProjectId,
|
|
||||||
teamId,
|
|
||||||
unauthorizedTeamId,
|
|
||||||
teamProjectId,
|
|
||||||
taskIds,
|
|
||||||
excludedTaskIds,
|
|
||||||
subjectNoteId,
|
|
||||||
otherUserNoteId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
|
||||||
if (!ids) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(missionTasks)
|
|
||||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(tasks)
|
|
||||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(missions)
|
|
||||||
.where(eq(missions.id, ids.subjectMissionId))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(projects)
|
|
||||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(teamMembers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(teamMembers.userId, ids.subjectUserId),
|
|
||||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(teams)
|
|
||||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(federationGrants)
|
|
||||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(federationPeers)
|
|
||||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
|
||||||
.catch(() => {});
|
|
||||||
await db
|
|
||||||
.delete(users)
|
|
||||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
|
||||||
let handle: DbHandle;
|
|
||||||
let db: Db;
|
|
||||||
let moduleRef: TestingModule;
|
|
||||||
let guard: FederationAuthGuard;
|
|
||||||
let listController: ListController;
|
|
||||||
let ids: TestIds | undefined;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
handle = createDb(PG_URL);
|
|
||||||
db = handle.db;
|
|
||||||
ids = await seedFixtures(db);
|
|
||||||
|
|
||||||
moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [ListController],
|
|
||||||
providers: [
|
|
||||||
{ provide: DB, useValue: db },
|
|
||||||
GrantsService,
|
|
||||||
FederationAuthGuard,
|
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
guard = moduleRef.get(FederationAuthGuard);
|
|
||||||
listController = moduleRef.get(ListController);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
|
||||||
const malformedOidCert = await makeSelfSignedCert();
|
|
||||||
const request = makeFederationRequest(malformedOidCert);
|
|
||||||
const { context, sent } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
|
||||||
expect(sent.statusCode).toBe(401);
|
|
||||||
expect(sent.payload).toMatchObject({
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: expect.stringContaining('missing required OID'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(request.federationContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const revokedCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.revokedGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(revokedCert);
|
|
||||||
const { context, sent } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
|
||||||
expect(sent.statusCode).toBe(403);
|
|
||||||
expect(sent.payload).toMatchObject({
|
|
||||||
error: {
|
|
||||||
code: 'forbidden',
|
|
||||||
message: 'Federation access denied',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(request.federationContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
const response = await listController.list('tasks', request, { limit: 100 });
|
|
||||||
const returnedIds = response.items.map((item) => item['id']);
|
|
||||||
|
|
||||||
expect(response.items).toHaveLength(2);
|
|
||||||
expect(response._truncated).toBe(true);
|
|
||||||
expect(response.nextCursor).toEqual(expect.any(String));
|
|
||||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
|
||||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
|
||||||
for (const excludedId of ids!.excludedTaskIds) {
|
|
||||||
expect(returnedIds).not.toContain(excludedId);
|
|
||||||
}
|
|
||||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
const response = await listController.list('notes', request, { limit: 10 });
|
|
||||||
const returnedIds = response.items.map((item) => item['id']);
|
|
||||||
|
|
||||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
|
||||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
|
||||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed for unsupported list resources', async () => {
|
|
||||||
expect(ids).toBeDefined();
|
|
||||||
const activeCert = await makeMosaicIssuedCert({
|
|
||||||
grantId: ids!.activeGrantId,
|
|
||||||
subjectUserId: ids!.subjectUserId,
|
|
||||||
});
|
|
||||||
const request = makeFederationRequest(activeCert);
|
|
||||||
const { context } = makeGuardContext(request);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
|
||||||
|
|
||||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is not supported',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -16,9 +14,6 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -60,14 +55,6 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
private async checkCache(): Promise<ServiceStatusDto> {
|
||||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
|
||||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
|
||||||
// ioredis connection on every admin health check (which would spam
|
|
||||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
|
||||||
// signals "no cache backend to measure" for this tier.
|
|
||||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
|
||||||
return { status: 'ok', latencyMs: 0 };
|
|
||||||
}
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
function buildService(): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
redis as never,
|
mockRedis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
@@ -131,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
expect(ttl).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/provider login remains available without Redis on the local tier', async () => {
|
|
||||||
const localService = buildService(null);
|
|
||||||
const payload: SlashCommandPayload = {
|
|
||||||
command: 'provider',
|
|
||||||
args: 'login anthropic',
|
|
||||||
conversationId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await localService.execute(payload, userScope);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).not.toContain('token=');
|
|
||||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
|
||||||
expect(mockRedis.set).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -446,16 +443,14 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
if (this.redis) {
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
await this.redis.set(
|
||||||
await this.redis.set(
|
key,
|
||||||
key,
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
'EX',
|
||||||
'EX',
|
300,
|
||||||
300,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// On Local tier there is no Redis — skip the ioredis connection.
|
|
||||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
|
||||||
if (config?.queue?.type === 'local') return null;
|
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
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);
|
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 { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||||
import { GetController } from './server/verbs/get.controller.js';
|
|
||||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||||
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
|
|||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||||
EnrollmentController,
|
|
||||||
FederationController,
|
|
||||||
CapabilitiesController,
|
|
||||||
ListController,
|
|
||||||
GetController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
FederationGetQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createPgliteDb,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
projects,
|
|
||||||
runPgliteMigrations,
|
|
||||||
teams,
|
|
||||||
users,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
|
||||||
import { FederationGetQueryService } from '../get-query.service.js';
|
|
||||||
|
|
||||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'credentials',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
|
||||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
|
||||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
|
||||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
|
||||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
|
||||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
|
||||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
|
||||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
|
||||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
|
||||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
|
||||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
|
||||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
|
||||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
|
||||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
|
||||||
|
|
||||||
let dbHandle: DbHandle | undefined;
|
|
||||||
|
|
||||||
function makeService() {
|
|
||||||
return new FederationGetQueryService({} as Db);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbService() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
return new FederationGetQueryService(dbHandle.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedNotesFixture() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbHandle.db.insert(users).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_USER_ID,
|
|
||||||
name: 'Federation Subject',
|
|
||||||
email: `${SUBJECT_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_USER_ID,
|
|
||||||
name: 'Federation Other',
|
|
||||||
email: `${OTHER_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: TEAM_ID,
|
|
||||||
name: 'FED-M3-06 Team',
|
|
||||||
slug: 'fed-m3-06-team',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
managerId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_TEAM_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Team',
|
|
||||||
slug: 'fed-m3-06-unauthorized-team',
|
|
||||||
ownerId: OTHER_USER_ID,
|
|
||||||
managerId: OTHER_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Personal Project',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
ownerType: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Team Project',
|
|
||||||
teamId: TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Project',
|
|
||||||
teamId: UNAUTHORIZED_TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missions).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Personal Mission',
|
|
||||||
projectId: PERSONAL_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Team Mission',
|
|
||||||
projectId: TEAM_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_MISSION_ID,
|
|
||||||
name: 'FED-M3-06 Unauthorized Mission',
|
|
||||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: OTHER_USER_ID,
|
|
||||||
notes: 'other user note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
missionId: PERSONAL_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on personal mission',
|
|
||||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
missionId: UNAUTHORIZED_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note outside grant-visible missions',
|
|
||||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationGetQueryService', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
|
||||||
await runPgliteMigrations(dbHandle);
|
|
||||||
await seedNotesFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await dbHandle?.close();
|
|
||||||
dbHandle = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'credentials federation get access is not implemented in M3',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows personal memory reads without requiring team lookup', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'memory',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
|
||||||
(
|
|
||||||
service as unknown as {
|
|
||||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
|
||||||
}
|
|
||||||
).listSubjectTeamIds = listSubjectTeamIds;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'tasks',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
});
|
|
||||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'credentials federation get is not implemented',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
...CREDENTIAL_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
},
|
|
||||||
id: 'row-1',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
status: 'found',
|
|
||||||
item: {
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
content: 'subject note on team mission',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Note is outside the federated scope',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import type {
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../../scope.service.js';
|
|
||||||
import { GetController } from '../get.controller.js';
|
|
||||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
|
||||||
|
|
||||||
const FEDERATION_CONTEXT = {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 1,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(): FastifyRequest {
|
|
||||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowedScope(
|
|
||||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
|
||||||
): FederationScopeEvaluationResult {
|
|
||||||
return { allowed: true, filter };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts?: {
|
|
||||||
scopeResult?: FederationScopeEvaluationResult;
|
|
||||||
queryResult?: FederationGetQueryResult;
|
|
||||||
}) {
|
|
||||||
const scope = {
|
|
||||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
|
||||||
};
|
|
||||||
const query = {
|
|
||||||
evaluateReadAccess: vi.fn(),
|
|
||||||
get: vi.fn().mockResolvedValue(
|
|
||||||
opts?.queryResult ?? {
|
|
||||||
status: 'found',
|
|
||||||
item: {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller: new GetController(scope as never, query as never),
|
|
||||||
scope,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GetController', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
|
||||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
|
||||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
|
||||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
|
||||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
|
||||||
|
|
||||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
|
||||||
context: FEDERATION_CONTEXT,
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 1,
|
|
||||||
nativeRbac: query,
|
|
||||||
});
|
|
||||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
|
||||||
expect(response).toEqual({
|
|
||||||
item: {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
_source: 'local',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Federation context missing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
|
||||||
const { controller, query } = makeController({
|
|
||||||
scopeResult: {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
|
||||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'not_found' } },
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Task is outside the federated scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
|
||||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
|
||||||
...TASK_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
};
|
|
||||||
const { controller } = makeController({
|
|
||||||
scopeResult: allowedScope(unsupportedFilter),
|
|
||||||
queryResult: {
|
|
||||||
status: 'denied',
|
|
||||||
reason: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Unsupported federation get resource: unknown-resource',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty ids before evaluating scope', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation get query layer (FED-M3-06).
|
|
||||||
*
|
|
||||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
|
||||||
* FederationScopeService have established the subject user, allowed resource,
|
|
||||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
|
||||||
* deferred to M4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
insights,
|
|
||||||
or,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import { DB } from '../../../database/database.module.js';
|
|
||||||
import type {
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../scope.service.js';
|
|
||||||
|
|
||||||
export interface FederationGetQueryRequest {
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
readonly id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
|
||||||
readonly status: 'found';
|
|
||||||
readonly item: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryNotFoundResult {
|
|
||||||
readonly status: 'not_found';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationGetQueryDeniedResult {
|
|
||||||
readonly status: 'denied';
|
|
||||||
readonly reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
|
||||||
| FederationGetQueryFoundResult<T>
|
|
||||||
| FederationGetQueryNotFoundResult
|
|
||||||
| FederationGetQueryDeniedResult;
|
|
||||||
|
|
||||||
type RowObject = Record<string, unknown>;
|
|
||||||
|
|
||||||
function firstRow<T>(rows: T[]): T | undefined {
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowBelongsToAccessibleProjectOrMission(
|
|
||||||
row: { projectId?: string | null; missionId?: string | null },
|
|
||||||
projectIds: readonly string[],
|
|
||||||
missionIds: readonly string[],
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
|
||||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async evaluateReadAccess(
|
|
||||||
request: FederationNativeRbacRequest,
|
|
||||||
): Promise<FederationNativeRbacResult> {
|
|
||||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
|
||||||
details: { resource: request.resource },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.resource === 'memory') {
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T extends RowObject = RowObject>(
|
|
||||||
request: FederationGetQueryRequest,
|
|
||||||
): Promise<FederationGetQueryResult<T>> {
|
|
||||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getByResource(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
switch (filter.resource) {
|
|
||||||
case 'tasks':
|
|
||||||
return this.getTask(filter, id);
|
|
||||||
case 'notes':
|
|
||||||
return this.getNote(filter, id);
|
|
||||||
case 'memory':
|
|
||||||
return this.getMemory(filter, id);
|
|
||||||
case 'credentials':
|
|
||||||
case 'api_keys':
|
|
||||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
status: 'denied',
|
|
||||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, subjectUserId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
|
||||||
const clauses = [];
|
|
||||||
if (filter.includePersonal) {
|
|
||||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
|
||||||
}
|
|
||||||
if (filter.teamIds.length > 0) {
|
|
||||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
|
||||||
// rows are authorized through projects.teamId, while ownerId remains the
|
|
||||||
// user who created/bootstrapped the project.
|
|
||||||
clauses.push(
|
|
||||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
|
||||||
if (projectIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: missions.id })
|
|
||||||
.from(missions)
|
|
||||||
.where(inArray(missions.projectId, [...projectIds]));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTask(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const row = firstRow(
|
|
||||||
await this.db
|
|
||||||
.select({
|
|
||||||
id: tasks.id,
|
|
||||||
title: tasks.title,
|
|
||||||
description: tasks.description,
|
|
||||||
status: tasks.status,
|
|
||||||
priority: tasks.priority,
|
|
||||||
projectId: tasks.projectId,
|
|
||||||
missionId: tasks.missionId,
|
|
||||||
assignee: tasks.assignee,
|
|
||||||
tags: tasks.tags,
|
|
||||||
dueDate: tasks.dueDate,
|
|
||||||
metadata: tasks.metadata,
|
|
||||||
createdAt: tasks.createdAt,
|
|
||||||
updatedAt: tasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(tasks)
|
|
||||||
.where(eq(tasks.id, id))
|
|
||||||
.limit(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
|
||||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'found', item: row as RowObject };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getNote(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const row = firstRow(
|
|
||||||
await this.db
|
|
||||||
.select({
|
|
||||||
id: missionTasks.id,
|
|
||||||
missionId: missionTasks.missionId,
|
|
||||||
taskId: missionTasks.taskId,
|
|
||||||
userId: missionTasks.userId,
|
|
||||||
status: missionTasks.status,
|
|
||||||
content: missionTasks.notes,
|
|
||||||
createdAt: missionTasks.createdAt,
|
|
||||||
updatedAt: missionTasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(eq(missionTasks.id, id))
|
|
||||||
.limit(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!row || row.content === null || row.content === '') {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
|
|
||||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
|
||||||
// Scope-visible missions must intersect with subject ownership; team scope
|
|
||||||
// narrows mission IDs but never widens note reads to another user's rows.
|
|
||||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
|
||||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = { ...row } as RowObject;
|
|
||||||
delete item['userId'];
|
|
||||||
return { status: 'found', item };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMemory(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
id: string,
|
|
||||||
): Promise<FederationGetQueryResult> {
|
|
||||||
const [insightRow, preferenceRow] = await Promise.all([
|
|
||||||
this.db
|
|
||||||
.select({
|
|
||||||
id: insights.id,
|
|
||||||
userId: insights.userId,
|
|
||||||
kind: insights.source,
|
|
||||||
content: insights.content,
|
|
||||||
category: insights.category,
|
|
||||||
relevanceScore: insights.relevanceScore,
|
|
||||||
metadata: insights.metadata,
|
|
||||||
createdAt: insights.createdAt,
|
|
||||||
updatedAt: insights.updatedAt,
|
|
||||||
})
|
|
||||||
.from(insights)
|
|
||||||
.where(eq(insights.id, id))
|
|
||||||
.limit(1)
|
|
||||||
.then(firstRow),
|
|
||||||
this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
userId: preferences.userId,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(eq(preferences.id, id))
|
|
||||||
.limit(1)
|
|
||||||
.then(firstRow),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const candidates = [insightRow, preferenceRow].filter(
|
|
||||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
|
||||||
);
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return { status: 'not_found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filter.includePersonal) {
|
|
||||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
|
||||||
if (!accessible) {
|
|
||||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = { ...accessible } as RowObject;
|
|
||||||
delete item['userId'];
|
|
||||||
return { status: 'found', item };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation get verb (FED-M3-06).
|
|
||||||
*
|
|
||||||
* POST /api/federation/v1/get/:resource/:id
|
|
||||||
*
|
|
||||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
|
||||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
|
||||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
|
||||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
|
||||||
* request or response bodies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FederationInvalidRequestError,
|
|
||||||
FederationNotFoundError,
|
|
||||||
FederationScopeViolationError,
|
|
||||||
FederationUnauthorizedError,
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
type FederationGetResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
import { FederationScopeService } from '../scope.service.js';
|
|
||||||
import { FederationGetQueryService } from './get-query.service.js';
|
|
||||||
|
|
||||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
|
||||||
|
|
||||||
function scopeDenyToHttpException(deny: {
|
|
||||||
readonly statusCode: 400 | 403;
|
|
||||||
readonly message: string;
|
|
||||||
}): HttpException {
|
|
||||||
const ErrorClass =
|
|
||||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
|
||||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/get')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class GetController {
|
|
||||||
constructor(
|
|
||||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
|
||||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post(':resource/:id')
|
|
||||||
async get(
|
|
||||||
@Param('resource') resource: string,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Req() request: FastifyRequest,
|
|
||||||
): Promise<FederationGetResponse<FederatedRow>> {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (id.trim().length === 0) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeResult = await this.scope.evaluateAccess({
|
|
||||||
context: request.federationContext,
|
|
||||||
resource,
|
|
||||||
requestedLimit: 1,
|
|
||||||
nativeRbac: this.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scopeResult.allowed) {
|
|
||||||
throw scopeDenyToHttpException(scopeResult.deny);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
|
||||||
if (result.status === 'not_found') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result.status === 'denied') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationScopeViolationError(result.reason, {
|
|
||||||
resource,
|
|
||||||
id,
|
|
||||||
grantId: request.federationContext.grantId,
|
|
||||||
peerId: request.federationContext.peerId,
|
|
||||||
subjectUserId: request.federationContext.subjectUserId,
|
|
||||||
}).toEnvelope(),
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
useFactory: (): QueueHandle => {
|
||||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
|
||||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
|
||||||
if (config?.queue?.type === 'local') return null;
|
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
inject: [MOSAIC_CONFIG],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
useFactory: (handle: QueueHandle) => handle.redis,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
@Optional()
|
|
||||||
@Inject(GC_QUEUE_HANDLE)
|
|
||||||
private readonly handle: QueueHandle | null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle?.close().catch(() => {});
|
await this.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).resolves.toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
|
||||||
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
|
||||||
|
|
||||||
const result = await localService.collect('local-session');
|
|
||||||
|
|
||||||
expect(result.sessionId).toBe('local-session');
|
|
||||||
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
|
||||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
|
||||||
'local-session',
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||||
@Optional()
|
|
||||||
@Inject(REDIS)
|
|
||||||
private readonly redis: QueueHandle['redis'] | null,
|
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,10 +29,8 @@ export class SessionGCService {
|
|||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* duration, which can cause latency spikes under production key volumes.
|
||||||
* Returns an empty population on the Local tier where Redis is disabled.
|
|
||||||
*/
|
*/
|
||||||
private async scanKeys(pattern: string): Promise<string[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
if (!this.redis) return [];
|
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -52,14 +47,12 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
// 1. Valkey: delete all session-scoped keys
|
||||||
if (this.redis) {
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
if (valkeyKeys.length > 0) {
|
||||||
if (valkeyKeys.length > 0) {
|
await this.redis.del(...valkeyKeys);
|
||||||
await this.redis.del(...valkeyKeys);
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
|
||||||
if (!this.queueService.isEnabled()) {
|
|
||||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
|
||||||
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { SystemOverrideService } from './system-override.service.js';
|
|
||||||
|
|
||||||
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
|
||||||
|
|
||||||
describe('SystemOverrideService local tier', () => {
|
|
||||||
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
|
||||||
const service = new SystemOverrideService(localConfig);
|
|
||||||
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
|
||||||
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
|
||||||
|
|
||||||
await service.set('shared-session', 'first override', firstScope);
|
|
||||||
await service.set('shared-session', 'second override', secondScope);
|
|
||||||
|
|
||||||
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
|
||||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
|
||||||
|
|
||||||
await service.clear('shared-session', firstScope);
|
|
||||||
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
|
||||||
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalOverrideEntry {
|
|
||||||
condensed: string;
|
|
||||||
fragments: OverrideFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SystemOverrideService implements OnApplicationShutdown {
|
export class SystemOverrideService {
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
private readonly handle: QueueHandle | null;
|
private readonly handle: QueueHandle;
|
||||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
|
||||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
@Optional()
|
this.handle = createQueue();
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {
|
|
||||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
|
||||||
await this.handle?.close().catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) {
|
|
||||||
const key = scopedSessionId(sessionId, scope);
|
|
||||||
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
|
||||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
|
||||||
entry.condensed = await this.condenseOverrides(
|
|
||||||
entry.fragments.map((fragment) => fragment.text),
|
|
||||||
);
|
|
||||||
this.localStore.set(key, entry);
|
|
||||||
this.logger.debug(
|
|
||||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
if (!this.handle) {
|
|
||||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
|
||||||
}
|
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) return;
|
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
if (!this.handle) {
|
|
||||||
this.localStore.delete(scopedSessionId(sessionId, scope));
|
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { QueueService } from './queue.service.js';
|
|
||||||
|
|
||||||
const localConfig = {
|
|
||||||
queue: { type: 'local' },
|
|
||||||
} as MosaicConfig;
|
|
||||||
|
|
||||||
describe('QueueService local tier', () => {
|
|
||||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
|
||||||
const service = new QueueService(null, localConfig);
|
|
||||||
|
|
||||||
expect(service.isEnabled()).toBe(false);
|
|
||||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
|
||||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
|
||||||
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
|
||||||
await expect(service.listJobs()).resolves.toEqual([]);
|
|
||||||
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
|
||||||
ok: false,
|
|
||||||
message: 'BullMQ is disabled on local tier.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,9 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
|
||||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
|
||||||
private readonly enabled: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
private readonly logService: LogService | null,
|
||||||
@Optional()
|
|
||||||
@Inject(MOSAIC_CONFIG)
|
|
||||||
private readonly mosaicConfig: MosaicConfig | null,
|
|
||||||
) {
|
) {
|
||||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
this.connection = getConnection();
|
||||||
this.connection = this.enabled
|
|
||||||
? getConnection()
|
|
||||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
|
||||||
isEnabled(): boolean {
|
|
||||||
return this.enabled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
if (this.enabled) {
|
this.logger.log('QueueService initialised (BullMQ)');
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
|
||||||
} else {
|
|
||||||
this.logger.log(
|
|
||||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
if (this.enabled) {
|
await this.closeAll();
|
||||||
await this.closeAll();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* Get or create a BullMQ Queue for the given queue name.
|
||||||
* Returns null on Local tier where BullMQ is disabled.
|
|
||||||
*/
|
*/
|
||||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||||
if (!this.enabled) return null;
|
|
||||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
queue = new Queue<T>(name, { connection: this.connection });
|
||||||
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Add a BullMQ repeatable job (cron-style).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
* No-op on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.enabled) {
|
const queue = this.getQueue<T>(queueName);
|
||||||
this.logger.debug(
|
|
||||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const queue = this.getQueue<T>(queueName)!;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||||
if (!this.enabled) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const queue = this.getQueue(queueName);
|
const queue = this.getQueue(queueName);
|
||||||
if (!queue) return 0;
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
* Returns null on Local tier where BullMQ is disabled.
|
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(
|
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||||
queueName: string,
|
|
||||||
handler: JobHandler<T>,
|
|
||||||
): Worker<T> | null {
|
|
||||||
if (!this.enabled) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const worker = new Worker<T>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
* Returns an empty healthy result on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
if (!this.enabled) {
|
|
||||||
return { queues: {}, healthy: true };
|
|
||||||
}
|
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
* Returns empty array on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
if (!this.enabled) return [];
|
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [status]
|
? [status]
|
||||||
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* job IDs are not globally unique — they are scoped to their queue.
|
||||||
* Returns an error on Local tier.
|
|
||||||
*/
|
*/
|
||||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const sep = compositeId.lastIndexOf('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||||
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Pause a queue by name.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
+184
@@ -102,6 +102,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pi Persistent Goal Loop (#1150)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
||||||
|
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
||||||
|
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
||||||
|
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
||||||
|
reached.
|
||||||
|
|
||||||
|
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
||||||
|
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
#### In scope
|
||||||
|
|
||||||
|
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
||||||
|
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
||||||
|
`mosaic pi` load it alongside the core Mosaic extension when present.
|
||||||
|
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
||||||
|
operations without silently replacing an active goal.
|
||||||
|
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
||||||
|
restored on session start and tree navigation, and never rely on a compaction summary as its
|
||||||
|
source of truth.
|
||||||
|
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
||||||
|
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
||||||
|
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
||||||
|
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
||||||
|
or `blocked` status without requiring a redundant model turn.
|
||||||
|
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
||||||
|
evidence-bearing verification report. Any continuation report or successful compaction during
|
||||||
|
verification SHALL reset the verification sequence.
|
||||||
|
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
||||||
|
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
||||||
|
continuation without re-entering compaction handlers.
|
||||||
|
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
||||||
|
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
||||||
|
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
||||||
|
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
||||||
|
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
||||||
|
its native session files used by `appendEntry()`.
|
||||||
|
|
||||||
|
#### Out of scope
|
||||||
|
|
||||||
|
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
||||||
|
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
||||||
|
`/goal` arguments.
|
||||||
|
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
||||||
|
supervisor owns process durability.
|
||||||
|
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
||||||
|
|
||||||
|
### User and stakeholder requirements
|
||||||
|
|
||||||
|
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
||||||
|
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
||||||
|
exhausted, or cancelled.
|
||||||
|
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
||||||
|
extension asset to `~/.pi/agent/extensions/`.
|
||||||
|
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
||||||
|
sync path.
|
||||||
|
|
||||||
|
### Non-functional requirements
|
||||||
|
|
||||||
|
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
||||||
|
completion without non-empty reported evidence.
|
||||||
|
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
||||||
|
context injection, and stale-timer cancellation on session shutdown.
|
||||||
|
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
||||||
|
active agent's final terminating tool call.
|
||||||
|
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
||||||
|
credentials or hidden model reasoning.
|
||||||
|
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
||||||
|
provider/network access.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
||||||
|
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
||||||
|
emitted in deterministic order while absent optional files remain backward-compatible.
|
||||||
|
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
||||||
|
refusal, and bounded input handling.
|
||||||
|
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
||||||
|
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
||||||
|
unmet goal without duplicate scheduling.
|
||||||
|
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
||||||
|
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
||||||
|
branch state is reconstructed correctly.
|
||||||
|
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
||||||
|
continuation, while pause/cancel/blocked states do not restart.
|
||||||
|
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
||||||
|
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
||||||
|
issue #1150 closes.
|
||||||
|
|
||||||
|
### Constraints, risks, and assumptions
|
||||||
|
|
||||||
|
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
||||||
|
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
||||||
|
tool results.
|
||||||
|
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
||||||
|
second verification pass, explicit semantic limitations, and operator-visible reports.
|
||||||
|
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
||||||
|
and paused terminal states.
|
||||||
|
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
||||||
|
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
||||||
|
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
||||||
|
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
||||||
|
add independent or deterministic validators.
|
||||||
|
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
||||||
|
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
||||||
|
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
||||||
|
is requested.
|
||||||
|
|
||||||
|
### Testing and delivery intent
|
||||||
|
|
||||||
|
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
||||||
|
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
||||||
|
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
||||||
|
reviewed squash PR to `main` with terminal-green CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
@@ -146,6 +268,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
|
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.
|
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)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||||
|
|
||||||
|
## Pi persistent goals
|
||||||
|
|
||||||
|
- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
|
||||||
|
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
|
||||||
|
- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
|
||||||
|
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
|
||||||
|
|
||||||
## Fleet configuration management
|
## Fleet configuration management
|
||||||
|
|
||||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+13
-10
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
|||||||
|
|
||||||
## Launch chain
|
## Launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| 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. |
|
| 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. |
|
| 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. |
|
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||||
|
|
||||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
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;
|
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.
|
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.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
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.
|
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.
|
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.
|
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
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
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
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`
|
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
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||||
|
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||||
|
|
||||||
|
### Mosaic Pi goal loop
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
||||||
|
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
||||||
|
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
||||||
|
|
||||||
|
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
||||||
|
out-of-range values fall back to the defaults; they do not disable the bounds.
|
||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pi Goal Loop Operations
|
||||||
|
|
||||||
|
The reviewed runtime asset is deployed at
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
||||||
|
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
||||||
|
competing lifecycle controllers.
|
||||||
|
|
||||||
|
Operational checks:
|
||||||
|
|
||||||
|
1. Run `mosaic pi` and verify `/goal help` is available.
|
||||||
|
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
||||||
|
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
||||||
|
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
||||||
|
in goals, pause reasons, or evidence.
|
||||||
|
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
||||||
|
abort the current goal-driven run when Pi is busy.
|
||||||
|
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
||||||
|
5. Use `/goal cancel` before replacing an unfinished goal.
|
||||||
|
|
||||||
|
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
||||||
|
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
||||||
|
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
||||||
|
supervisor.
|
||||||
|
|||||||
+82
-13
@@ -9,8 +9,9 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,17 +196,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
|||||||
|
|
||||||
A pre-push hook enforces this mechanically.
|
A pre-push hook enforces this mechanically.
|
||||||
|
|
||||||
### CI Publish Channels
|
|
||||||
|
|
||||||
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
|
|
||||||
|
|
||||||
| Source | npm packages | Gateway image |
|
|
||||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
|
|
||||||
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
|
|
||||||
|
|
||||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding New Agent Tools
|
## Adding New Agent Tools
|
||||||
@@ -396,6 +386,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
|||||||
canonical root directly. Codex still relies on the existing full skill-sync
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||||
|
|
||||||
|
## Pi Persistent Goal Extension
|
||||||
|
|
||||||
|
The source of the Mosaic-owned Pi goal controller is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
||||||
|
TypeScript file adapter therefore deploy the same reviewed source to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
||||||
|
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
||||||
|
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
||||||
|
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
||||||
|
not have the goal file yet.
|
||||||
|
|
||||||
|
### Lifecycle design
|
||||||
|
|
||||||
|
| Pi API | Goal-controller responsibility |
|
||||||
|
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||||
|
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
||||||
|
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
||||||
|
| `context` | Inject the active goal contract before every provider request |
|
||||||
|
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
||||||
|
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
||||||
|
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
||||||
|
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
||||||
|
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
||||||
|
|
||||||
|
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
||||||
|
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
||||||
|
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
||||||
|
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
||||||
|
to verify, continue, or stop.
|
||||||
|
|
||||||
|
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
||||||
|
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
||||||
|
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
||||||
|
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
||||||
|
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
||||||
|
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
||||||
|
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
||||||
|
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
||||||
|
while ordinary typed fields such as `token: string` remain intact.
|
||||||
|
|
||||||
|
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
||||||
|
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
||||||
|
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
||||||
|
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
||||||
|
|
||||||
|
### Tests and local smoke workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
||||||
|
src/runtime/pi-goal-extension.spec.ts \
|
||||||
|
src/commands/launch.spec.ts \
|
||||||
|
src/config/file-adapter.test.ts
|
||||||
|
|
||||||
|
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For an additive local smoke test without reseeding unrelated live framework files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -D -m 0644 \
|
||||||
|
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
||||||
|
~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
|
||||||
|
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
||||||
|
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
||||||
|
|
||||||
## API Endpoint Reference
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
+57
-15
@@ -8,9 +8,10 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||||
8. [Telemetry](#telemetry)
|
8. [Sub-package Commands](#sub-package-commands)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [Telemetry](#telemetry)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,18 +176,8 @@ Or use the direct URL:
|
|||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
non-interactive use:
|
||||||
Install lanes:
|
|
||||||
|
|
||||||
| Lane | Command | Source |
|
|
||||||
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
||||||
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
|
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
|
|
||||||
|
|
||||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
|
|
||||||
Flags for non-interactive use:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--yes # Accept all defaults
|
--yes # Accept all defaults
|
||||||
@@ -317,6 +308,57 @@ mosaic prdy
|
|||||||
mosaic quality-rails
|
mosaic quality-rails
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Pi Persistent Goals
|
||||||
|
|
||||||
|
`mosaic pi` loads a Mosaic-owned goal extension from
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
||||||
|
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
||||||
|
runtime assets.
|
||||||
|
|
||||||
|
Start Pi, then set a goal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/goal set Deliver the feature, tests, documentation, and verification evidence
|
||||||
|
# Shorthand:
|
||||||
|
/goal Deliver the feature, tests, documentation, and verification evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
Control and inspect the loop with:
|
||||||
|
|
||||||
|
| Command | Behavior |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
||||||
|
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
||||||
|
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
||||||
|
| `/goal cancel` | Cancel the goal and remove its active status |
|
||||||
|
| `/goal help` | Show command help |
|
||||||
|
|
||||||
|
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
||||||
|
completed model/tool turn. The agent ends each work cycle with the structured
|
||||||
|
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
||||||
|
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
||||||
|
verification.
|
||||||
|
|
||||||
|
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
||||||
|
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
||||||
|
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
||||||
|
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
||||||
|
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
||||||
|
|
||||||
|
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
||||||
|
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
||||||
|
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
||||||
|
launching Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
||||||
|
```
|
||||||
|
|
||||||
|
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
||||||
|
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
||||||
|
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
||||||
|
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Claude Code Skill Registration
|
### Claude Code Skill Registration
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# #1099 pipefail + early-exit sweep
|
||||||
|
|
||||||
|
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||||
|
|
||||||
|
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
||||||
|
|
||||||
|
## Tranche 1 — runtime and general scripts
|
||||||
|
|
||||||
|
| Baseline site | Verdict | Construction / reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
||||||
|
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
||||||
|
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
||||||
|
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
||||||
|
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
||||||
|
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
||||||
|
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
||||||
|
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
||||||
|
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
||||||
|
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
||||||
|
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
||||||
|
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||||
|
|
||||||
|
## Explicit withdrawn / non-load-bearing sites
|
||||||
|
|
||||||
|
| Baseline site | Verdict | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||||
|
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
||||||
|
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
||||||
|
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||||
|
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||||
|
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||||
|
|
||||||
|
## Tranche 2 — non-wake test harnesses
|
||||||
|
|
||||||
|
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
||||||
|
|
||||||
|
| Baseline site(s) | Verdict | Construction |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
||||||
|
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
||||||
|
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
||||||
|
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
||||||
|
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
||||||
|
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
||||||
|
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
||||||
|
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
||||||
|
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
||||||
|
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||||
|
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||||
|
|
||||||
|
## Tranche 3 — wake validation harnesses
|
||||||
|
|
||||||
|
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
||||||
|
|
||||||
|
| Baseline site(s) | Verdict | Construction |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
||||||
|
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
||||||
|
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
||||||
|
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
||||||
|
|
||||||
|
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #1019 — Zero-timeout queue-guard harness race
|
||||||
|
|
||||||
|
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||||
|
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||||
|
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||||
|
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||||
|
2. Every case that intends status classification positively proves provider observation.
|
||||||
|
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||||
|
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||||
|
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||||
|
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||||
|
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||||
|
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||||
|
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||||
|
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||||
|
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||||
|
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||||
|
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||||
|
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||||
|
|
||||||
|
## Review remediation — semantic timeout vs. liveness bound
|
||||||
|
|
||||||
|
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||||
|
|
||||||
|
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||||
|
|
||||||
|
Remediation:
|
||||||
|
|
||||||
|
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||||
|
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||||
|
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||||
|
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||||
|
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||||
|
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||||
|
|
||||||
|
Post-review evidence:
|
||||||
|
|
||||||
|
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||||
|
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||||
|
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||||
|
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||||
|
|
||||||
|
## 60% context hold
|
||||||
|
|
||||||
|
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# #1099 — pipefail + early-exit sweep
|
||||||
|
|
||||||
|
## Scope and decisions
|
||||||
|
|
||||||
|
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
||||||
|
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
||||||
|
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
||||||
|
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
||||||
|
|
||||||
|
## Tranche 1 TDD
|
||||||
|
|
||||||
|
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
||||||
|
|
||||||
|
Construction choices:
|
||||||
|
|
||||||
|
- here-string/file redirection for scalar grep assertions;
|
||||||
|
- full capture then parameter expansion for first-line selection;
|
||||||
|
- arrays/`mapfile` for complete populations;
|
||||||
|
- direct jq/awk/grep selection where one tool can express the property;
|
||||||
|
- no `|| true` added to a load-bearing assertion.
|
||||||
|
|
||||||
|
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||||
|
|
||||||
|
## Tranche 2 TDD
|
||||||
|
|
||||||
|
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||||
|
|
||||||
|
## Tranche 3 TDD
|
||||||
|
|
||||||
|
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
||||||
|
|
||||||
|
## Verification so far
|
||||||
|
|
||||||
|
- `bash -n` on every changed shell script: pass.
|
||||||
|
- structural Node control: pass.
|
||||||
|
- `test-mutate-push-guard.sh`: 8/8 pass.
|
||||||
|
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||||
|
- `test-send-message-socket.sh`: pass.
|
||||||
|
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||||
|
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||||
|
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# #1150 — Pi persistent goal extension
|
||||||
|
|
||||||
|
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
||||||
|
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
||||||
|
- **Branch:** `feat/1150-pi-goal-extension`
|
||||||
|
- **Mode:** Delivery
|
||||||
|
- **Status:** in progress
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
||||||
|
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
||||||
|
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
||||||
|
Pi's native session manager remains the owner of session entries.
|
||||||
|
|
||||||
|
## Scope and acceptance source
|
||||||
|
|
||||||
|
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
||||||
|
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
||||||
|
tested locally before framework delivery.
|
||||||
|
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
||||||
|
reports from the working agent rather than a second model request after every turn. This keeps the
|
||||||
|
loop testable and avoids doubling model cost while making the limitation explicit.
|
||||||
|
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
||||||
|
bounded numeric environment overrides.
|
||||||
|
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
||||||
|
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
||||||
|
test and would touch unrelated framework-owned files.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- Working estimate: 30K implementation/review tokens.
|
||||||
|
- Hard user cap: none stated.
|
||||||
|
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
||||||
|
smoke workflows against the installed runtime.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Update PRD and create tracking/scratchpad artifacts.
|
||||||
|
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
||||||
|
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
||||||
|
verification, and continuation deduplication.
|
||||||
|
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
||||||
|
5. Add framework-sync/deployment acceptance coverage.
|
||||||
|
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
||||||
|
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
||||||
|
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
||||||
|
|
||||||
|
## TDD decision
|
||||||
|
|
||||||
|
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
||||||
|
or false terminal state can cause unbounded work or premature completion.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] Issue #1150 created through Mosaic wrapper.
|
||||||
|
- [x] Isolated worktree created from `origin/main`.
|
||||||
|
- [x] PRD requirements and acceptance criteria added.
|
||||||
|
- [x] Task scratchpad created.
|
||||||
|
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
||||||
|
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
||||||
|
implemented.
|
||||||
|
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
||||||
|
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
||||||
|
deployed SHA-256 are identical.
|
||||||
|
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
||||||
|
no extension errors.
|
||||||
|
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
||||||
|
PostgreSQL-only root integration case.
|
||||||
|
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
||||||
|
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
||||||
|
|
||||||
|
## Tests and evidence
|
||||||
|
|
||||||
|
### Situational
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
||||||
|
- final: 25 passed.
|
||||||
|
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
||||||
|
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
||||||
|
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
||||||
|
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
||||||
|
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
||||||
|
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
||||||
|
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
||||||
|
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
||||||
|
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
||||||
|
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
||||||
|
- Live deployment/load evidence:
|
||||||
|
- source/deployed SHA-256:
|
||||||
|
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
||||||
|
- `get_commands` identified `/goal` as an extension command sourced from
|
||||||
|
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
||||||
|
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
||||||
|
and zero extension errors.
|
||||||
|
- no goal extension exists under `~/.pi` extension paths.
|
||||||
|
|
||||||
|
### Baseline
|
||||||
|
|
||||||
|
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
||||||
|
package build input and its final source passed the standalone Pi type check.
|
||||||
|
- `pnpm typecheck`: 45/45 tasks passed.
|
||||||
|
- `pnpm lint`: 25/25 tasks passed.
|
||||||
|
- `pnpm format:check`: passed.
|
||||||
|
- Final Mosaic package components:
|
||||||
|
- Vitest: 82 files / 1,539 tests passed.
|
||||||
|
- full `test:framework-shell` harness passed.
|
||||||
|
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
||||||
|
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
||||||
|
in the full shell harness.
|
||||||
|
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
||||||
|
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
||||||
|
focused suites remained green.
|
||||||
|
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
||||||
|
(6 files / 12 tests skipped by their existing environment gates).
|
||||||
|
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
||||||
|
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
||||||
|
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
||||||
|
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
||||||
|
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
||||||
|
the safe verification paths.
|
||||||
|
|
||||||
|
### Independent review
|
||||||
|
|
||||||
|
- Codex code review: approved, 0 findings across 15 files.
|
||||||
|
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
||||||
|
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
||||||
|
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
||||||
|
history.
|
||||||
|
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
||||||
|
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
||||||
|
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
||||||
|
|
||||||
|
## Risks and blockers
|
||||||
|
|
||||||
|
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
||||||
|
additive framework file and did not reload or restart unrelated sessions.
|
||||||
|
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
||||||
|
evidence twice. Operators must still inspect consequential outcomes.
|
||||||
|
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
||||||
|
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
||||||
|
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
||||||
|
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
||||||
|
session must be removed.
|
||||||
|
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
||||||
|
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
||||||
|
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
||||||
|
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
||||||
|
merge.
|
||||||
|
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
||||||
|
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
||||||
|
passed in a separate run, and CI remains the merge authority.
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Scratchpad — FED-M3-06 get verb
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
|
|
||||||
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
|
|
||||||
- Unit coverage for controller pipeline + query service RBAC guardrails
|
|
||||||
- Register controller/service in `FederationModule`
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service.
|
|
||||||
2. Return one `_source: "local"` tagged item on success.
|
|
||||||
3. Return federation error envelopes:
|
|
||||||
- `404 not_found` when the resource id does not exist.
|
|
||||||
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
|
|
||||||
- `400 invalid_request` for malformed ids/scope requests.
|
|
||||||
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
|
|
||||||
- Review-change coverage added for comment 15971:
|
|
||||||
- get note access now requires subject ownership AND authorized mission intersection.
|
|
||||||
- missing federation context returns structured `401 unauthorized` envelope.
|
|
||||||
- unsupported get resources fail closed with structured denial.
|
|
||||||
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
|
|
||||||
- `pnpm build` — pass (23 successful tasks before review changes).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
|
|
||||||
- `pnpm format:check` — pass (re-run after review changes).
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# B1 / @next Durable Publish Pipeline — Design
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
|
|
||||||
|
|
||||||
Every merge to `next` publishes:
|
|
||||||
|
|
||||||
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
|
|
||||||
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
|
|
||||||
|
|
||||||
The existing stable release behavior remains isolated to `main` / tags.
|
|
||||||
|
|
||||||
## Registry verification
|
|
||||||
|
|
||||||
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
|
|
||||||
|
|
||||||
Pre-implementation checks:
|
|
||||||
|
|
||||||
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
|
|
||||||
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
|
|
||||||
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
|
|
||||||
|
|
||||||
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
|
|
||||||
|
|
||||||
## Version scheme
|
|
||||||
|
|
||||||
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
|
|
||||||
|
|
||||||
For each non-private `@mosaicstack/*` package:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<target-stable>-next.<CI_PIPELINE_NUMBER>
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
|
|
||||||
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
|
|
||||||
- `target-stable` is the package's current committed stable version with the patch component incremented.
|
|
||||||
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
|
|
||||||
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
|
|
||||||
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
|
|
||||||
- The prerelease does not overtake the future stable `0.0.49`.
|
|
||||||
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
|
|
||||||
|
|
||||||
## Branch and tag guardrails
|
|
||||||
|
|
||||||
| Pipeline path | Branch/event | Publishes | Forbidden |
|
|
||||||
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
|
|
||||||
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
|
|
||||||
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
|
|
||||||
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
|
|
||||||
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
|
|
||||||
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
|
|
||||||
|
|
||||||
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
|
|
||||||
|
|
||||||
## Implementation plan
|
|
||||||
|
|
||||||
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
|
|
||||||
2. Keep existing `publish-npm` on `main` / tags only.
|
|
||||||
3. Add `publish-next-npm` for `next` push/manual only:
|
|
||||||
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
|
|
||||||
- preflight registry dist-tag metadata;
|
|
||||||
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
|
|
||||||
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
|
|
||||||
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
|
|
||||||
4. Split image `when` anchors:
|
|
||||||
- `image_build_when` includes `next` and is used by `build-gateway`;
|
|
||||||
- `main_image_build_when` keeps appservice/web on main/tags only.
|
|
||||||
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
|
|
||||||
|
|
||||||
## Risk controls
|
|
||||||
|
|
||||||
- Auth/registry failures are fatal.
|
|
||||||
- No manual image build/push path is introduced.
|
|
||||||
- No production `latest` tags are touched from `next`.
|
|
||||||
- No `@latest` npm dist-tags are touched from `next`.
|
|
||||||
- All changes live in CI config and docs; no runtime source behavior changes.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# B2 — Fresh-install skills sync path
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Greenfield wizard on `next` reported:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
|
|
||||||
Skills: install failed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/mosaic/tools/_scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
|
|
||||||
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
|
|
||||||
- Point missing-script warnings at the current `tools/_scripts` layout.
|
|
||||||
- Update the finalize skills test fixture to model the fresh framework layout.
|
|
||||||
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
|
|
||||||
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
|
|
||||||
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# B3 — Wizard completion ordering
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
|
|
||||||
|
|
||||||
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
|
|
||||||
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
|
|
||||||
3. gateway config starts/waits for daemon health;
|
|
||||||
4. gateway bootstrap runs.
|
|
||||||
|
|
||||||
The summary needed to be deferred until after the gateway readiness gates.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
|
|
||||||
- Wizard/quick-start paths call finalize with `deferSummary: true`.
|
|
||||||
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
|
|
||||||
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
|
|
||||||
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
|
|
||||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
|
|
||||||
- `pnpm format:check`
|
|
||||||
- `pnpm typecheck`
|
|
||||||
- `pnpm lint`
|
|
||||||
- `pnpm build`
|
|
||||||
- `pnpm test`
|
|
||||||
- Codex code review: approve.
|
|
||||||
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# B4 — Wizard step deduplication
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
|
|
||||||
|
|
||||||
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
|
|
||||||
|
|
||||||
## Diagnosis
|
|
||||||
|
|
||||||
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
|
|
||||||
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
|
|
||||||
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
|
|
||||||
- provider step completed and user intentionally skipped the key, vs.
|
|
||||||
- provider step was never run.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
|
|
||||||
- Completed menu steps now log a skip message instead of re-running their stage.
|
|
||||||
- Quick Start marks Providers and Skills complete after running them.
|
|
||||||
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
|
|
||||||
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
|
|
||||||
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
|
|
||||||
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
|
|
||||||
- `pnpm format:check`
|
|
||||||
- `pnpm typecheck`
|
|
||||||
- `pnpm lint`
|
|
||||||
- `pnpm build`
|
|
||||||
- `pnpm test`
|
|
||||||
- Codex code review: approve.
|
|
||||||
- Codex security review: no findings.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# FED-M3-10 — Federation M3 Integration Tests
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
|
|
||||||
|
|
||||||
## Branch / base
|
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-integration`
|
|
||||||
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
|
|
||||||
- PR base when unblocked: `next`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Real PostgreSQL via `@mosaicstack/db`.
|
|
||||||
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
|
|
||||||
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
|
|
||||||
- Gated by `FEDERATED_INTEGRATION=1`.
|
|
||||||
- No federation harness dependency.
|
|
||||||
|
|
||||||
## Fixture notes
|
|
||||||
|
|
||||||
Aligned with the B2 seed design vocabulary:
|
|
||||||
|
|
||||||
- `tasks` visibility uses personal `projects` + `missions` chain.
|
|
||||||
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
|
|
||||||
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
|
|
||||||
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
|
|
||||||
|
|
||||||
## Current implementation
|
|
||||||
|
|
||||||
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
|
|
||||||
|
|
||||||
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
|
|
||||||
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
|
|
||||||
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
|
|
||||||
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
|
|
||||||
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/types build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/db build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/storage build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/brain build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/queue build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/config build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/auth build` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
|
|
||||||
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings.
|
|
||||||
|
|
||||||
## Push / PR
|
|
||||||
|
|
||||||
- #683 landed in `next`; branch rebased onto `origin/next` before push.
|
|
||||||
- CI is serialized; run queue guard before push.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Installer `--next` fast npm lane — 2026-06-25
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
|
|
||||||
|
|
||||||
## Registry reality check
|
|
||||||
|
|
||||||
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
|
|
||||||
|
|
||||||
Verified before implementation:
|
|
||||||
|
|
||||||
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
|
|
||||||
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
|
|
||||||
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
|
|
||||||
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
|
|
||||||
|
|
||||||
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- `--next` with no explicit ref:
|
|
||||||
1. framework archive from `next`;
|
|
||||||
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
|
|
||||||
3. require both resolved versions to share the same `next.<pipeline>` suffix;
|
|
||||||
4. install the exact resolved package versions;
|
|
||||||
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
|
|
||||||
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
|
|
||||||
- `--dev` remains pure source build.
|
|
||||||
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
|
|
||||||
|
|
||||||
## Install detail
|
|
||||||
|
|
||||||
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
|
|
||||||
|
|
||||||
## Verification notes
|
|
||||||
|
|
||||||
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
|
|
||||||
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
|
|
||||||
- Real temp-prefix fast install succeeded with `@mosaicstack/[email protected]` and `@mosaicstack/[email protected]`.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Scratchpad — installer `--next` lane
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add a prerelease installer lane for the permanent `next` integration branch.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `tools/install.sh`
|
|
||||||
- README/install documentation
|
|
||||||
- Follow-up design note for future npm `@next` prerelease publishing
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
|
|
||||||
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
|
|
||||||
3. Update installer source display/help text.
|
|
||||||
4. Document three lanes:
|
|
||||||
- stable npm `@latest`
|
|
||||||
- prerelease `--next`
|
|
||||||
- contributor `--dev --ref X`
|
|
||||||
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `bash -n tools/install.sh` — pass.
|
|
||||||
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
|
|
||||||
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
|
|
||||||
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
|
|
||||||
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
|
|
||||||
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks).
|
|
||||||
- `pnpm format:check` — pass.
|
|
||||||
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# PR merge squash message field
|
||||||
|
|
||||||
|
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Branch:** `fix/pr-merge-message-field`
|
||||||
|
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||||
|
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||||
|
|
||||||
|
## Binding requirements
|
||||||
|
|
||||||
|
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||||
|
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||||
|
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||||
|
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||||
|
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||||
|
|
||||||
|
## Derived interface decisions
|
||||||
|
|
||||||
|
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||||
|
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||||
|
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||||
|
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||||
|
|
||||||
|
## Canonical delivery plan
|
||||||
|
|
||||||
|
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||||
|
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||||
|
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||||
|
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||||
|
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||||
|
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||||
|
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||||
|
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||||
|
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||||
|
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||||
|
|
||||||
|
## Remediation and current review state
|
||||||
|
|
||||||
|
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||||
|
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||||
|
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||||
|
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||||
|
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||||
|
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||||
|
|
||||||
|
## Disposable provider fixture acceptance
|
||||||
|
|
||||||
|
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||||
|
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||||
|
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||||
|
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||||
|
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||||
|
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||||
|
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||||
|
|
||||||
|
## Fixture preflight
|
||||||
|
|
||||||
|
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||||
|
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||||
|
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||||
|
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||||
|
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||||
|
|
||||||
|
## Fixture result
|
||||||
|
|
||||||
|
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||||
|
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||||
|
- Trailer-shaped lines, verbatim and in order:
|
||||||
|
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||||
|
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||||
|
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||||
|
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||||
|
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||||
|
|
||||||
|
## Current hold point
|
||||||
|
|
||||||
|
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||||
|
|
||||||
|
## Security review 96 remediation
|
||||||
|
|
||||||
|
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||||
|
|
||||||
|
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||||
|
|
||||||
|
Security remediation:
|
||||||
|
|
||||||
|
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||||
|
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||||
|
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||||
|
|
||||||
|
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||||
+1
-2
@@ -10,8 +10,7 @@
|
|||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test",
|
||||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -11,37 +11,9 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||||
import { createConfigService } from '../../src/config/config-service.js';
|
import { createConfigService } from '../../src/config/config-service.js';
|
||||||
import type { SelectOption } from '../../src/prompter/interface.js';
|
|
||||||
import type { MenuSection, WizardState } from '../../src/types.js';
|
|
||||||
|
|
||||||
const gatewayConfigMock = vi.fn();
|
const gatewayConfigMock = vi.fn();
|
||||||
const gatewayBootstrapMock = vi.fn();
|
const gatewayBootstrapMock = vi.fn();
|
||||||
const providerSetupMock = vi.fn();
|
|
||||||
const skillsSelectMock = vi.fn();
|
|
||||||
|
|
||||||
class SequencedMenuPrompter extends HeadlessPrompter {
|
|
||||||
constructor(
|
|
||||||
answers: Record<string, string | boolean | string[]>,
|
|
||||||
private readonly menuChoices: string[],
|
|
||||||
) {
|
|
||||||
super(answers);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async select<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: SelectOption<T>[];
|
|
||||||
initialValue?: T;
|
|
||||||
}): Promise<T> {
|
|
||||||
if (opts.message === 'What would you like to configure?') {
|
|
||||||
const next = this.menuChoices.shift();
|
|
||||||
if (!next) throw new Error('No queued menu choice left');
|
|
||||||
const match = opts.options.find((o) => String(o.value) === next);
|
|
||||||
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
|
|
||||||
return match.value;
|
|
||||||
}
|
|
||||||
return super.select(opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||||
@@ -51,14 +23,6 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
|||||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/stages/provider-setup.js', () => ({
|
|
||||||
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../src/stages/skills-select.js', () => ({
|
|
||||||
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||||
import { runWizard } from '../../src/wizard.js';
|
import { runWizard } from '../../src/wizard.js';
|
||||||
|
|
||||||
@@ -80,16 +44,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
}
|
}
|
||||||
gatewayConfigMock.mockReset();
|
gatewayConfigMock.mockReset();
|
||||||
gatewayBootstrapMock.mockReset();
|
gatewayBootstrapMock.mockReset();
|
||||||
providerSetupMock.mockReset();
|
|
||||||
skillsSelectMock.mockReset();
|
|
||||||
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
|
||||||
state.providerType = 'none';
|
|
||||||
state.completedSections?.add('providers' satisfies MenuSection);
|
|
||||||
});
|
|
||||||
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
|
||||||
state.selectedSkills = [];
|
|
||||||
state.completedSections?.add('skills' satisfies MenuSection);
|
|
||||||
});
|
|
||||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||||
// branch does not call `process.exit(1)` during these tests.
|
// branch does not call `process.exit(1)` during these tests.
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints the success summary only after gateway health succeeds', async () => {
|
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||||
p.log('Gateway is healthy.');
|
|
||||||
return { ready: true, host: 'localhost', port: 14242 };
|
|
||||||
});
|
|
||||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
@@ -168,43 +118,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
skipGatewayNpmInstall: true,
|
skipGatewayNpmInstall: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
|
||||||
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
|
|
||||||
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
|
|
||||||
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
|
|
||||||
|
|
||||||
expect(healthIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(summaryIndex).toBeGreaterThan(healthIndex);
|
|
||||||
expect(readyIndex).toBeGreaterThan(summaryIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not claim success when gateway health reports not ready', async () => {
|
|
||||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
|
||||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
|
||||||
return { ready: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
|
||||||
'Installation mode': 'quick',
|
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
|
||||||
'Your name': 'Tester',
|
|
||||||
'Your pronouns': 'They/Them',
|
|
||||||
'Your timezone': 'UTC',
|
|
||||||
});
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome: tmpDir,
|
|
||||||
sourceDir: tmpDir,
|
|
||||||
prompter,
|
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
|
||||||
skipGatewayNpmInstall: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
|
||||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
|
||||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
|
||||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
|
||||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -230,34 +143,4 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not re-run completed provider or skills menu steps', async () => {
|
|
||||||
const prompter = new SequencedMenuPrompter(
|
|
||||||
{
|
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
|
||||||
'Your name': 'Tester',
|
|
||||||
'Your pronouns': 'They/Them',
|
|
||||||
'Your timezone': 'UTC',
|
|
||||||
},
|
|
||||||
['providers', 'providers', 'skills', 'skills', 'finish'],
|
|
||||||
);
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome: tmpDir,
|
|
||||||
sourceDir: tmpDir,
|
|
||||||
prompter,
|
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
|
||||||
skipGateway: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(providerSetupMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prompter.getLogs()).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.stringContaining('Providers [done] is already complete; skipping.'),
|
|
||||||
expect.stringContaining('Skills [done] is already complete; skipping.'),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
|
|
||||||
1. Injects the full runtime contract via `--append-system-prompt`
|
1. Injects the full runtime contract via `--append-system-prompt`
|
||||||
2. Loads Mosaic skills via `--skill` flags
|
2. Loads Mosaic skills via `--skill` flags
|
||||||
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
||||||
|
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
||||||
4. Detects active missions and injects initial prompts
|
4. Detects active missions and injects initial prompts
|
||||||
|
|
||||||
## Capabilities vs Other Runtimes
|
## Capabilities vs Other Runtimes
|
||||||
@@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
- Native thinking levels replace sequential-thinking MCP
|
- Native thinking levels replace sequential-thinking MCP
|
||||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||||
|
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
||||||
- Native session persistence and resume
|
- Native session persistence and resume
|
||||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||||
|
|
||||||
|
|||||||
@@ -43,16 +43,6 @@ The installer:
|
|||||||
- Runs a health audit
|
- Runs a health audit
|
||||||
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
|
||||||
|
|
||||||
### Install lanes
|
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
|
||||||
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
||||||
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
|
|
||||||
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
|
||||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
|
|
||||||
|
|
||||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
|
|
||||||
|
|
||||||
## First Run
|
## First Run
|
||||||
|
|
||||||
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
||||||
@@ -104,7 +94,14 @@ The launcher:
|
|||||||
1. Verifies `~/.config/mosaic` exists
|
1. Verifies `~/.config/mosaic` exists
|
||||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||||
3. Injects `AGENTS.md` into the runtime
|
3. Injects `AGENTS.md` into the runtime
|
||||||
4. Forwards all arguments to the runtime CLI
|
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
||||||
|
`~/.config/mosaic/runtime/pi/`
|
||||||
|
5. Forwards all arguments to the runtime CLI
|
||||||
|
|
||||||
|
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
||||||
|
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
||||||
|
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
||||||
|
directory.
|
||||||
|
|
||||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||||
|
|
||||||
@@ -118,13 +115,13 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
||||||
├── STANDARDS.md ← Machine-wide standards
|
├── STANDARDS.md ← Machine-wide standards
|
||||||
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
|
||||||
|
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
|
||||||
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
|
||||||
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
|
|
||||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
||||||
│ └── mcp/ ← MCP server configs
|
│ └── mcp/ ← MCP server configs
|
||||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||||
├── skills-local/ ← Local cross-runtime skills
|
├── skills-local/ ← Local cross-runtime skills
|
||||||
@@ -136,7 +133,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
|
|
||||||
| Launch method | Injection mechanism |
|
| Launch method | Injection mechanism |
|
||||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
||||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||||
@@ -184,9 +181,7 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
|
|||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
@@ -196,11 +191,10 @@ The installer rejects unrecognized flags or positional arguments before making c
|
|||||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic sync # Full canonical catalog sync
|
mosaic sync # Full canonical catalog sync
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||||
@@ -210,8 +204,8 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
|||||||
## Health Audit
|
## Health Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic doctor # Standard audit
|
mosaic doctor # Standard audit
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP Registration
|
## MCP Registration
|
||||||
@@ -222,8 +216,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
|
|||||||
To verify or re-register manually:
|
To verify or re-register manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||||
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code MCP Registration
|
### Claude Code MCP Registration
|
||||||
|
|||||||
@@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
|||||||
- Do not auto-resolve data conflicts in shared state files.
|
- Do not auto-resolve data conflicts in shared state files.
|
||||||
- Keep commits scoped to a single logical change set.
|
- Keep commits scoped to a single logical change set.
|
||||||
|
|
||||||
|
## Model Tiering
|
||||||
|
|
||||||
|
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
||||||
|
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
||||||
|
produces a review that passes and proves nothing. Both are defects.
|
||||||
|
|
||||||
|
Tiers are named by **capability class**, so the standard survives a model
|
||||||
|
generation. An operator binds each class to a concrete model id.
|
||||||
|
|
||||||
|
| Class | Use for |
|
||||||
|
| ------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
||||||
|
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
||||||
|
| `judge` | code review, planning, API/compat-sensitive changes |
|
||||||
|
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
||||||
|
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
||||||
|
which is usually the most expensive one.
|
||||||
|
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
||||||
|
with an existing contract is judged, not just built.
|
||||||
|
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
||||||
|
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
||||||
|
by someone other than the author. "It seemed fine" is not evidence.
|
||||||
|
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
||||||
|
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
||||||
|
|
||||||
|
### Where the binding lives
|
||||||
|
|
||||||
|
The class→model map is operator configuration, never framework source: model
|
||||||
|
availability, cost, and quotas differ per operator and per host.
|
||||||
|
|
||||||
|
Resolution order, first hit wins:
|
||||||
|
|
||||||
|
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
||||||
|
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
||||||
|
injects it)
|
||||||
|
3. the framework default — the class names above, with no binding
|
||||||
|
|
||||||
|
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
||||||
|
and 3 exist so a host with no config service still runs. A local override that
|
||||||
|
silently disagrees with the config service is drift — the same failure class the
|
||||||
|
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
||||||
|
|
||||||
## Prompting Contract
|
## Prompting Contract
|
||||||
|
|
||||||
All runtime adapters should inject:
|
All runtime adapters should inject:
|
||||||
|
|||||||
@@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
|||||||
|
|
||||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||||
|
|
||||||
|
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
||||||
|
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
||||||
|
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
||||||
|
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
||||||
|
|
||||||
|
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
||||||
|
supply it explicitly on any host where the provider CLI's default account is an admin.
|
||||||
|
|
||||||
|
| Issues | |
|
||||||
|
| ------------------ | --------------------------------- |
|
||||||
|
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
||||||
|
| `issue-view.sh` | Show one issue |
|
||||||
|
| `issue-list.sh` | List issues |
|
||||||
|
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
||||||
|
| `issue-comment.sh` | Add a comment |
|
||||||
|
| `issue-assign.sh` | Assign or unassign |
|
||||||
|
| `issue-close.sh` | Close an issue |
|
||||||
|
| `issue-reopen.sh` | Reopen a closed issue |
|
||||||
|
|
||||||
|
| Pull requests | |
|
||||||
|
| ---------------- | --------------------------------------------------------- |
|
||||||
|
| `pr-create.sh` | Open a pull request |
|
||||||
|
| `pr-view.sh` | Show one PR |
|
||||||
|
| `pr-list.sh` | List PRs |
|
||||||
|
| `pr-diff.sh` | Fetch a PR's diff |
|
||||||
|
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
||||||
|
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
||||||
|
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
||||||
|
| `pr-merge.sh` | Merge a PR |
|
||||||
|
| `pr-close.sh` | Close a PR without merging |
|
||||||
|
|
||||||
|
| Milestones | |
|
||||||
|
| --------------------- | ------------------ |
|
||||||
|
| `milestone-create.sh` | Create a milestone |
|
||||||
|
| `milestone-list.sh` | List milestones |
|
||||||
|
| `milestone-close.sh` | Close a milestone |
|
||||||
|
|
||||||
|
| Gates and guards | |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||||
|
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
||||||
|
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
||||||
|
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
||||||
|
|
||||||
|
| Context | |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
||||||
|
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
||||||
|
|
||||||
|
| Workspace | |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
||||||
|
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
||||||
|
|
||||||
|
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
||||||
|
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
||||||
|
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
||||||
|
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
||||||
|
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
||||||
|
anyway, under five simultaneous conventions on a single host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Issues
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
||||||
~/.config/mosaic/tools/git/issue-create.sh
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
||||||
~/.config/mosaic/tools/git/issue-close.sh
|
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
||||||
|
```
|
||||||
|
|
||||||
# PRs
|
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
||||||
~/.config/mosaic/tools/git/pr-create.sh
|
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
||||||
~/.config/mosaic/tools/git/pr-merge.sh
|
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
||||||
|
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
||||||
|
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
||||||
|
|
||||||
# Milestones
|
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
||||||
|
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
||||||
|
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
||||||
|
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
||||||
|
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
||||||
|
a missing flag — extend the wrapper.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/git/issue-create.sh --help
|
||||||
|
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
||||||
|
|
||||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
||||||
|
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
||||||
|
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
||||||
|
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
||||||
|
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
||||||
|
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||||
|
placed.
|
||||||
|
|
||||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||||
|
|
||||||
### Code Review (Codex)
|
### Code Review (Codex)
|
||||||
|
|||||||
@@ -52,6 +52,16 @@
|
|||||||
"timeout": 10
|
"timeout": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
|
|||||||
@@ -51,12 +51,26 @@ Skills are discovered from:
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
||||||
|
order:
|
||||||
|
|
||||||
- Session start/end lifecycle hooks
|
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||||
- Active mission detection and context injection
|
and fleet heartbeat reporting.
|
||||||
- Memory routing to `~/.config/mosaic/memory/`
|
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||||
- MACP queue status reporting
|
checks.
|
||||||
|
|
||||||
|
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||||
|
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
||||||
|
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
||||||
|
request, restored from branch-specific session entries, and considered achieved only after two
|
||||||
|
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
||||||
|
goal-state entries are persisted or
|
||||||
|
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
||||||
|
and pass/fail summaries rather than secrets or raw sensitive output.
|
||||||
|
|
||||||
|
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
||||||
|
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
||||||
|
`1..100`.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,10 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
|||||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||||
MARKER="$TEST_ROOT/loader-marker"
|
MARKER="$TEST_ROOT/loader-marker"
|
||||||
LIBRARY="$TEST_ROOT/marker.so"
|
LIBRARY="$TEST_ROOT/marker.so"
|
||||||
|
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
||||||
|
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
||||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||||
|
mkfifo "$FIXTURE_FIFO"
|
||||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
|
|||||||
EOF
|
EOF
|
||||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
tmux -L "$TEST_SOCKET" new-session -d -s _holder \
|
||||||
|
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
|
||||||
|
# tmux starts the pane asynchronously. Wait until its contaminated shell has
|
||||||
|
# loaded the constructor and reached a builtin-only FIFO barrier before
|
||||||
|
# clearing the marker; otherwise that expected constructor can race with the
|
||||||
|
# clean holder assertion below and create a false failure.
|
||||||
|
for _attempt in {1..100}; do
|
||||||
|
[ -e "$FIXTURE_READY" ] && break
|
||||||
|
sleep 0.01
|
||||||
|
done
|
||||||
|
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
|
||||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||||
: > "$MARKER"
|
: > "$MARKER"
|
||||||
@@ -112,6 +125,7 @@ EOF
|
|||||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||||
|
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=
|
MOSAIC_AGENT_MODEL=
|
||||||
@@ -144,7 +158,8 @@ EOF
|
|||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
||||||
|
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
|
||||||
fail "fresh holder retained LD_PRELOAD"
|
fail "fresh holder retained LD_PRELOAD"
|
||||||
fi
|
fi
|
||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
|
|||||||
group_response=$(curl -sk \
|
group_response=$(curl -sk \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
||||||
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
||||||
if [[ -n "$group_pk" ]]; then
|
if [[ -n "$group_pk" ]]; then
|
||||||
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ is_sensitive_key() {
|
|||||||
|
|
||||||
is_generated_key() {
|
is_generated_key() {
|
||||||
case "$1" in
|
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 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ validate_generated_value() {
|
|||||||
local value="$2"
|
local value="$2"
|
||||||
case "$key" in
|
case "$key" in
|
||||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
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_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_RUNTIME)
|
MOSAIC_AGENT_RUNTIME)
|
||||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
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
|
load_environment_file "$GENERATED_ENV" generated
|
||||||
for required_key in \
|
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
|
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" ''
|
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||||
done
|
done
|
||||||
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
|
|||||||
|
|
||||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_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_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
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_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||||
@@ -343,6 +347,7 @@ LAUNCH_ENV=(
|
|||||||
"PATH=$PANE_PATH"
|
"PATH=$PANE_PATH"
|
||||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||||
|
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||||
|
|||||||
@@ -14,6 +14,82 @@ fail() {
|
|||||||
exit 1
|
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'
|
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -62,6 +138,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
|||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
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() {
|
write_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -71,6 +160,7 @@ write_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||||
@@ -88,6 +178,7 @@ run_start() {
|
|||||||
local agent="$2"
|
local agent="$2"
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
|
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -100,19 +191,55 @@ AGENT_VALID="coder0"
|
|||||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
if contains_literal "$valid_args" 'bash -c'; then
|
||||||
fail "launcher constructed a shell command payload"
|
fail "launcher constructed a shell command payload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The pane must start through an absolute clean-environment boundary. Its
|
# The pane must start through an absolute clean-environment boundary. Its
|
||||||
# runtime command remains an argv vector, but no holder/session environment
|
# runtime command remains an argv vector, but no holder/session environment
|
||||||
# control variable can pass through the pane command.
|
# control variable can pass through the pane command.
|
||||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
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
|
# 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.
|
# 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"
|
fail "generated file under a world-writable parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
[ ! -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"
|
: > "$TMUX_CALLS"
|
||||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
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"
|
fail "generated file under a symlinked parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
[ ! -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
|
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||||
@@ -174,8 +301,8 @@ assert_managed_ancestor_rejected() {
|
|||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||||
[ ! -e "$home/work" ] || fail "workdir was created 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"
|
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -196,9 +323,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
|||||||
fail "generated-key shadow was accepted"
|
fail "generated-key shadow was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||||
if echo "$output" | grep -qF 'codex'; then
|
if contains_literal "$output" codex; then
|
||||||
fail "shadow diagnostic leaked value"
|
fail "shadow diagnostic leaked value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -214,9 +341,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
|||||||
fail "arbitrary command override was accepted"
|
fail "arbitrary command override was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||||
fail "command diagnostic leaked command value"
|
fail "command diagnostic leaked command value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -230,7 +357,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
|||||||
fail "world-readable local input was accepted"
|
fail "world-readable local input was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
[ ! -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
|
# 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
|
# 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 \
|
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||||
"$START" coder-pane-boundary
|
"$START" coder-pane-boundary
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
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"
|
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"
|
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
|
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
|
done
|
||||||
|
|
||||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
|
||||||
fail "pane command did not clear its environment"
|
|
||||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
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"
|
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
|
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
|
done
|
||||||
|
|
||||||
write_interaction_generated() {
|
write_interaction_generated() {
|
||||||
@@ -290,6 +427,7 @@ write_interaction_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
|
MOSAIC_GIT_IDENTITY=$agent
|
||||||
MOSAIC_AGENT_CLASS=operator-interaction
|
MOSAIC_AGENT_CLASS=operator-interaction
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
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"
|
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
touch -t 200001010000.00 "$STALE_HB.native"
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
# 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"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
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"
|
fail "interaction wrapper accepted malformed generated data"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
[ ! -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
|
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||||
# interaction policy check only after strict parsing and never starts tmux.
|
# interaction policy check only after strict parsing and never starts tmux.
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "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"
|
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||||
fi
|
fi
|
||||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
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"
|
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"
|
fail "interaction pinned-policy check did not follow strict parsing"
|
||||||
|
|
||||||
# Exact stop derives the socket exclusively from the validated generated
|
# 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_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
contains_line "$stop_args" 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"
|
contains_line "$stop_args" 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"
|
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
if contains_literal "$stop_args" ambient-socket; then
|
||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ function Get-QueueState {
|
|||||||
$state = "$($Payload.state)".ToLowerInvariant()
|
$state = "$($Payload.state)".ToLowerInvariant()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($pending -contains $state) { return "pending" }
|
||||||
|
if ($failure -contains $state) { return "terminal-failure" }
|
||||||
|
if ($success -contains $state) { return "terminal-success" }
|
||||||
|
|
||||||
$values = @()
|
$values = @()
|
||||||
$statuses = @()
|
$statuses = @()
|
||||||
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
|
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
|
||||||
@@ -97,15 +101,7 @@ function Get-QueueState {
|
|||||||
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
|
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
|
||||||
}
|
}
|
||||||
|
|
||||||
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" }
|
||||||
# state of "pending" alongside statuses:null / total_count:0 (a commit
|
|
||||||
# with no CI at all), and honoring that aggregate would poll to the
|
|
||||||
# timeout. With zero contexts there is nothing to wait on.
|
|
||||||
if ($values.Count -eq 0) { return "no-status" }
|
|
||||||
|
|
||||||
if ($pending -contains $state) { return "pending" }
|
|
||||||
if ($failure -contains $state) { return "terminal-failure" }
|
|
||||||
if ($success -contains $state) { return "terminal-success" }
|
|
||||||
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
|
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
|
||||||
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
|
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
|
||||||
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
|
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ except Exception:
|
|||||||
print("malformed")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
# Gitea returns "statuses": null (not []) for a commit with zero status
|
|
||||||
# contexts -- e.g. any repo with no CI configured. Treat null as empty.
|
|
||||||
raw_statuses = payload.get("statuses", [])
|
raw_statuses = payload.get("statuses", [])
|
||||||
if raw_statuses is None:
|
|
||||||
raw_statuses = []
|
|
||||||
raw_state = payload.get("state", "")
|
raw_state = payload.get("state", "")
|
||||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||||
print("malformed")
|
print("malformed")
|
||||||
@@ -79,18 +75,14 @@ for item in statuses:
|
|||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
values.append(raw_value.lower())
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
# state of "pending" alongside total_count:0, and an aggregate with no
|
|
||||||
# contexts behind it must not read as an in-flight pipeline (it would poll
|
|
||||||
# to the timeout). With zero contexts there is nothing to wait on.
|
|
||||||
if not values:
|
|
||||||
print("no-status")
|
|
||||||
elif any(value in pending_values for value in values) or state in pending_values:
|
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(value in failure_values for value in values) or state in failure_values:
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif all(value in success_values for value in values) and state in {"", "success"}:
|
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
'
|
'
|
||||||
@@ -473,18 +465,9 @@ while true; do
|
|||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 3
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
# A head with zero status contexts has no CI queue to wait on.
|
|
||||||
# For push, that is queue-clear (a repo with no CI must remain
|
|
||||||
# pushable) -- mirroring record_cannot_assert's dispositions
|
|
||||||
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
|
|
||||||
# no-status there may just mean CI has not reported yet.
|
|
||||||
if [[ "$PURPOSE" == "push" ]]; then
|
|
||||||
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
|
||||||
exit 3
|
exit 3
|
||||||
;;
|
;;
|
||||||
terminal-failure|malformed|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local remote_url
|
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
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -39,7 +42,10 @@ detect_platform() {
|
|||||||
|
|
||||||
get_repo_info() {
|
get_repo_info() {
|
||||||
local remote_url
|
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
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -240,6 +246,21 @@ PY
|
|||||||
} >&2
|
} >&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() {
|
get_gitea_login_for_host() {
|
||||||
local host="${1:-}"
|
local host="${1:-}"
|
||||||
local login
|
local login
|
||||||
|
|||||||
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; 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
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
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
|
fi
|
||||||
gitea_issue_close_api
|
gitea_issue_close_api
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
#
|
||||||
# port and its explicit default form compare equal.
|
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||||
|
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||||
|
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||||
|
# provider's own correct answer about a write that landed — a deterministic
|
||||||
|
# false negative on every comment posted against such a deployment. The
|
||||||
|
# scheme is also not what this check defends: the forgeries it exists to
|
||||||
|
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||||
|
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||||
|
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||||
|
#
|
||||||
|
# Port: an implicit port and its own scheme's default compare equal, so
|
||||||
|
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||||
|
# because a different port is a different service on the same host.
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
default_port = 80 if scheme == "http" else 443
|
if scheme in ("http", "https"):
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
scheme_class = "web"
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
default_port = 80 if scheme == "http" else 443
|
||||||
|
port = None if parsed.port in (None, default_port) else parsed.port
|
||||||
|
else:
|
||||||
|
scheme_class = scheme
|
||||||
|
port = parsed.port
|
||||||
|
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
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
|
fi
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
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
|
fi
|
||||||
gitea_issue_view_api
|
gitea_issue_view_api
|
||||||
else
|
else
|
||||||
|
|||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||||
|
# worktree on a fleet host.
|
||||||
|
#
|
||||||
|
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||||
|
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||||
|
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||||
|
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||||
|
# and a decision an agent has to make is a decision that drifts.
|
||||||
|
#
|
||||||
|
# So this script makes NO placement decision available. The caller supplies a
|
||||||
|
# branch name. Every path is DERIVED:
|
||||||
|
#
|
||||||
|
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||||
|
# itself already be a worktree)
|
||||||
|
# REPO_NAME <- basename of the main worktree
|
||||||
|
# REPO_PARENT <- dirname of the main worktree
|
||||||
|
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||||
|
# SLUG <- branch with '/' replaced by '-'
|
||||||
|
# WT_PATH <- $WT_ROOT/$SLUG
|
||||||
|
#
|
||||||
|
# The derivation puts the worktree on the same filesystem as the object store
|
||||||
|
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||||
|
# properties that make the checkout cheap and — via `git worktree list` —
|
||||||
|
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||||
|
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||||
|
# mosaic-worktree.sh list this repo's worktrees + state
|
||||||
|
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||||
|
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||||
|
#
|
||||||
|
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||||
|
# commits absent from every remote, or holding ignored files that are not of the
|
||||||
|
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||||
|
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||||
|
# by size or age. --force overrides it and is yours to type deliberately.
|
||||||
|
#
|
||||||
|
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REPO_HINT=""
|
||||||
|
ARGS=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
# ---- mechanical derivation -------------------------------------------------
|
||||||
|
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||||
|
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||||
|
# would nest worktrees inside worktrees.
|
||||||
|
resolve_repo() {
|
||||||
|
local start="${REPO_HINT:-$PWD}"
|
||||||
|
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||||
|
|| die "not inside a git repository: $start"
|
||||||
|
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
|
||||||
|
# first match closes the read end while git is still writing, git takes SIGPIPE,
|
||||||
|
# and under `set -euo pipefail` the command substitution returns 141 and this
|
||||||
|
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
|
||||||
|
# printing nothing at all.
|
||||||
|
#
|
||||||
|
# Whether it happens depends on how much git still had to write when awk left,
|
||||||
|
# so the failure is a function of REPO SIZE: fine on a repo with three
|
||||||
|
# worktrees, reliably broken on one with seventy. That is backwards — the repos
|
||||||
|
# this helper exists to serve are exactly the ones that accumulated worktrees,
|
||||||
|
# and it silently did nothing on those while working everywhere it was tried.
|
||||||
|
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
|
||||||
|
#
|
||||||
|
# The file's own comment block below already names this class for `head -200`
|
||||||
|
# and removed that cap for the same reason. The `exit` here is the same defect
|
||||||
|
# in the same file, so the rule is now uniform: nothing in this script closes a
|
||||||
|
# git pipe early. Dropping `exit` costs one pass over a few KB.
|
||||||
|
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
|
||||||
|
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||||
|
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||||
|
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||||
|
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||||
|
}
|
||||||
|
|
||||||
|
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||||
|
|
||||||
|
derive_path() {
|
||||||
|
local branch="$1"
|
||||||
|
[ -n "$branch" ] || die "branch name required"
|
||||||
|
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||||
|
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||||
|
# silently produce the layout we are trying to eliminate.
|
||||||
|
assert_not_home() {
|
||||||
|
local p="$1" home_real repo_real
|
||||||
|
home_real="$(cd "$HOME" && pwd -P)"
|
||||||
|
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||||
|
case "$repo_real/" in
|
||||||
|
"$home_real"/*)
|
||||||
|
die "refusing: derived path is under \$HOME ($p).
|
||||||
|
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||||
|
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||||
|
configuration, credentials, state and caches — not checkouts." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- work-loss evidence ----------------------------------------------------
|
||||||
|
# Two independent questions, both answered from git, neither from size or age:
|
||||||
|
# dirty — anything uncommitted in the tree
|
||||||
|
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||||
|
# precious — IGNORED files git will not mention and will not miss
|
||||||
|
#
|
||||||
|
# The third question is not obvious and was missed on the first pass. An
|
||||||
|
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||||
|
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||||
|
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||||
|
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||||
|
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||||
|
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||||
|
# is also why nothing else is holding a copy.
|
||||||
|
#
|
||||||
|
# So ignored files count as work unless they are the well-known regenerable
|
||||||
|
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||||
|
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||||
|
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||||
|
# The list stays short and conservative for that reason.
|
||||||
|
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||||
|
|
||||||
|
# These three run under `set -euo pipefail` inside command substitution, which
|
||||||
|
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
||||||
|
# silently. Two ways that bites, one of which shipped:
|
||||||
|
#
|
||||||
|
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
||||||
|
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
||||||
|
# `rm` exit 1 with no message and no removal — found by review.
|
||||||
|
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
||||||
|
# with 201 dirty files into the same silent abort. Not reported; it is the
|
||||||
|
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
||||||
|
# the cap only ever protected output that is now never printed.
|
||||||
|
#
|
||||||
|
# Every one of them therefore ends in a total, and every stage that can
|
||||||
|
# legitimately exit nonzero says so explicitly.
|
||||||
|
wt_dirty() {
|
||||||
|
local out
|
||||||
|
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
||||||
|
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
||||||
|
|
||||||
|
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||||
|
# directory entry instead of being enumerated and then discarded.
|
||||||
|
wt_precious() {
|
||||||
|
local ignored
|
||||||
|
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||||
|
| awk '/^!! /{print substr($0,4)}' || true)"
|
||||||
|
[ -n "$ignored" ] || { printf '0'; return 0; }
|
||||||
|
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
wt_state() {
|
||||||
|
local wt="$1" d u p
|
||||||
|
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||||
|
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||||
|
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||||
|
else
|
||||||
|
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- commands --------------------------------------------------------------
|
||||||
|
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||||
|
|
||||||
|
cmd_new() {
|
||||||
|
local branch="${1:-}" base=""
|
||||||
|
shift || true
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||||
|
|
||||||
|
resolve_repo
|
||||||
|
local path; path="$(derive_path "$branch")"
|
||||||
|
assert_not_home "$path"
|
||||||
|
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
echo "exists: $path"
|
||||||
|
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$WT_ROOT"
|
||||||
|
|
||||||
|
# Existing branch -> check it out. New branch -> create from base (default:
|
||||||
|
# the remote's default branch if resolvable, else current HEAD).
|
||||||
|
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||||
|
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||||
|
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||||
|
else
|
||||||
|
if [ -z "$base" ]; then
|
||||||
|
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$base" ] || base="HEAD"
|
||||||
|
fi
|
||||||
|
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
worktree: $path
|
||||||
|
branch: $branch
|
||||||
|
|
||||||
|
Removal is part of this task, not a later chore. When the work is pushed:
|
||||||
|
mosaic-worktree.sh rm $branch
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
resolve_repo
|
||||||
|
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||||
|
git -C "$MAIN_WT" worktree list --porcelain \
|
||||||
|
| awk '/^worktree /{print substr($0,10)}' \
|
||||||
|
| while read -r wt; do
|
||||||
|
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||||
|
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_rm() {
|
||||||
|
local branch="${1:-}" force=0
|
||||||
|
shift || true
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||||
|
done
|
||||||
|
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||||
|
|
||||||
|
resolve_repo
|
||||||
|
local path; path="$(derive_path "$branch")"
|
||||||
|
[ -d "$path" ] || die "no worktree at $path"
|
||||||
|
|
||||||
|
local d u p
|
||||||
|
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||||
|
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||||
|
die "refusing to remove $path
|
||||||
|
uncommitted files: $d
|
||||||
|
unpushed commits: $u
|
||||||
|
ignored, not disposable: $p
|
||||||
|
Commit and push first — that is the contract. Ignored files are counted because
|
||||||
|
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||||
|
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||||
|
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||||
|
If this work is genuinely disposable, re-run with --force."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||||
|
if [ "$force" -eq 1 ]; then
|
||||||
|
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||||
|
else
|
||||||
|
git -C "$MAIN_WT" worktree remove "$path"
|
||||||
|
fi
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
echo "removed: $path"
|
||||||
|
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_gc() {
|
||||||
|
local apply=0
|
||||||
|
[ "${1:-}" = "--apply" ] && apply=1
|
||||||
|
resolve_repo
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
git -C "$MAIN_WT" worktree list --porcelain \
|
||||||
|
| awk '/^worktree /{print substr($0,10)}' \
|
||||||
|
| while read -r wt; do
|
||||||
|
[ "$wt" = "$MAIN_WT" ] && continue
|
||||||
|
local_state="$(wt_state "$wt")"
|
||||||
|
case "$local_state" in
|
||||||
|
SAFE*)
|
||||||
|
if [ "$apply" -eq 1 ]; then
|
||||||
|
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||||
|
else
|
||||||
|
echo "reclaimable (clean + fully pushed): $wt"
|
||||||
|
fi ;;
|
||||||
|
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
git -C "$MAIN_WT" worktree prune
|
||||||
|
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
new) cmd_new "$@" ;;
|
||||||
|
path) cmd_path "$@" ;;
|
||||||
|
list) cmd_list "$@" ;;
|
||||||
|
rm) cmd_rm "$@" ;;
|
||||||
|
gc) cmd_gc "$@" ;;
|
||||||
|
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
|
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||||
|
esac
|
||||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
|||||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||||
|
|
||||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
||||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
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
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ MERGE_METHOD="squash"
|
|||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
|
CO_AUTHOR_TRAILERS=false
|
||||||
|
ESCALATE_TO=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -27,12 +29,16 @@ Options:
|
|||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after merge
|
||||||
--dry-run Run metadata/login preflight without merging
|
--dry-run Run metadata/login preflight without merging
|
||||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
|
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -57,9 +63,25 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--expect-head)
|
--expect-head)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
EXPECT_HEAD="$2"
|
EXPECT_HEAD="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--co-author-trailers)
|
||||||
|
CO_AUTHOR_TRAILERS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--escalate-to)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --escalate-to requires one principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ESCALATE_TO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -88,17 +110,30 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
|||||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
|
||||||
|
echo "Error: --escalate-to must be one exact principal name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
|
||||||
|
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||||
|
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||||
|
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -122,70 +157,442 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
write_curl_auth_config() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local mode="$1" credential="$2"
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
printf '%s' "$credential" | python3 -c '
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
import sys
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
mode = sys.argv[1]
|
||||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
credential = sys.stdin.read()
|
||||||
|
if not credential or any(char in credential for char in "\r\n"):
|
||||||
|
raise SystemExit(1)
|
||||||
|
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
if mode == "token":
|
||||||
|
print(f"header = \"Authorization: token {escaped}\"")
|
||||||
|
elif mode == "basic":
|
||||||
|
print(f"user = \"{escaped}\"")
|
||||||
|
else:
|
||||||
|
raise SystemExit(1)
|
||||||
|
' "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
|
LAST_GITEA_ERROR=""
|
||||||
|
MERGE_TEMP_DIRS=()
|
||||||
|
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
|
||||||
|
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
|
||||||
|
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
|
||||||
|
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
|
||||||
|
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
GITEA_CURL_BOUNDS=(
|
||||||
|
--max-filesize "$GITEA_CURL_MAX_BYTES"
|
||||||
|
--max-time "$GITEA_CURL_MAX_TIME"
|
||||||
|
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
|
||||||
|
)
|
||||||
|
|
||||||
|
format_gitea_error_response() {
|
||||||
|
local response_file="$1"
|
||||||
|
python3 - "$response_file" <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
head_sha, delete_branch = sys.argv[1:]
|
with open(sys.argv[1], "rb") as handle:
|
||||||
|
raw = handle.read(65536)
|
||||||
|
try:
|
||||||
|
response = json.loads(raw.decode("utf-8", errors="replace"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
message = "non-JSON response omitted"
|
||||||
|
else:
|
||||||
|
if isinstance(response, dict):
|
||||||
|
message = response.get("message") or response.get("error")
|
||||||
|
if not message and response.get("errors") is not None:
|
||||||
|
message = json.dumps(response["errors"], separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
message = None
|
||||||
|
if not message:
|
||||||
|
message = "JSON response contained no error message"
|
||||||
|
message = str(message)
|
||||||
|
if len(message) > 500:
|
||||||
|
message = message[:500] + "..."
|
||||||
|
print(ascii(message))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_merge_temp_dirs() {
|
||||||
|
local path
|
||||||
|
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||||
|
[[ -n "$path" ]] && rm -rf -- "$path"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup_merge_temp_dirs EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
fetch_gitea_pr_head() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
|
local response_file raw_code api_url auth_config curl_rc
|
||||||
|
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
pull = json.load(handle)
|
||||||
|
head = pull.get("head") if isinstance(pull, dict) else None
|
||||||
|
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(sha)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
|
||||||
|
rm -f "$response_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$response_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_gitea_pr_commits() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
|
||||||
|
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
|
||||||
|
printf '[]' > "$combined_file"
|
||||||
|
|
||||||
|
page=1
|
||||||
|
while true; do
|
||||||
|
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
|
||||||
|
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! page_count=$(python3 - "$page_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
page = json.load(handle)
|
||||||
|
if not isinstance(page, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(len(page))
|
||||||
|
PY
|
||||||
|
); then
|
||||||
|
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
|
||||||
|
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
combined = json.load(handle)
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
page = json.load(handle)
|
||||||
|
json.dump(combined + page, sys.stdout, separators=(",", ":"))
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
|
||||||
|
rm -f "$page_file" "$combined_file" "$merged_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$merged_file" "$combined_file"
|
||||||
|
rm -f "$page_file"
|
||||||
|
|
||||||
|
if [[ "$page_count" -lt 50 ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
page=$((page + 1))
|
||||||
|
if [[ "$page" -gt 1000 ]]; then
|
||||||
|
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
|
||||||
|
rm -f "$combined_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat "$combined_file"
|
||||||
|
rm -f "$combined_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
|
||||||
|
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
|
||||||
|
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
|
||||||
|
build_coauthor_message_fields() {
|
||||||
|
local commits_file="$1" context_file="$2" head_file="$3"
|
||||||
|
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
commits_path, context_path, head_path = sys.argv[1:]
|
||||||
|
with open(commits_path, encoding="utf-8") as handle:
|
||||||
|
commits = json.load(handle)
|
||||||
|
head_sha = open(head_path, encoding="utf-8").read().strip()
|
||||||
|
context_parts = open(context_path, "rb").read().split(b"\0")
|
||||||
|
if len(context_parts) != 4 or context_parts[-1] != b"":
|
||||||
|
raise SystemExit(1)
|
||||||
|
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
|
||||||
|
|
||||||
|
if not isinstance(commits, list) or not commits:
|
||||||
|
print(
|
||||||
|
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
|
||||||
|
f"Refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not poster:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR poster login is empty; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
trailers = []
|
||||||
|
head_seen = False
|
||||||
|
for item in commits:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
|
||||||
|
raise SystemExit(75)
|
||||||
|
sha = str(item.get("sha") or "<unknown>")
|
||||||
|
if sha == head_sha:
|
||||||
|
head_seen = True
|
||||||
|
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
|
||||||
|
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
||||||
|
email = str(commit_author.get("email") or "").strip()
|
||||||
|
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
|
||||||
|
login = str(provider_author.get("login") or "").strip()
|
||||||
|
|
||||||
|
if not login:
|
||||||
|
diagnostic_email = email or "<missing>"
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has author.login=NULL while "
|
||||||
|
f"commit.author.email={diagnostic_email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if (
|
||||||
|
not email.isascii()
|
||||||
|
or not email.isprintable()
|
||||||
|
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||||
|
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"BLOCK: commit {sha!r} has unusable linked identity "
|
||||||
|
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if login == poster or login in seen:
|
||||||
|
continue
|
||||||
|
seen.add(login)
|
||||||
|
trailers.append(f"Co-authored-by: {login} <{email}>")
|
||||||
|
|
||||||
|
if not head_seen:
|
||||||
|
print(
|
||||||
|
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not trailers:
|
||||||
|
print("{}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
if not title:
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
|
||||||
|
print(
|
||||||
|
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
|
||||||
|
f"escalate to named principal '{principal}'.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(75)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"MergeTitleField": title,
|
||||||
|
"MergeMessageField": "\n".join(trailers),
|
||||||
|
}, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_gitea_api_attempt() {
|
||||||
|
local host="$1" auth_mode="$2" credential="$3"
|
||||||
|
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
|
||||||
|
LAST_GITEA_HTTP_CODE="000"
|
||||||
|
LAST_GITEA_ERROR=""
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
|
mkdir -p "$work_root"
|
||||||
|
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
|
||||||
|
chmod 0700 "$attempt_dir"
|
||||||
|
MERGE_TEMP_DIRS+=("$attempt_dir")
|
||||||
|
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
|
||||||
|
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
|
||||||
|
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
|
||||||
|
printf '{}' > "$fields_file"
|
||||||
|
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
|
||||||
|
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
|
||||||
|
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
|
||||||
|
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
|
||||||
|
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
|
||||||
|
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
attempt_rc=$?
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
|
||||||
|
return "$attempt_rc"
|
||||||
|
fi
|
||||||
|
rm -f "$commits_file" "$context_file" "$head_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
fields = json.load(handle)
|
||||||
|
head_sha, delete_branch = sys.argv[2:]
|
||||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
if delete_branch == "true":
|
if delete_branch == "true":
|
||||||
payload["delete_branch_after_merge"] = True
|
payload["delete_branch_after_merge"] = True
|
||||||
|
payload.update(fields)
|
||||||
|
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
|
||||||
|
if payload.get("Do") != "squash" or set(payload) - allowed:
|
||||||
|
raise SystemExit(1)
|
||||||
print(json.dumps(payload, separators=(",", ":")))
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
PY
|
PY
|
||||||
)
|
then
|
||||||
|
rm -f "$body_file" "$fields_file" "$payload_file"
|
||||||
token=$(get_gitea_token "$host" || true)
|
return 1
|
||||||
if [[ -n "$token" ]]; then
|
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
|
||||||
-X POST \
|
|
||||||
-H "User-Agent: curl/8" \
|
|
||||||
-H "Authorization: token $token" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$api_url" || true)
|
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
rm -f "$fields_file"
|
||||||
|
|
||||||
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
|
||||||
if [[ -n "$basic_auth" ]]; then
|
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
rm -f "$body_file" "$payload_file"
|
||||||
-X POST \
|
return 1
|
||||||
-u "$basic_auth" \
|
|
||||||
-H "User-Agent: curl/8" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$payload" \
|
|
||||||
"$api_url" || true)
|
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST -H "User-Agent: curl/8" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
|
||||||
|
curl_rc=$?
|
||||||
|
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
|
||||||
|
if [[ "$curl_rc" -ne 0 ]]; then
|
||||||
|
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$raw_code" =~ ^2 ]]; then
|
||||||
|
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
|
||||||
|
fi
|
||||||
|
rm -f "$body_file" "$payload_file"
|
||||||
|
rm -rf -- "$attempt_dir"
|
||||||
|
[[ "$raw_code" =~ ^2 ]]
|
||||||
|
}
|
||||||
|
|
||||||
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
merge_gitea_with_api() {
|
||||||
import json
|
local host="$1" token attempt_rc
|
||||||
import sys
|
|
||||||
code, path = sys.argv[1], sys.argv[2]
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
try:
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
return 1
|
||||||
raw = handle.read(500)
|
fi
|
||||||
data = json.loads(raw) if raw else {}
|
if [[ -z "$token" ]]; then
|
||||||
message = data.get("message") or data.get("error") or raw or "empty response"
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
except Exception:
|
return 1
|
||||||
try:
|
fi
|
||||||
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
if merge_gitea_api_attempt "$host" token "$token"; then
|
||||||
except Exception:
|
return 0
|
||||||
message = "unreadable response"
|
else
|
||||||
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
attempt_rc=$?
|
||||||
PY
|
fi
|
||||||
rm -f "$body_file"
|
if [[ "$attempt_rc" -eq 75 ]]; then
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
|
||||||
|
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
@@ -209,6 +615,10 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
|
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
@@ -219,7 +629,7 @@ case "$PLATFORM" in
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
# tea cannot express it, so every Gitea merge uses the authenticated API path.
|
||||||
merge_gitea_with_api "$HOST"
|
merge_gitea_with_api "$HOST"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
#
|
||||||
# port and its explicit default form compare equal.
|
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||||
|
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||||
|
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||||
|
# provider's own correct answer about a comment that landed — a deterministic
|
||||||
|
# false negative on EVERY review comment posted against such a deployment.
|
||||||
|
# That matters more here than anywhere else: on a host where no seat can
|
||||||
|
# create a review OBJECT, the comment-form review record this path produces
|
||||||
|
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||||
|
# The scheme is also not what the check defends: the forgeries it exists to
|
||||||
|
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||||
|
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||||
|
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||||
|
#
|
||||||
|
# Port: an implicit port and its own scheme's default compare equal, so
|
||||||
|
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||||
|
# because a different port is a different service on the same host.
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
default_port = 80 if scheme == "http" else 443
|
if scheme in ("http", "https"):
|
||||||
port = parsed.port if parsed.port is not None else default_port
|
scheme_class = "web"
|
||||||
return (scheme, host, port), parsed.path.rstrip("/")
|
default_port = 80 if scheme == "http" else 443
|
||||||
|
port = None if parsed.port in (None, default_port) else parsed.port
|
||||||
|
else:
|
||||||
|
scheme_class = scheme
|
||||||
|
port = parsed.port
|
||||||
|
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for ci-queue-wait.sh's zero-status-context handling.
|
|
||||||
#
|
|
||||||
# Gitea's combined-status endpoint returns, for a commit with NO status
|
|
||||||
# contexts (e.g. a repo with no CI configured at all):
|
|
||||||
# {"state":"pending","sha":"...","total_count":0,"statuses":null,...}
|
|
||||||
# -- statuses is JSON null (not []), and state is a synthetic "pending"
|
|
||||||
# even though nothing is running. Captured live from a Gitea 1.22 host,
|
|
||||||
# 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline).
|
|
||||||
#
|
|
||||||
# Before the fix, `payload.get("statuses", [])` received null, failed the
|
|
||||||
# isinstance(list) check, and the guard reported ASSERTED_NOT_READY
|
|
||||||
# state=malformed (exit 3) -- blocking every push to a CI-less repo. Had
|
|
||||||
# null been tolerated, the synthetic aggregate "pending" would instead
|
|
||||||
# have polled to the timeout (exit 124). The fix must:
|
|
||||||
# 1. treat statuses:null as an empty list, and
|
|
||||||
# 2. classify zero status VALUES as "no-status" regardless of the
|
|
||||||
# synthetic aggregate state, and
|
|
||||||
# 3. treat no-status for --purpose push (without --require-status) as
|
|
||||||
# queue-clear (exit 0) -- a repo with no CI has no queue to wait on;
|
|
||||||
# this mirrors record_cannot_assert's disposition table
|
|
||||||
# (push=degraded-pass, merge=hold).
|
|
||||||
#
|
|
||||||
# Covers:
|
|
||||||
# (a) statuses:null + synthetic state:pending, purpose=push
|
|
||||||
# -> exit 0, queue-clear/no-status message (THE live fault).
|
|
||||||
# (b) same payload, purpose=merge -> still fail-closed (exit 3).
|
|
||||||
# (c) same payload, push + --require-status -> still fail-closed (exit 3).
|
|
||||||
# (d) statuses:[] + state:"" -> same as (a) (exit 0).
|
|
||||||
# (e) a real pending context -> still polls (times out, 124),
|
|
||||||
# proving the relaxation didn't swallow genuine pending states.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
|
||||||
|
|
||||||
# Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh):
|
|
||||||
# branch lookup answers 200 with a fixed SHA; the status endpoint's payload
|
|
||||||
# is selected by MOSAIC_STUB_STATUS_MODE.
|
|
||||||
cat > "$STUB_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
has_w=0
|
|
||||||
url=""
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
-w) has_w=1 ;;
|
|
||||||
http://*|https://*) url="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
case "$url" in
|
|
||||||
*/branches/*)
|
|
||||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
|
||||||
if [[ "$has_w" == 1 ]]; then
|
|
||||||
printf '%s\n200' "$body"
|
|
||||||
else
|
|
||||||
printf '%s' "$body"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*/status)
|
|
||||||
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "curl stub: unrecognized URL: $url" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$mode" in
|
|
||||||
# Verbatim shape of the live Gitea response (repository object elided).
|
|
||||||
gitea-null-statuses)
|
|
||||||
body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}'
|
|
||||||
;;
|
|
||||||
empty-statuses)
|
|
||||||
body='{"state":"","statuses":[]}'
|
|
||||||
;;
|
|
||||||
real-pending)
|
|
||||||
body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}'
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
printf '%s' "$body"
|
|
||||||
SH
|
|
||||||
chmod +x "$STUB_DIR/curl"
|
|
||||||
|
|
||||||
run_ci_queue_wait() {
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
export PATH="$STUB_DIR:$PATH"
|
|
||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
|
||||||
export GITEA_TOKEN="stub-token"
|
|
||||||
export GITEA_URL="https://git.example.test"
|
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
|
|
||||||
# (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0.
|
|
||||||
set +e
|
|
||||||
out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1)
|
|
||||||
status_a=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status_a" -ne 0 ]]; then
|
|
||||||
echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2
|
|
||||||
echo "$out_a" >&2
|
|
||||||
fail=1
|
|
||||||
elif [[ "$out_a" == *"malformed"* ]]; then
|
|
||||||
echo "FAIL(a): null statuses must not be classified as malformed" >&2
|
|
||||||
echo "$out_a" >&2
|
|
||||||
fail=1
|
|
||||||
elif [[ "$out_a" != *"no-status"* ]]; then
|
|
||||||
echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2
|
|
||||||
echo "$out_a" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (b) Same payload, purpose=merge -> hold, fail-closed exit 3.
|
|
||||||
set +e
|
|
||||||
out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1)
|
|
||||||
status_b=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status_b" -ne 3 ]]; then
|
|
||||||
echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2
|
|
||||||
echo "$out_b" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (c) Same payload, push + --require-status -> strictness opt-in still fails.
|
|
||||||
set +e
|
|
||||||
out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1)
|
|
||||||
status_c=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status_c" -ne 3 ]]; then
|
|
||||||
echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2
|
|
||||||
echo "$out_c" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push.
|
|
||||||
set +e
|
|
||||||
out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1)
|
|
||||||
status_d=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status_d" -ne 0 ]]; then
|
|
||||||
echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2
|
|
||||||
echo "$out_d" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (e) A REAL pending context must still block: polls to timeout, exit 124.
|
|
||||||
set +e
|
|
||||||
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1)
|
|
||||||
status_e=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$status_e" -ne 124 ]]; then
|
|
||||||
echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2
|
|
||||||
echo "$out_e" >&2
|
|
||||||
fail=1
|
|
||||||
elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then
|
|
||||||
echo "FAIL(e): expected the pending context to be reported, got:" >&2
|
|
||||||
echo "$out_e" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
|
||||||
echo "ci-queue-wait no-status regression passed (5/5 cases)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$fail"
|
|
||||||
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
|
|||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
|
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||||
|
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||||
|
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||||
|
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||||
|
WATCHDOG_TIMEOUT_SEC=5
|
||||||
|
WATCHDOG_EXIT=90
|
||||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||||
|
|
||||||
|
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||||
|
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
timeout_seconds = float(sys.argv[1])
|
||||||
|
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||||
|
try:
|
||||||
|
return_code = process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
process.wait()
|
||||||
|
print(
|
||||||
|
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||||
|
"before completing its intended path",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(90)
|
||||||
|
|
||||||
|
if return_code < 0:
|
||||||
|
raise SystemExit(128 - return_code)
|
||||||
|
raise SystemExit(return_code)
|
||||||
|
PY
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||||
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
|||||||
|
|
||||||
case "$url" in
|
case "$url" in
|
||||||
*/branches/*)
|
*/branches/*)
|
||||||
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||||
|
while :; do :; done
|
||||||
|
fi
|
||||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||||
exit 7
|
exit 7
|
||||||
fi
|
fi
|
||||||
@@ -44,6 +88,7 @@ case "$url" in
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*/status)
|
*/status)
|
||||||
|
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||||
@@ -63,7 +108,31 @@ case "$url" in
|
|||||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
SH
|
SH
|
||||||
chmod +x "$STUB_DIR/curl"
|
|
||||||
|
cat > "$STUB_DIR/date" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||||
|
echo "unexpected date invocation: $*" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||||
|
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1002\n'
|
||||||
|
else
|
||||||
|
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1000\n'
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/sleep" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||||
|
|
||||||
run_guard() {
|
run_guard() {
|
||||||
local status_mode="$1"
|
local status_mode="$1"
|
||||||
@@ -83,13 +152,46 @@ run_guard() {
|
|||||||
export GITEA_URL=https://git.example.test
|
export GITEA_URL=https://git.example.test
|
||||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||||
fi
|
fi
|
||||||
|
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||||
|
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||||
|
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
# Provider observation is the synchronization event. The one-second
|
||||||
|
# timeout is subject semantics under virtual time, never a wall wait.
|
||||||
|
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||||
|
# the subject's isolated process group. Neither operation can resolve
|
||||||
|
# to the virtual date/sleep stubs at the front of PATH.
|
||||||
|
local subject_rc
|
||||||
|
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||||
|
subject_rc=0
|
||||||
|
else
|
||||||
|
subject_rc=$?
|
||||||
|
fi
|
||||||
|
return "$subject_rc"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
|
assert_provider_observed() {
|
||||||
|
local name="$1" require_expiration="${2:-0}"
|
||||||
|
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL $name: status provider was not observed" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$require_expiration" -eq 1 ]]; then
|
||||||
|
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
run_assertion() {
|
run_assertion() {
|
||||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||||
local output rc
|
local output rc
|
||||||
@@ -124,6 +226,13 @@ run_assertion() {
|
|||||||
printf '%s\n' "$output" >&2
|
printf '%s\n' "$output" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||||
|
if [[ "$status_mode" == "pending" ]]; then
|
||||||
|
assert_provider_observed "$name" 1
|
||||||
|
else
|
||||||
|
assert_provider_observed "$name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -140,6 +249,27 @@ run_assertion large-payload not126 large-success 'state=terminal-success'
|
|||||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||||
|
|
||||||
|
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||||
|
# can reach the status provider. Only the independent real-clock watchdog may
|
||||||
|
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||||
|
set +e
|
||||||
|
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||||
|
watchdog_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||||
|
printf '%s\n' "$watchdog_output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
@@ -160,6 +290,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
|||||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
assert_provider_observed merge-provider-unreachable
|
||||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||||
@@ -223,6 +354,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
|
|||||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
fi
|
fi
|
||||||
|
assert_provider_observed audit-unavailable
|
||||||
|
|
||||||
if [[ "$failures" -ne 0 ]]; then
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||||
|
# not kill the caller silently.
|
||||||
|
#
|
||||||
|
# Both functions already contained the right error path:
|
||||||
|
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||||
|
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||||
|
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||||
|
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||||
|
#
|
||||||
|
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||||
|
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||||
|
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||||
|
# `blocked` with no diagnostic to report.
|
||||||
|
#
|
||||||
|
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||||
|
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||||
|
local fn="$1" out rc
|
||||||
|
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||||
|
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||||
|
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||||
|
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||||
|
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||||
|
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||||
|
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||||
|
# does nothing. Resolve it before exporting.
|
||||||
|
TMP="$(cd "$TMP" && pwd -P)"
|
||||||
|
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||||
|
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||||
|
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||||
|
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||||
|
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||||
|
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||||
|
|
||||||
|
echo "== inside a git repo the functions still work =="
|
||||||
|
git init -q "$TMP/repo" 2>/dev/null
|
||||||
|
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||||
|
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||||
|
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||||
|
#
|
||||||
|
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||||
|
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||||
|
# declare -F explain_... >/dev/null && explain_...
|
||||||
|
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||||
|
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||||
|
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||||
|
# passing control.
|
||||||
|
#
|
||||||
|
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||||
|
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||||
|
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||||
|
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||||
|
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||||
|
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||||
|
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||||
|
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||||
|
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||||
|
# probe pass on a build whose real call sites still carry the bare && form.
|
||||||
|
set -uo pipefail
|
||||||
|
fail=0
|
||||||
|
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||||
|
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||||
|
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||||
|
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||||
|
script="$TMP/probe.sh"
|
||||||
|
{
|
||||||
|
echo '#!/bin/bash'
|
||||||
|
echo 'set -e'
|
||||||
|
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||||
|
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||||
|
echo "$construct" # the shipped line, parsed by a real shell
|
||||||
|
echo 'echo FALLBACK_REACHED'
|
||||||
|
} > "$script"
|
||||||
|
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||||
|
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||||
|
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||||
|
}
|
||||||
|
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||||
|
|
||||||
|
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||||
|
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||||
|
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||||
|
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== all three call sites use the status-neutral form =="
|
||||||
|
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||||
|
p="$GIT_DIR_UNDER_TEST/$f"
|
||||||
|
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||||
|
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||||
|
exit "$fail"
|
||||||
@@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
||||||
|
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
||||||
|
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
||||||
|
# variable changed) and passes everywhere else, including CI, which has no
|
||||||
|
# per-agent token to leak.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
||||||
|
# bound a failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -86,6 +112,7 @@ run_in_repo() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -283,6 +310,7 @@ run_in_repo2() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR2:$PATH" \
|
PATH="$BIN_DIR2:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
|||||||
token_fallback() {
|
token_fallback() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
@@ -354,7 +382,7 @@ token_fallback() {
|
|||||||
token_pyyaml() {
|
token_pyyaml() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json"
|
|||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||||
|
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
||||||
|
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
||||||
|
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
||||||
|
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
||||||
|
# failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
||||||
|
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
||||||
|
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
||||||
|
# thing that says what went wrong — exists only inside a directory that is gone
|
||||||
|
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
||||||
|
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
||||||
|
# assay into a non-measurement: "nothing found" there means "no surviving
|
||||||
|
# trace", never "clean". It was found by intercepting the identity read at its
|
||||||
|
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
||||||
|
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
||||||
|
# by grepping for the symptom.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To
|
||||||
|
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
||||||
|
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
||||||
|
# branch — or intercept the read as described above.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
ISSUE_NUMBER=7
|
ISSUE_NUMBER=7
|
||||||
REPO_SLUG="mosaicstack/stack"
|
REPO_SLUG="mosaicstack/stack"
|
||||||
@@ -280,7 +319,10 @@ print("201")
|
|||||||
print(json.dumps(record))
|
print(json.dumps(record))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -299,7 +341,10 @@ else:
|
|||||||
print(json.dumps(match))
|
print(json.dumps(match))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
else
|
else
|
||||||
echo "Unexpected curl request: $method $url" >&2
|
echo "Unexpected curl request: $method $url" >&2
|
||||||
exit 97
|
exit 97
|
||||||
@@ -366,6 +411,7 @@ run_comment() {
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||||
|
|||||||
@@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||||
|
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||||
|
# ignored and the suite runs against a production credential. An empty repo-local
|
||||||
|
# value shadows the global one and reads back empty at rc=0. Measured: this suite
|
||||||
|
# resolves a per-slot token without it.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
|
||||||
|
# that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||||
@@ -50,6 +75,7 @@ run_wrapper() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ section_nums() { # $1 = output $2 = header-prefix
|
|||||||
}
|
}
|
||||||
|
|
||||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||||
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
contains() { grep -qx "$2" <<<"$1"; }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixed (current) script behavior
|
# Fixed (current) script behavior
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for.
|
||||||
|
#
|
||||||
|
# resolve_repo() took the first line of `git worktree list --porcelain` with
|
||||||
|
# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end
|
||||||
|
# of the pipe while git is still writing, git takes SIGPIPE, and under
|
||||||
|
# `set -euo pipefail` the command substitution returns 141 — so the assignment
|
||||||
|
# fails, `set -e` aborts the function, and the script dies printing NOTHING. No
|
||||||
|
# message, no path, no worktree, exit 141.
|
||||||
|
#
|
||||||
|
# What makes it worth a dedicated test rather than a fixture line is WHEN it
|
||||||
|
# fires. If git finishes writing before awk leaves, there is no SIGPIPE and
|
||||||
|
# everything works. So the failure is a function of how much porcelain the repo
|
||||||
|
# produces: invisible on a three-worktree repo, reliable on a seventy-worktree
|
||||||
|
# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141,
|
||||||
|
# no output — and it had passed every hand-check before that, on small repos.
|
||||||
|
#
|
||||||
|
# A test that ran `git worktree list` against whatever repo it happens to sit in
|
||||||
|
# would inherit that same size dependence and would have PASSED on the tree that
|
||||||
|
# was broken. So git is stubbed on PATH and made to emit a large porcelain
|
||||||
|
# stream, which turns "depends on the repo you are standing in" into "always".
|
||||||
|
#
|
||||||
|
# Exit: 0 = the helper resolved the repo · 1 = it did not
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TOOL="${1:-$HERE/mosaic-worktree.sh}"
|
||||||
|
[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
mkdir -p "$TMP/bin"
|
||||||
|
|
||||||
|
# The stub answers exactly the two calls resolve_repo makes, and answers the
|
||||||
|
# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the
|
||||||
|
# writer is still writing when a reader that quits early goes away. Anything
|
||||||
|
# else exits non-zero rather than pretending to be git.
|
||||||
|
cat > "$TMP/bin/git" <<'STUB'
|
||||||
|
#!/bin/sh
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -C) shift 2 ;; *) break ;; esac
|
||||||
|
done
|
||||||
|
case "$*" in
|
||||||
|
"rev-parse --git-dir")
|
||||||
|
echo .git; exit 0 ;;
|
||||||
|
"worktree list --porcelain")
|
||||||
|
# The first entry is the main worktree. That single line is all the helper
|
||||||
|
# needs, and it is exactly what it stopped receiving.
|
||||||
|
printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0
|
||||||
|
awk 'BEGIN{ for (i = 0; i < 4000; i++)
|
||||||
|
printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }'
|
||||||
|
# NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail
|
||||||
|
# in the caller is what turns that into the silent abort. A stub that exits 0
|
||||||
|
# regardless hands the caller a clean status and the probe passes on the
|
||||||
|
# broken tree — which is how this test failed to be a test on its first run.
|
||||||
|
exit $? ;;
|
||||||
|
esac
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
chmod +x "$TMP/bin/git"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
check() {
|
||||||
|
local why="$1" want="$2" got="$3"
|
||||||
|
if [ "$want" = "$got" ]; then
|
||||||
|
printf 'ok %s\n' "$why"
|
||||||
|
else
|
||||||
|
printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
# Both halves are asserted. rc alone would pass if the helper started printing a
|
||||||
|
# usage error, and output alone would miss a non-zero exit — and the defect's
|
||||||
|
# signature is precisely a non-zero exit with no output, which only the pair
|
||||||
|
# distinguishes from every other way this could go wrong.
|
||||||
|
check 'resolving a repo with a large worktree list exits 0' 0 "$rc"
|
||||||
|
check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out"
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$fail" -eq 0 ]; then
|
||||||
|
printf 'mosaic-worktree: resolves against a large porcelain stream.\n'
|
||||||
|
else
|
||||||
|
cat <<'EOF'
|
||||||
|
mosaic-worktree could not resolve the repository.
|
||||||
|
|
||||||
|
An empty output with a non-zero exit is the SIGPIPE signature: a reader that
|
||||||
|
quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns
|
||||||
|
that into a silent abort. Nothing in this script may close a git pipe early.
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
exit "$fail"
|
||||||
@@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|||||||
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||||
MOCK_BIN="$SANDBOX/bin"
|
MOCK_BIN="$SANDBOX/bin"
|
||||||
REPO_DIR="$SANDBOX/repo"
|
REPO_DIR="$SANDBOX/repo"
|
||||||
|
HOME_DIR="$SANDBOX/home"
|
||||||
LOG_FILE="$SANDBOX/mock.log"
|
LOG_FILE="$SANDBOX/mock.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -15,7 +16,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
@@ -51,22 +52,23 @@ for arg in "$@"; do
|
|||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$prev" == "-d" ]]; then
|
if [[ "$prev" == "data" ]]; then
|
||||||
post_data="$arg"
|
post_data="$arg"
|
||||||
|
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
|
||||||
prev=""
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-o" ]]; then
|
if [[ "$prev" == "config" ]]; then
|
||||||
prev="-o"
|
[[ "$arg" == "-" ]] && cat >/dev/null
|
||||||
|
prev=""
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [[ "$arg" == "-d" ]]; then
|
case "$arg" in
|
||||||
prev="-d"
|
-o) prev="-o" ;;
|
||||||
continue
|
-d|--data|--data-binary) prev="data" ;;
|
||||||
fi
|
-K|--config) prev="config" ;;
|
||||||
if [[ "$arg" == "-w" ]]; then
|
-w) write_code=true ;;
|
||||||
write_code=true
|
esac
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
emit_response() {
|
emit_response() {
|
||||||
local body="$1"
|
local body="$1"
|
||||||
@@ -108,7 +110,48 @@ chmod +x "$MOCK_BIN/curl"
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||||
|
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
|
||||||
|
# overridden and a REAL per-slot token from $HOME is what flows through the
|
||||||
|
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
|
||||||
|
# carried the real per-slot token in argv and the fixture token was never used at
|
||||||
|
# ALL. Three consequences specific to this suite:
|
||||||
|
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
|
||||||
|
# mock curl logs full argv, so the real credential is written to $LOG_FILE
|
||||||
|
# on disk — transiently: the suite truncates that file between phases and
|
||||||
|
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
|
||||||
|
# why this suite was the hardest of the three to detect; observing it needs
|
||||||
|
# an instrument that captures argv while the run is live.
|
||||||
|
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
|
||||||
|
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
|
||||||
|
# is the literal fixture string and therefore CANNOT match the token
|
||||||
|
# actually in use.
|
||||||
|
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
|
||||||
|
# same fixture string, so on a provisioned seat it passes vacuously: it is
|
||||||
|
# searching for a value the run never used.
|
||||||
|
# An empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
|
||||||
|
# that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
|
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
|
||||||
|
export HOME="$HOME_DIR"
|
||||||
export PATH="$MOCK_BIN:$PATH"
|
export PATH="$MOCK_BIN:$PATH"
|
||||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
|||||||
@@ -36,13 +36,30 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
payload=""
|
payload=""
|
||||||
for ((i=1; i<=$#; i++)); do
|
out_file=""
|
||||||
if [[ "${!i}" == "-d" ]]; then
|
while [[ $# -gt 0 ]]; do
|
||||||
j=$((i + 1))
|
case "$1" in
|
||||||
payload="${!j}"
|
-d|--data|--data-binary)
|
||||||
fi
|
payload="$2"
|
||||||
|
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
[[ "$2" == "-" ]] && cat >/dev/null
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w|-X|-H)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
|
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
|
||||||
printf '200'
|
printf '200'
|
||||||
SH
|
SH
|
||||||
chmod +x "$WORK_DIR/gitea/curl"
|
chmod +x "$WORK_DIR/gitea/curl"
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for the optional, identity-checked Gitea squash message.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
|
||||||
|
ORIG_PATH="$PATH"
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL $1" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
make_case() {
|
||||||
|
local name="$1" case_dir
|
||||||
|
case_dir="$WORK_DIR/$name"
|
||||||
|
mkdir -p "$case_dir/bin" "$case_dir/agent"
|
||||||
|
cp "$SUBJECT" "$case_dir/pr-merge.sh"
|
||||||
|
chmod +x "$case_dir/pr-merge.sh"
|
||||||
|
|
||||||
|
cat > "$case_dir/detect-platform.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
|
||||||
|
get_repo_owner() { printf 'acme\n'; }
|
||||||
|
get_repo_name() { printf 'widgets\n'; }
|
||||||
|
get_remote_host() { printf 'git.example.test\n'; }
|
||||||
|
get_gitea_token() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'fixture-token\n'
|
||||||
|
}
|
||||||
|
get_gitea_basic_auth() {
|
||||||
|
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
|
||||||
|
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
|
||||||
|
printf 'fixture-user:fixture-password\n'
|
||||||
|
return "${MOSAIC_TEST_BASIC_RC:-0}"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
get_gitea_login_for_host() { return 1; }
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/pr-metadata.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
|
||||||
|
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
|
||||||
|
else
|
||||||
|
title='Preserve both branch authors'
|
||||||
|
fi
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/python3" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$case_dir/bin/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
*"Preserve both branch authors"*|*"[email protected]"*)
|
||||||
|
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
url=""
|
||||||
|
method="GET"
|
||||||
|
out_file=""
|
||||||
|
data=""
|
||||||
|
config=""
|
||||||
|
auth_mode="none"
|
||||||
|
has_max_filesize=0
|
||||||
|
has_max_time=0
|
||||||
|
has_connect_timeout=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o)
|
||||||
|
out_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-X)
|
||||||
|
method="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|--data|--data-binary)
|
||||||
|
data="$2"
|
||||||
|
if [[ "$data" == @* ]]; then
|
||||||
|
data=$(<"${data#@}")
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-K|--config)
|
||||||
|
if [[ "$2" == "-" ]]; then
|
||||||
|
config=$(cat)
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-filesize)
|
||||||
|
has_max_filesize=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--max-time)
|
||||||
|
has_max_time=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--connect-timeout)
|
||||||
|
has_connect_timeout=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-H|--header|-u|--user)
|
||||||
|
if [[ "$2" == *"fixture-token"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
if [[ "$2" == *"fixture-password"* ]]; then
|
||||||
|
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
http://*|https://*)
|
||||||
|
url="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
|
||||||
|
auth_mode="token"
|
||||||
|
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
|
||||||
|
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
|
||||||
|
auth_mode="basic"
|
||||||
|
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
|
||||||
|
fi
|
||||||
|
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
|
||||||
|
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/pulls/42)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified) head_sha=2222222222222222222222222222222222222222 ;;
|
||||||
|
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
|
||||||
|
single) head_sha=1111111111111111111111111111111111111111 ;;
|
||||||
|
*) echo "unknown commits mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
|
||||||
|
head_sha=4444444444444444444444444444444444444444
|
||||||
|
fi
|
||||||
|
body="{\"head\":{\"sha\":\"$head_sha\"}}"
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/commits*)
|
||||||
|
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
|
||||||
|
verified)
|
||||||
|
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
else
|
||||||
|
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
null-login)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
|
||||||
|
;;
|
||||||
|
unsafe-identity)
|
||||||
|
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
single)
|
||||||
|
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown commits mode" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/pulls/42/merge)
|
||||||
|
body='{}'
|
||||||
|
code=200
|
||||||
|
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
|
||||||
|
body='{"message":"token rejected"}'
|
||||||
|
code=401
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
|
||||||
|
body='{"message":"branch policy rejected\n\u001b[31m"}'
|
||||||
|
code=409
|
||||||
|
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
|
||||||
|
body='{"message":"permission denied"}'
|
||||||
|
code=403
|
||||||
|
else
|
||||||
|
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/users/*)
|
||||||
|
body='{"message":"not found"}'
|
||||||
|
code=404
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
body='{"message":"unexpected URL"}'
|
||||||
|
code=500
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n "$out_file" ]]; then
|
||||||
|
printf '%s' "$body" > "$out_file"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
printf '%s' "$code"
|
||||||
|
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
|
||||||
|
oversize) exit 63 ;;
|
||||||
|
stalled) exit 28 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
|
||||||
|
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
|
||||||
|
printf '%s\n' "$case_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local case_dir="$1" mode="$2"
|
||||||
|
shift 2
|
||||||
|
MOSAIC_TEST_COMMITS_MODE="$mode" \
|
||||||
|
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
|
||||||
|
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
|
||||||
|
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
|
||||||
|
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
|
||||||
|
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
|
||||||
|
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
|
||||||
|
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
|
||||||
|
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
|
||||||
|
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
|
||||||
|
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
|
||||||
|
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
|
||||||
|
AGENT_WORK_ROOT="$case_dir/agent" \
|
||||||
|
PATH="$case_dir/bin:$ORIG_PATH" \
|
||||||
|
"$case_dir/pr-merge.sh" -n 42 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verified multi-author path: the non-poster trailer is built from one commit's
|
||||||
|
# linked author.login and that same commit's author email. No /users lookup.
|
||||||
|
verified_dir=$(make_case verified)
|
||||||
|
set +e
|
||||||
|
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
verified_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$verified_rc" -ne 0 ]]; then
|
||||||
|
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
|
||||||
|
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
|
||||||
|
fail "verified multi-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "2222222222222222222222222222222222222222",
|
||||||
|
"MergeTitleField": "Preserve both branch authors",
|
||||||
|
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
|
||||||
|
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
|
||||||
|
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
|
||||||
|
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
|
||||||
|
fail "verified path performed a forbidden second /users lookup"
|
||||||
|
fi
|
||||||
|
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
|
||||||
|
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A linked email containing a terminal escape must block before mutation.
|
||||||
|
escape_email_dir=$(make_case escape-email)
|
||||||
|
set +e
|
||||||
|
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
escape_email_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
|
||||||
|
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
|
||||||
|
|
||||||
|
# Curl transfer and duration failures must remain failures even with HTTP 200.
|
||||||
|
for failure_mode in oversize stalled; do
|
||||||
|
failure_dir=$(make_case "curl-$failure_mode")
|
||||||
|
set +e
|
||||||
|
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
failure_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
|
||||||
|
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The authenticated head is re-read under the mutation credential but cannot
|
||||||
|
# replace the canonical preflight/review head. A move blocks before enumeration
|
||||||
|
# or mutation even though the provider returned a valid new SHA.
|
||||||
|
moved_dir=$(make_case moved-head)
|
||||||
|
set +e
|
||||||
|
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
|
||||||
|
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
moved_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
|
||||||
|
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
|
||||||
|
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
|
||||||
|
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
|
||||||
|
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
|
||||||
|
|
||||||
|
# Token resolution failure is not an authentication response. It must fail
|
||||||
|
# closed instead of borrowing a Basic credential under a different principal.
|
||||||
|
token_missing_dir=$(make_case token-missing)
|
||||||
|
set +e
|
||||||
|
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
|
||||||
|
run_case "$token_missing_dir" single 2>&1)
|
||||||
|
token_missing_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
|
||||||
|
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
|
||||||
|
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
|
||||||
|
|
||||||
|
# A failed Basic resolver must never use its nonempty output or reach mutation.
|
||||||
|
basic_rc_dir=$(make_case basic-resolver-rc)
|
||||||
|
set +e
|
||||||
|
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
basic_rc_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
|
||||||
|
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
|
||||||
|
|
||||||
|
# HTTP 401 never changes principals: inspection rejection fails closed without
|
||||||
|
# resolving or attempting Basic Auth.
|
||||||
|
fallback_inspect_dir=$(make_case fallback-inspection)
|
||||||
|
set +e
|
||||||
|
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
|
||||||
|
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_inspect_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
|
||||||
|
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
|
||||||
|
|
||||||
|
# Token rejection at merge likewise fails closed without cross-principal retry.
|
||||||
|
fallback_merge_dir=$(make_case fallback-merge)
|
||||||
|
set +e
|
||||||
|
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
|
||||||
|
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
fallback_merge_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
|
||||||
|
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
|
||||||
|
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
|
||||||
|
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
|
||||||
|
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
|
||||||
|
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
|
||||||
|
|
||||||
|
# BLOCK path: a commit email exists but author.login is null. It must name both
|
||||||
|
# facts, name the escalation principal, and never reach the merge endpoint.
|
||||||
|
null_dir=$(make_case null-login)
|
||||||
|
set +e
|
||||||
|
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
null_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
|
||||||
|
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
|
||||||
|
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
|
||||||
|
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
|
||||||
|
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
|
||||||
|
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
|
||||||
|
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
|
||||||
|
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
|
||||||
|
# including an invalid non-head SHA that contains control characters.
|
||||||
|
unsafe_dir=$(make_case unsafe-identity)
|
||||||
|
set +e
|
||||||
|
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
unsafe_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
|
||||||
|
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
|
||||||
|
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
|
||||||
|
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
|
||||||
|
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
|
||||||
|
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
|
||||||
|
|
||||||
|
# The provider PR title cannot add an unchecked trailer outside the constructed
|
||||||
|
# message field: multi-line and trailer-shaped titles block before mutation.
|
||||||
|
title_dir=$(make_case title-injection)
|
||||||
|
set +e
|
||||||
|
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
|
||||||
|
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
|
||||||
|
title_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
|
||||||
|
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
|
||||||
|
|
||||||
|
# Provider failures remain diagnosable after their temporary response file is
|
||||||
|
# removed, but provider-controlled control characters stay log-safe.
|
||||||
|
error_dir=$(make_case provider-error)
|
||||||
|
set +e
|
||||||
|
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
|
||||||
|
run_case "$error_dir" single 2>&1)
|
||||||
|
error_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
|
||||||
|
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
|
||||||
|
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
|
||||||
|
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
|
||||||
|
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
|
||||||
|
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# Authorization denials likewise fail closed instead of changing principals.
|
||||||
|
forbidden_dir=$(make_case forbidden)
|
||||||
|
set +e
|
||||||
|
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
|
||||||
|
run_case "$forbidden_dir" single 2>&1)
|
||||||
|
forbidden_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
|
||||||
|
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
|
||||||
|
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
|
||||||
|
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
|
||||||
|
|
||||||
|
# The BLOCK destination cannot be generic or inferred after failure: opting in
|
||||||
|
# without a named principal is refused before any provider operation.
|
||||||
|
principal_dir=$(make_case missing-principal)
|
||||||
|
set +e
|
||||||
|
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
|
||||||
|
principal_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
|
||||||
|
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
|
||||||
|
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
|
||||||
|
|
||||||
|
# A trailing value-taking option receives a stable CLI diagnostic instead of a
|
||||||
|
# set -u unbound-variable crash.
|
||||||
|
value_dir=$(make_case missing-principal-value)
|
||||||
|
set +e
|
||||||
|
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
|
||||||
|
value_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
|
||||||
|
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
|
||||||
|
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
|
||||||
|
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
|
||||||
|
|
||||||
|
# Negative control: ordinary single-author merge remains byte-for-byte payload
|
||||||
|
# compatible and hardcoded to squash, with no optional message fields.
|
||||||
|
single_dir=$(make_case single)
|
||||||
|
set +e
|
||||||
|
single_output=$(run_case "$single_dir" single 2>&1)
|
||||||
|
single_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$single_rc" -ne 0 ]]; then
|
||||||
|
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
|
||||||
|
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
|
||||||
|
fail "ordinary single-author merge did not reach the API payload"
|
||||||
|
else
|
||||||
|
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "1111111111111111111111111111111111111111",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
|
||||||
|
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
|
||||||
|
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
|
||||||
|
|
||||||
|
# Squash is not defaultable: an explicit non-squash method must remain refused.
|
||||||
|
method_dir=$(make_case method-refusal)
|
||||||
|
set +e
|
||||||
|
method_output=$(run_case "$method_dir" single -m merge 2>&1)
|
||||||
|
method_rc=$?
|
||||||
|
set -e
|
||||||
|
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
|
||||||
|
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
|
||||||
|
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "pr-merge message-field regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
|
||||||
@@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
|||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||||
|
#
|
||||||
|
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||||
|
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||||
|
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||||
|
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||||
|
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
|
||||||
|
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
|
||||||
|
# Measured on a provisioned seat before this pin: both stub-curl calls carried
|
||||||
|
# the real token in argv. An empty repo-local value shadows the global one and
|
||||||
|
# reads back empty at rc=0.
|
||||||
|
#
|
||||||
|
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
|
||||||
|
# bound a failure that the pin should already have prevented.
|
||||||
|
#
|
||||||
|
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||||
|
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||||
|
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||||
|
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||||
|
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||||
|
# per-slot token) so step 0 reaches its fail-loud branch. See
|
||||||
|
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
|
||||||
|
# suite's whole subject IS identity resolution.
|
||||||
|
#
|
||||||
|
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||||
|
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
|
# The pin above removes step 0, but this suite has a SECOND, independent
|
||||||
|
# dependency on operator state, and closing only the first would leave the suite
|
||||||
|
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
|
||||||
|
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
|
||||||
|
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
|
||||||
|
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
|
||||||
|
# it. Before this fixture the only credential that could reach the authenticated
|
||||||
|
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
|
||||||
|
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
|
||||||
|
# "curl success path" case passed: not because the stub credential worked, but
|
||||||
|
# because a production credential was available.
|
||||||
|
#
|
||||||
|
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
|
||||||
|
# nothing: a test that passes because production configuration is ABSENT fails
|
||||||
|
# the moment it is present. Step 1 now resolves deterministically to a value that
|
||||||
|
# is a fixture on every machine.
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "stub-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -131,6 +187,8 @@ run_curl_success_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
run_case() {
|
run_case() {
|
||||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||||
local output
|
local output
|
||||||
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||||
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ write_response() {
|
|||||||
emit() {
|
emit() {
|
||||||
# Split a two-line "status\n<json body>" python result into the response.
|
# Split a two-line "status\n<json body>" python result into the response.
|
||||||
local result="$1"
|
local result="$1"
|
||||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
response_status="${result%%$'\n'*}"
|
||||||
|
response_body=""
|
||||||
|
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||||
|
write_response "$response_status" "$response_body"
|
||||||
}
|
}
|
||||||
|
|
||||||
mode="${PR_REVIEW_TEST_MODE:-}"
|
mode="${PR_REVIEW_TEST_MODE:-}"
|
||||||
@@ -436,6 +439,19 @@ elif mode == "comment-url-wrong-repo":
|
|||||||
elif mode == "comment-url-suffix-injection":
|
elif mode == "comment-url-suffix-injection":
|
||||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-wrong-port":
|
||||||
|
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
||||||
|
# host. Relaxing http-vs-https must NOT relax this.
|
||||||
|
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-non-web-scheme":
|
||||||
|
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
||||||
|
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
||||||
|
elif mode == "comment-url-scheme-downgrade":
|
||||||
|
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
||||||
|
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
||||||
|
# https://. Same host, same path, correct record — a truthful provider
|
||||||
|
# answer about a comment that landed, not a forgery.
|
||||||
|
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
||||||
elif mode == "comment-mixed-case-slug":
|
elif mode == "comment-mixed-case-slug":
|
||||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||||
@@ -890,11 +906,16 @@ fi
|
|||||||
assert_no_temp_leak "review-body-reuse"
|
assert_no_temp_leak "review-body-reuse"
|
||||||
|
|
||||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
# but whose provider-returned pull_request_url does not belong to this PR must
|
||||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
||||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
||||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
||||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
# and prefix-injection variants. comment-url-wrong-port and
|
||||||
|
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
||||||
|
# side: collapsing http/https must not also collapse a different port or a
|
||||||
|
# different scheme family.
|
||||||
|
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
||||||
|
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
||||||
if run_review "$bad_mode" comment durable-body; then
|
if run_review "$bad_mode" comment durable-body; then
|
||||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||||
cat "$OUTPUT_FILE" >&2
|
cat "$OUTPUT_FILE" >&2
|
||||||
@@ -920,6 +941,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
|||||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
assert_no_temp_leak "comment-mixed-case-slug"
|
assert_no_temp_leak "comment-mixed-case-slug"
|
||||||
|
|
||||||
|
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
||||||
|
# reaches it over https://, so the provider returns an http:// pull_request_url
|
||||||
|
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
||||||
|
# legitimate provider response, not a spoof — and a scheme-strict compare
|
||||||
|
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
||||||
|
# negative here: on a host where no seat can create a review OBJECT, this
|
||||||
|
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
||||||
|
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
||||||
|
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
||||||
|
run_review comment-url-scheme-downgrade comment durable-body
|
||||||
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
assert_no_temp_leak "comment-url-scheme-downgrade"
|
||||||
|
|
||||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||||
|
|||||||
@@ -222,8 +222,8 @@ grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
|||||||
|
|
||||||
# --- Case 2: -h/--help documents both overrides.
|
# --- Case 2: -h/--help documents both overrides.
|
||||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||||
echo "$HELP_TEXT" | grep -q -- '-r, --repo'
|
grep -q -- '-r, --repo' <<<"$HELP_TEXT"
|
||||||
echo "$HELP_TEXT" | grep -q -- '-H, --host'
|
grep -q -- '-H, --host' <<<"$HELP_TEXT"
|
||||||
|
|
||||||
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
||||||
# and must not fail with "not a git repository or no origin remote" either.
|
# and must not fail with "not a git repository or no origin remote" either.
|
||||||
|
|||||||
+605
@@ -0,0 +1,605 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
||||||
|
#
|
||||||
|
# Resolves no credentials, touches no network, and creates no repository: the
|
||||||
|
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
||||||
|
# contract is testable from fixtures.
|
||||||
|
#
|
||||||
|
# The fixtures are written to a temp file rather than passed inline, and this is
|
||||||
|
# not stylistic. The guard inspects the literal text of the Bash command it is
|
||||||
|
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
||||||
|
# trips the guard on the harness instead of on the fixture — which is exactly
|
||||||
|
# what happened the first time this was checked by hand. Substring matching over
|
||||||
|
# whole command text is the guard's deliberate fail-closed posture; a test that
|
||||||
|
# does not account for it silently measures the wrong thing.
|
||||||
|
#
|
||||||
|
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
||||||
|
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
FIXTURES="$TMP/fixtures.tsv"
|
||||||
|
|
||||||
|
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
||||||
|
# [ TAB <substring the block message must contain> ]
|
||||||
|
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
|
||||||
|
# itself gets checked; without it a block is only asserted to have happened,
|
||||||
|
# not to have been useful.
|
||||||
|
{
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
|
||||||
|
# Path words use the same quote/escape state machine as names, but preserve
|
||||||
|
# substitutions so HOME remains visible. Quotes do not split the path word.
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
|
||||||
|
# The target may be HOME itself. End-of-command and whitespace terminate the
|
||||||
|
# token just as a slash does; punctuation that can extend a path does not.
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
|
||||||
|
# Unquoted POSIX metacharacters terminate the target word even without spaces.
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
|
||||||
|
# Quote removal must not create an expansion the shell never performs.
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
||||||
|
# Routing this arm through the shared name site also repaired an over-block it
|
||||||
|
# had carried from the start: the old whole-command regex found `git` INSIDE a
|
||||||
|
# longer word, so these two were refused at every head before this commit.
|
||||||
|
# Same class as mycurl and curl-wrapper, and refusing them is how a guard gets
|
||||||
|
# routed around instead of repaired.
|
||||||
|
printf '0\t{"tool_input":{"command":"mygit clone https://example.invalid/x $HOME/wt"}}\tmygit is a different program and its checkout is not ours\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gitfoo clone https://example.invalid/x $HOME/wt"}}\tthe name has to end where git ends\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
||||||
|
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
||||||
|
# --- bypasses an independent reviewer demonstrated against the first version.
|
||||||
|
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
||||||
|
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
||||||
|
# the guard reads text, so every spelling it does not know is a hole.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
||||||
|
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
||||||
|
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
||||||
|
# amount of fragment matching, because the endpoint does not exist until the
|
||||||
|
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
||||||
|
# read, rather than pretending it read one.
|
||||||
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
||||||
|
# --- round six changed the contract in this direction, and these fixtures are
|
||||||
|
# where it shows. They used to assert that discussing a call is not making one.
|
||||||
|
# Five rounds proved there is no textual way to tell a quoted example from a
|
||||||
|
# quoted command, so the guard stopped trying: it judges the payload, and a
|
||||||
|
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
||||||
|
# command line is now refused, and the way to write the example is a
|
||||||
|
# file-writing tool. This is the deliberate cost of the mechanism change.
|
||||||
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
||||||
|
# The boundary that keeps this from being "block everything": what is refused
|
||||||
|
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
||||||
|
# these are asserted as hard as the blocks above.
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
||||||
|
# Command position must still catch the real thing behind operators and env.
|
||||||
|
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
||||||
|
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
||||||
|
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
||||||
|
# to be parsed away; under this one it is the documented cost, and the message
|
||||||
|
# gets composed with a file-writing tool instead.
|
||||||
|
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
||||||
|
# ...but quotes stop being data the moment something executes them.
|
||||||
|
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
||||||
|
# --- round three. Each of these four is a real write that a bare-name match
|
||||||
|
# for the client could not see, because an ordinary word sat in front of it.
|
||||||
|
# They are kept as fixtures after the mechanism change even though the guard no
|
||||||
|
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
||||||
|
# and a future re-narrowing that reintroduced position would fail here first.
|
||||||
|
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
||||||
|
# A shell standing between quoted data and execution makes that data code,
|
||||||
|
# and the pipe is the form agents actually use. Filing it as data allowed the
|
||||||
|
# call to vanish from the skeleton while still running.
|
||||||
|
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
||||||
|
# ...and the questions that used to follow — is the pipe target a shell, does a
|
||||||
|
# shell on one line execute a string on another — no longer have to be answered
|
||||||
|
# at all. Both of these carry the payload, both are refused, and neither
|
||||||
|
# outcome depends on parsing what the pipe or the other line does.
|
||||||
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
||||||
|
# --- round four. The guard was still reading the command as typed rather than
|
||||||
|
# as the shell will run it: a backslash before a newline is removed before
|
||||||
|
# anything else happens, so the endpoint token can be split across the join.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
||||||
|
# Transparent prefixes take option VALUES, and the value was a word the list
|
||||||
|
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
||||||
|
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
||||||
|
# Execution through another command needed its own case under the old design.
|
||||||
|
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
||||||
|
# --- round five, and the finding that ended the parser. Command substitution
|
||||||
|
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
||||||
|
# span as inert prose. The unquoted and process-substitution forms already
|
||||||
|
# blocked, which is what made it a classification defect rather than a spelling
|
||||||
|
# one: the same call was refused or allowed depending on a quote character.
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
||||||
|
# A client the guard was never taught is the point of dropping client
|
||||||
|
# detection: neither of these names curl at all.
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
||||||
|
|
||||||
|
# Round six scoped the guard on `https?://`, and review found the absence shape
|
||||||
|
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
|
||||||
|
# so the guard never reached the write question. These are the reported repros.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
|
||||||
|
# ...and the boundary that stops a broader scope gate becoming block-everything.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
|
||||||
|
|
||||||
|
# Wrong remediation is its own defect: /issues/1/labels used to block with
|
||||||
|
# "use issue-create.sh", which is not the wrapper for that call. Round seven
|
||||||
|
# answered that by letting EVERY path under a numbered issue or PR through,
|
||||||
|
# and review showed the reasoning ("no wrapper owns these") was false in this
|
||||||
|
# tree. These are the reported repros, all rc 0 before round eight, and each
|
||||||
|
# asserts the wrapper the advice must name — not merely that a block happened.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
|
||||||
|
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
|
||||||
|
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
|
||||||
|
# state=closed, so a title/description/due-date edit is a gap and the message
|
||||||
|
# has to say so — round eight named the wrapper and stopped there.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
|
||||||
|
# The residue: still genuinely owned by nothing, and still flowing through.
|
||||||
|
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
|
||||||
|
# nothing in the tree adds a requested reviewer.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
|
||||||
|
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
|
||||||
|
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
|
||||||
|
# for `https?://`, so a split endpoint in the other two was in scope to block,
|
||||||
|
# produced no readable endpoint, and fell through to allow. Same defect class
|
||||||
|
# as the milestone arm, one layer up. Each shape gets its own fixture, because
|
||||||
|
# a single one would have passed on the arm that already worked.
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
|
||||||
|
# And the reason this is not "any variable blocks a write": a payload in a
|
||||||
|
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
|
||||||
|
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
|
||||||
|
# The arms above the numbered ones must keep blocking, with their own wrappers.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
|
||||||
|
|
||||||
|
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
|
||||||
|
# which is Gitea's spelling. GitHub's API carries no version segment at all
|
||||||
|
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
|
||||||
|
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
|
||||||
|
# than to what identifies a provider API. `/repos/` is the marker both share.
|
||||||
|
# These endpoints are READABLE, so each asserts the wrapper it must name; a
|
||||||
|
# rc-only fixture here would pass on the unreadable arm and prove nothing.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
|
||||||
|
# The end-of-options marker, which is the one option not spelled like one.
|
||||||
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
|
||||||
|
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
|
||||||
|
# in the newly admitted shape have to stay allowed, or this is a regression
|
||||||
|
# wearing a fix'"'"'s clothes.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
|
||||||
|
|
||||||
|
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
|
||||||
|
# must never trip it.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
|
||||||
|
# Documented over-block, pinned so it is a known boundary and not a surprise.
|
||||||
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
|
||||||
|
|
||||||
|
# --- round nine, all four from one adversarial pass, and three of them are
|
||||||
|
# the same shape: a test written over the WHOLE command text deciding an
|
||||||
|
# ALLOW. That is the fail-open form this file keeps rediscovering, and it had
|
||||||
|
# reached the break-glass itself.
|
||||||
|
#
|
||||||
|
# BREAK-GLASS. `case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire
|
||||||
|
# command if that string appeared anywhere in it — so quoting the override in a
|
||||||
|
# note, or naming a variable after it, disabled the guard for the call sitting
|
||||||
|
# beside it. The override is now read POSITIONALLY: leading `NAME=value`
|
||||||
|
# assignments only, exactly where the shell would honour one.
|
||||||
|
printf '2\t{"tool_input":{"command":"echo \\"MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews\\" >> notes.md"}}\tquoting the override in a document does not arm it\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tan assignment whose VALUE is the override is not the override\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t=10 matched the old substring test and is not the value 1\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"GITEA_TOKEN=$T MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe override still works behind other assignments, as the shell reads it\n'
|
||||||
|
# The cost, pinned rather than discovered later: positional means positional.
|
||||||
|
printf '2\t{"tool_input":{"command":"cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tan override after && is not in command position and does not arm\n'
|
||||||
|
|
||||||
|
# SUBRESOURCE REFINEMENT, same defect one arm lower. It asked whether a
|
||||||
|
# subresource appears ANYWHERE in the command, so a numbered-object write was
|
||||||
|
# cleared on the strength of text in its own BODY. Inverted: clear only when
|
||||||
|
# EVERY numbered-object occurrence carries a subresource.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/pulls/2/files"}}\ta subresource in the body does not clear a write to the numbered issue\tissue-edit.sh\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/issues/3/reactions"}}\tsame, quoting a subresource of the same object type\tissue-edit.sh\n'
|
||||||
|
# ...and its documented cost, in the safe direction.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=cf-/issues/2"}}\tan unwrapped subresource write that quotes a bare issue is refused\n'
|
||||||
|
|
||||||
|
# -K/--config. curl reads the method, the body, the headers AND the URL from
|
||||||
|
# that file, so none of them are in the command: every write test above read 0
|
||||||
|
# and the call went through. An unreadable request is not a cleared one.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe request in a config file is unreadable, so it is refused\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -K /tmp/req https://git.example.invalid/api/v1/repos/a/b/issues"}}\tthe short spelling, same answer\t--config/-K\n'
|
||||||
|
# Cost, stated: this refuses a --config read against a host that has nothing
|
||||||
|
# to do with a forge. The alternative is to require a forge marker in a
|
||||||
|
# command whose URL may itself be in the file, which is the hole again.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://example.invalid/anything"}}\tan unrelated https URL with --config is refused too, by decision\t--config/-K\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"eslint --config .eslintrc.json src/"}}\t--config on a command that is not curl is nobody'"'"'s business\n'
|
||||||
|
|
||||||
|
# ROUND TEN. The --config check above was first written INSIDE the API-shape
|
||||||
|
# gate, so it was guarded by a condition that the capability it guards against
|
||||||
|
# removes. A config file can carry the URL; delete the URL from the command and
|
||||||
|
# nothing is API-shaped, the branch is never entered, and the guard reports
|
||||||
|
# clean on precisely the call it exists to refuse. It is now asked of any curl.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/provider-write.cfg"}}\ta config file can own the URL, so there is nothing API-shaped left to gate on\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
|
||||||
|
# curl by any ordinary path spelling. The first version of the config check
|
||||||
|
# matched the bare word only, so these three executed the same wrapped write
|
||||||
|
# while the guard reported clean. Recognizing only the unqualified name is
|
||||||
|
# caller-name parsing, and that is the class this file exists to refuse.
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
|
||||||
|
# The prefix must end at a slash: a basename that merely ENDS in curl is a
|
||||||
|
# different program, and blocking it would be the over-block that gets a guard
|
||||||
|
# routed around rather than fixed.
|
||||||
|
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
|
||||||
|
|
||||||
|
# And the same name once it is punctuated. The basename repair above fixed the
|
||||||
|
# UNQUOTED path spelling and nothing else, so two quote characters restored the
|
||||||
|
# bypass it had just closed: the check was still modelling one presentation of
|
||||||
|
# a shell word instead of the word. Every one of these executes the real curl.
|
||||||
|
printf '2\t{"tool_input":{"command":"\\"/usr/bin/curl\\" --config /tmp/provider-write.cfg"}}\tquoting a path does not make it a different program\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"'"'"'./curl'"'"' --config /tmp/provider-write.cfg"}}\tnor does quoting a relative one\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"$(which curl) --config /tmp/provider-write.cfg"}}\tthe name is in the text even when a substitution supplies the path\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"`which curl` --config /tmp/provider-write.cfg"}}\tand in the older spelling of the same substitution\t--config/-K\n'
|
||||||
|
|
||||||
|
# The provider-CLI SCOPE gate had the identical defect, untouched while the
|
||||||
|
# curl arm was repaired twice. It decides whether write detection runs at all,
|
||||||
|
# so failing to admit these is indistinguishable from allowing them — and no
|
||||||
|
# URL marker rescues them, because provider CLI paths carry no leading slash.
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/issues -f title=x"}}\tan absolute path to a provider CLI is still a provider CLI\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"./gh api -X POST repos/a/b/issues -f title=x"}}\tand a relative one still is too\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/local/bin/tea api -X POST repos/a/b/issues -f title=x"}}\tthe same is true of every CLI the gate names, not just the first\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
||||||
|
|
||||||
|
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
|
||||||
|
# characters with whitespace, which is token separation and not quote removal:
|
||||||
|
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
|
||||||
|
# one word naming curl while whitespace made it two words naming neither.
|
||||||
|
# `"/usr/bin/curl"` passed under that version only because the inserted space
|
||||||
|
# happened to land after a slash, which established nothing.
|
||||||
|
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
|
||||||
|
# A backslash is NOT uniformly removed. It is literal inside single quotes,
|
||||||
|
# and inside double quotes when it precedes anything other than $, `, ",
|
||||||
|
# backslash, or newline. These spell a different program and must stay allowed.
|
||||||
|
printf '0\t{"tool_input":{"command":"'"'"'cu\\\\rl'"'"' --config /tmp/provider-write.cfg"}}\ta backslash inside single quotes remains literal\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"\\"cu\\\\rl\\" --config /tmp/provider-write.cfg"}}\ta backslash before r inside double quotes remains literal\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"'"'"'g\\\\it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a single-quoted non-git name is not a checkout\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"\\"g\\\\it\\" clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a double-quoted non-git name is not a checkout\n'
|
||||||
|
# The other branch of the same rule: OUTSIDE quotes a backslash escapes the
|
||||||
|
# next character, so an escaped quote is a literal quote IN the name and the
|
||||||
|
# program is not curl. Held separately from the cases above because it is a
|
||||||
|
# different arm of the state machine, and an arm without a fixture is a rule
|
||||||
|
# that is not held.
|
||||||
|
printf '0\t{"tool_input":{"command":"cu\\\\\\"rl\\\\\\" --config /tmp/provider-write.cfg"}}\tan escaped quote is a literal quote in the name\n'
|
||||||
|
# Three quoted segments concatenate into ONE word. This is the shape that
|
||||||
|
# distinguishes quote removal from token separation, so it is worth its own line.
|
||||||
|
printf '2\t{"tool_input":{"command":"\\"cu\\"'"'"'r'"'"'\\"l\\" --config /tmp/provider-write.cfg"}}\tadjacent quoted segments are one word, and that word is curl\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
|
||||||
|
# The FLAG is the same recognition problem as the name, and is read the same
|
||||||
|
# way. No review raised this one; the name was simply the easier half to reach.
|
||||||
|
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
|
||||||
|
|
||||||
|
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
|
||||||
|
# bare-name copy of the scope gate's regex, so a caller could be admitted by
|
||||||
|
# the repaired gate and then go unrecognized by the fail-closed refinement —
|
||||||
|
# a gate and its own refinement disagreeing about who the caller is.
|
||||||
|
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
|
||||||
|
|
||||||
|
# Shapes nobody raised. Written down because reasoning that they were already
|
||||||
|
# covered is precisely what produced two of the rounds above; each one below
|
||||||
|
# was measured, and the three that fail at df83a9ee are here on that evidence.
|
||||||
|
# `\curl` is the ordinary way to bypass a shell alias and is a thing people
|
||||||
|
# actually type, which makes it the least hypothetical entry in the file.
|
||||||
|
printf '2\t{"tool_input":{"command":"\\\\curl --config /tmp/provider-write.cfg"}}\tescaping the leading character to dodge an alias still names curl\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"cur\\"l\\" --config /tmp/provider-write.cfg"}}\tthe quote may sit at any offset in the word\t--config/-K\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST \\"repos/a/b/$EP\\" -f title=x"}}\tboth halves dressed at once, which is where they last disagreed\n'
|
||||||
|
|
||||||
|
# Over-blocking is a real failure and not a safe direction: a guard that
|
||||||
|
# refuses legitimate work gets routed around instead of repaired. These four
|
||||||
|
# pass at both heads, which is what a regression guard is for.
|
||||||
|
printf '0\t{"tool_input":{"command":"gh api \\"repos/a/b/issues\\""}}\ta quoted read is still a read\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"curl https://example.com/file.txt -o /tmp/f"}}\tan ordinary download is not a provider write\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"echo \\"$EP\\" && gh --version"}}\tno api subcommand, so nothing to refuse\n'
|
||||||
|
printf '0\t{"tool_input":{"command":"echo \\"not a curl call\\""}}\tthe word inside a string, with no flag, is prose\n'
|
||||||
|
|
||||||
|
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
|
||||||
|
# both returned HTTP 200 with the same object from the live forge, so the
|
||||||
|
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
||||||
|
# sees a segment matching nothing. Refused rather than decoded — a decoder has
|
||||||
|
# to be exactly right about depth and normalization, which is the parser
|
||||||
|
# mistake this file declines everywhere else.
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%75es/1/comments -f body=x"}}\tan encoded path segment reaches the wrapped endpoint\tpercent-escape\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/revi%%65ws"}}\tsame for the review endpoint, which is the one that matters most\tpercent-escape\n'
|
||||||
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%2575es/1/comments -f body=x"}}\tdouble-encoded too, which is why this refuses instead of decoding\tpercent-escape\n'
|
||||||
|
# Scoped to writes, deliberately. Reads are never blocked by this guard and a
|
||||||
|
# query string carrying %%20 is an ordinary URL, not a hazard.
|
||||||
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
|
||||||
|
} > "$FIXTURES"
|
||||||
|
|
||||||
|
fail=0 n=0
|
||||||
|
while IFS=$'\t' read -r want payload why remedy; do
|
||||||
|
[ -n "${want:-}" ] || continue
|
||||||
|
n=$((n + 1))
|
||||||
|
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
||||||
|
got=$?
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# A block that names the wrong wrapper is a defect in its own right, and until
|
||||||
|
# now it was invisible here: the harness read the exit code and nothing else,
|
||||||
|
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
||||||
|
# six rounds. Where a fixture states the remediation it expects, assert it.
|
||||||
|
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
||||||
|
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf 'ok %s\n' "$why"
|
||||||
|
done < "$FIXTURES"
|
||||||
|
|
||||||
|
# ---- $HOME resolution ------------------------------------------------------
|
||||||
|
# These cannot be fixtures. Every case above varies the COMMAND; this defect
|
||||||
|
# varies the ENVIRONMENT, and the loop has no way to express that.
|
||||||
|
#
|
||||||
|
# The checkout arm built its pattern from "$HOME" without asking whether $HOME
|
||||||
|
# was a usable value. Three values it is not: unset (which is a crash under
|
||||||
|
# `set -u`, not a decision), empty (the pattern collapses to `/`, so `~|\$HOME|`
|
||||||
|
# matches whatever the empty alternative touches), and "/" (every absolute path
|
||||||
|
# is under it, so the comparison stops discriminating). An agent seat running
|
||||||
|
# with no HOME — a systemd unit without one, a container, `env -i` — got the
|
||||||
|
# checkout question answered by accident rather than on the merits.
|
||||||
|
#
|
||||||
|
# The fix resolves $HOME once, rejects all three, and BLOCKS the checkout it
|
||||||
|
# cannot adjudicate. A guard may not clear a question it was unable to ask. The
|
||||||
|
# blast radius of that fail-closed arm is asserted below to be one command shape
|
||||||
|
# and not the session: with no HOME at all, ordinary commands still pass and the
|
||||||
|
# API arms still block.
|
||||||
|
home_case() {
|
||||||
|
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||||
|
local out got
|
||||||
|
n=$((n + 1))
|
||||||
|
local payload
|
||||||
|
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
|
||||||
|
if [ "$homeval" = "@unset" ]; then
|
||||||
|
out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
|
||||||
|
else
|
||||||
|
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
|
||||||
|
fi
|
||||||
|
got=$?
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||||
|
fail=1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||||
|
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||||
|
fail=1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf 'ok %s\n' "$why"
|
||||||
|
}
|
||||||
|
|
||||||
|
home_case 'HOME unset: a checkout is refused, not adjudicated' \
|
||||||
|
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||||
|
home_case 'HOME empty: same, and it is not the same thing as unset' \
|
||||||
|
2 '' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||||
|
home_case 'HOME=/ : every path is under it, so it discriminates nothing' \
|
||||||
|
2 '/' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||||
|
home_case 'a usable HOME still allows a checkout onto a work filesystem' \
|
||||||
|
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
|
||||||
|
home_case 'a usable HOME still catches the literal path' \
|
||||||
|
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
|
||||||
|
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
|
||||||
|
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
|
||||||
|
home_case 'quotes around the exact literal HOME path do not change the target' \
|
||||||
|
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under'
|
||||||
|
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
|
||||||
|
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
|
||||||
|
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
|
||||||
|
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
|
||||||
|
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
|
||||||
|
# HOME would have every command it runs refused, which is how a guard gets
|
||||||
|
# disabled rather than fixed.
|
||||||
|
home_case 'HOME unset does not block an ordinary command' \
|
||||||
|
0 '@unset' 'ls -la /src'
|
||||||
|
home_case 'HOME unset does not stop the API arms doing their job' \
|
||||||
|
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||||
|
|
||||||
|
# ---- the guard standing on its own -----------------------------------------
|
||||||
|
# Every case above runs the guard from the directory holding its siblings, so
|
||||||
|
# `[ -x "$W/pr-review.sh" ]` succeeds and the $HOME fallback beside it never
|
||||||
|
# evaluates. That is a property of the HARNESS, not of the guard, and it hid a
|
||||||
|
# live fail-open: with the guard copied somewhere alone AND no HOME, the
|
||||||
|
# fallback expanded an unset variable under `set -u` and the script died at
|
||||||
|
# rc=1 — on EVERY arm, before any adjudication. A PreToolUse hook exiting
|
||||||
|
# nonzero-but-not-2 is a non-blocking error, so that seat ran with no guard and
|
||||||
|
# nothing reported it.
|
||||||
|
#
|
||||||
|
# The first remediation moved that expansion four lines earlier and called it
|
||||||
|
# closed. It was not closed, because the test could not reach it. So the guard
|
||||||
|
# is copied ALONE here — no siblings, no installed mosaic home — which is the
|
||||||
|
# deployment this file already claims to support ("still works from a repo
|
||||||
|
# checkout with no installed mosaic home").
|
||||||
|
LONE="$TMP/lone"; mkdir -p "$LONE"
|
||||||
|
cp "$GUARD" "$LONE/wrapper-guard.sh"; chmod +x "$LONE/wrapper-guard.sh"
|
||||||
|
|
||||||
|
lone_case() {
|
||||||
|
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||||
|
local out got
|
||||||
|
n=$((n + 1))
|
||||||
|
if [ "$homeval" = "@unset" ]; then
|
||||||
|
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$LONE/wrapper-guard.sh" 2>&1)"
|
||||||
|
else
|
||||||
|
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$LONE/wrapper-guard.sh" 2>&1)"
|
||||||
|
fi
|
||||||
|
got=$?
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
printf 'FAIL %s [standalone] (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||||
|
fail=1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||||
|
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||||
|
fail=1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf 'ok %s [standalone]\n' "$why"
|
||||||
|
}
|
||||||
|
|
||||||
|
lone_case 'no siblings and no HOME: an ordinary command still passes, not rc=1' \
|
||||||
|
0 '@unset' 'ls -la /src'
|
||||||
|
lone_case 'no siblings and no HOME: a wrapped write is still refused' \
|
||||||
|
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||||
|
lone_case 'no siblings and no HOME: a checkout is refused, not adjudicated' \
|
||||||
|
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||||
|
# Spelled without quotes on purpose. The payload is interpolated into a JSON
|
||||||
|
# string by the helper, so a fixture carrying bare double quotes produces
|
||||||
|
# malformed JSON, jq returns empty, and the guard exits 0 on an empty command —
|
||||||
|
# a PASS that measures nothing. That is what the first version of this case did.
|
||||||
|
lone_case 'no siblings and no HOME: the APPROVE trap still fires' \
|
||||||
|
2 '@unset' 'gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE'
|
||||||
|
lone_case 'no siblings, usable HOME: ordinary commands unaffected' \
|
||||||
|
0 '/home/tester' 'ls -la /src'
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$fail" -eq 0 ]; then
|
||||||
|
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
||||||
|
else
|
||||||
|
cat <<'EOF'
|
||||||
|
wrapper-guard drifted from its contract.
|
||||||
|
|
||||||
|
A guard that blocks too much gets routed around, and a guard that blocks too
|
||||||
|
little is decoration. Both directions are failures here, which is why the
|
||||||
|
allowed cases are asserted as hard as the blocked ones.
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
exit "$fail"
|
||||||
+897
@@ -0,0 +1,897 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# wrapper-guard.sh — PreToolUse hook on Bash.
|
||||||
|
#
|
||||||
|
# Blocks three specific, mechanically-detectable mistakes that prose has
|
||||||
|
# repeatedly failed to prevent:
|
||||||
|
#
|
||||||
|
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
||||||
|
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
||||||
|
#
|
||||||
|
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
||||||
|
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
||||||
|
# provider dialect, identity, and queue-guard ordering that raw curl does
|
||||||
|
# not. Reads are untouched — they are how you gather evidence.
|
||||||
|
#
|
||||||
|
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
||||||
|
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
||||||
|
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
||||||
|
# is what a verdict silently failing to land looks like.
|
||||||
|
#
|
||||||
|
# Design constraint: this hook must not become something agents route around.
|
||||||
|
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
||||||
|
# curl for reads, for registry/manifest calls, and for endpoints with no
|
||||||
|
# wrapper (there are many) all pass untouched.
|
||||||
|
#
|
||||||
|
# One consequence is worth knowing before it surprises you: it judges the
|
||||||
|
# payload, not the caller, so a command that merely QUOTES such a write is
|
||||||
|
# refused as well. See the long note at section 2 for why that trade was made.
|
||||||
|
#
|
||||||
|
# Break-glass, for a genuine gap where no wrapper can express the call:
|
||||||
|
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
||||||
|
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
||||||
|
# extend the wrapper, not to keep typing the override.
|
||||||
|
#
|
||||||
|
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT="$(cat)"
|
||||||
|
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
||||||
|
[ -z "$CMD" ] && exit 0
|
||||||
|
|
||||||
|
# Read the command the SHELL will run, not the text as typed. A backslash before
|
||||||
|
# a newline is removed before anything else happens, so
|
||||||
|
# curl -d@b https://host/api/v1/repos/a/b/iss\
|
||||||
|
# ues/1/comments
|
||||||
|
# executes the comments endpoint while the literal token `issues` never appears
|
||||||
|
# in the text. Every check below — position, URL, body, endpoint — reads the
|
||||||
|
# joined form, because that is the command.
|
||||||
|
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
|
||||||
|
|
||||||
|
# A second reading of the SAME command, used by every check below that has to
|
||||||
|
# recognize a program by NAME. It applies shell word formation without executing
|
||||||
|
# expansions, so a name the shell will resolve is a word here regardless of how
|
||||||
|
# it was dressed:
|
||||||
|
#
|
||||||
|
# "/usr/bin/curl" --config /tmp/req quoted absolute path
|
||||||
|
# './curl' --config /tmp/req quoted relative path
|
||||||
|
# $(which curl) --config /tmp/req command substitution
|
||||||
|
# `which curl` --config /tmp/req the older spelling of the same thing
|
||||||
|
# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI
|
||||||
|
# cu"rl" --config /tmp/req quotes INSIDE the word
|
||||||
|
# /usr/bin/cu\rl --config /tmp/req a backslash inside the word
|
||||||
|
# g"h" api -X POST repos/a/b/... the same, in the provider-CLI name
|
||||||
|
#
|
||||||
|
# Every one of them executed the real program and every one returned ALLOW, at
|
||||||
|
# each of this file's three previous heads. The repairs went bare word, then
|
||||||
|
# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and
|
||||||
|
# left the class, and the third is worth spelling out because it is the subtlest
|
||||||
|
# and it was mine: replacing quote characters with whitespace is token
|
||||||
|
# SEPARATION, not quote removal. A shell removes a quote WITHOUT splitting the
|
||||||
|
# word around it, so `cu"rl"` is one word naming curl, while whitespace made it
|
||||||
|
# two words naming neither. `"/usr/bin/curl"` blocked under that version only
|
||||||
|
# because the whitespace happened to land after a slash — a passing case that
|
||||||
|
# established nothing.
|
||||||
|
#
|
||||||
|
# Quote removal and escape handling are separate operations. Outside quotes, a
|
||||||
|
# backslash escapes the next character. Inside single quotes it is literal.
|
||||||
|
# Inside double quotes it escapes only $, `, ", backslash, or newline; before
|
||||||
|
# anything else both the backslash and following character remain literal. Quote
|
||||||
|
# characters themselves are dropped without splitting the word. The existing
|
||||||
|
# substitution flattening remains: unquoted and double-quoted $, (, ), and ` are
|
||||||
|
# dropped, so `$(which curl)` becomes `which curl`, where the name is a word on
|
||||||
|
# its own.
|
||||||
|
#
|
||||||
|
# This does NOT try to be a shell. It cannot see a name that is absent from the
|
||||||
|
# text — assembled from variables, or reached through a wrapper script that
|
||||||
|
# execs the program — and those remain stated limits of inspecting a command
|
||||||
|
# string, not defects a pattern closes. What it removes is the class where the
|
||||||
|
# name IS present and merely punctuated.
|
||||||
|
#
|
||||||
|
# The cost is the one this file already chose and documented for the payload
|
||||||
|
# check further down: quoting an example no longer exempts it, so writing one of
|
||||||
|
# these commands inside quotes on a Bash line is refused too. Applying that same
|
||||||
|
# rule here keeps the file coherent — the alternative is a guard where the
|
||||||
|
# payload arm treats quotes as text and the name arms treat them as armour.
|
||||||
|
normalize_command_words() {
|
||||||
|
local flatten_substitutions="${1:-1}"
|
||||||
|
local protect_path_literals="${2:-0}"
|
||||||
|
awk -v flatten_substitutions="$flatten_substitutions" \
|
||||||
|
-v protect_path_literals="$protect_path_literals" '
|
||||||
|
BEGIN {
|
||||||
|
state = "outside"; out = ""; word_start = 1
|
||||||
|
word_boundary = sprintf("%c", 27)
|
||||||
|
literal_dollar = sprintf("%c", 28)
|
||||||
|
literal_tilde = sprintf("%c", 29)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if (NR > 1) {
|
||||||
|
if (protect_path_literals) out = out word_boundary
|
||||||
|
else out = out "\n"
|
||||||
|
word_start = 1
|
||||||
|
}
|
||||||
|
for (i = 1; i <= length($0); i++) {
|
||||||
|
c = substr($0, i, 1)
|
||||||
|
# A raw marker byte is ordinary word content. Encode it visibly so only
|
||||||
|
# this machine can manufacture an internal boundary marker.
|
||||||
|
if (protect_path_literals && c == word_boundary) {
|
||||||
|
out = out "\\x1b"; word_start = 0; continue
|
||||||
|
}
|
||||||
|
if (state == "single") {
|
||||||
|
if (c == "\047") {
|
||||||
|
state = "outside"
|
||||||
|
} else {
|
||||||
|
if (protect_path_literals && c == "$") out = out literal_dollar
|
||||||
|
else if (protect_path_literals && c == "~") out = out literal_tilde
|
||||||
|
else out = out c
|
||||||
|
word_start = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (state == "double") {
|
||||||
|
if (c == "\"") {
|
||||||
|
state = "outside"
|
||||||
|
} else if (c == "\\") {
|
||||||
|
if (i == length($0)) {
|
||||||
|
out = out c
|
||||||
|
} else {
|
||||||
|
nextc = substr($0, i + 1, 1)
|
||||||
|
if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") {
|
||||||
|
if (protect_path_literals && nextc == "$") out = out literal_dollar
|
||||||
|
else out = out nextc
|
||||||
|
} else {
|
||||||
|
out = out c
|
||||||
|
if (protect_path_literals && nextc == "~") out = out literal_tilde
|
||||||
|
else out = out nextc
|
||||||
|
}
|
||||||
|
word_start = 0
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
} else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
|
||||||
|
if (protect_path_literals && c == "~") out = out literal_tilde
|
||||||
|
else out = out c
|
||||||
|
word_start = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (protect_path_literals && c ~ /[[:space:]|&;()<>]/) {
|
||||||
|
out = out word_boundary
|
||||||
|
word_start = 1
|
||||||
|
} else if (c == "\\") {
|
||||||
|
if (i == length($0)) {
|
||||||
|
out = out c
|
||||||
|
} else {
|
||||||
|
nextc = substr($0, i + 1, 1)
|
||||||
|
if (protect_path_literals && nextc == "$") out = out literal_dollar
|
||||||
|
else if (protect_path_literals && nextc == "~") out = out literal_tilde
|
||||||
|
else out = out nextc
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
word_start = 0
|
||||||
|
} else if (c == "\047") {
|
||||||
|
state = "single"
|
||||||
|
} else if (c == "\"") {
|
||||||
|
state = "double"
|
||||||
|
} else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
|
||||||
|
if (protect_path_literals && c == "~" && !word_start) out = out literal_tilde
|
||||||
|
else out = out c
|
||||||
|
if (c ~ /[[:space:]|;&]/) word_start = 1
|
||||||
|
else word_start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { printf "%s", out }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)"
|
||||||
|
# Paths need the same quote and escape handling, but not name-mode substitution
|
||||||
|
# flattening: an expansion-capable `$HOME` spelling must remain visible to the
|
||||||
|
# checkout check. Unquoted POSIX shell metacharacters become an internal word-
|
||||||
|
# boundary marker; quoted/escaped metacharacters remain content. Shell-literal
|
||||||
|
# dollar/tilde characters become separate nonmatching markers; otherwise quote
|
||||||
|
# removal would create a HOME spelling the shell never expands. A path assembled
|
||||||
|
# from a different variable or command substitution is absent from the literal text and
|
||||||
|
# remains outside a text guard's visibility.
|
||||||
|
CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)"
|
||||||
|
PATH_WORD_BOUNDARY=$'\033'
|
||||||
|
|
||||||
|
# The one place the shape of a program NAME is written down. Every name consumer
|
||||||
|
# below uses it, so the next fix to this class lands in a single location instead of
|
||||||
|
# being applied to whichever arm review happened to probe. The prefix must end
|
||||||
|
# at a slash: `mycurl` and `curl-wrapper` are different programs, and blocking
|
||||||
|
# them is the over-block that gets a guard routed around instead of repaired.
|
||||||
|
NAME_PREFIX='(^|[[:space:]|;&])([^[:space:]|;&]*/)?'
|
||||||
|
|
||||||
|
# Honour the override only where a shell would actually TREAT it as one: the
|
||||||
|
# environment-assignment run at the head of the command, or this process's own
|
||||||
|
# environment. The first version asked whether the token appeared ANYWHERE in the
|
||||||
|
# command text. That is not a test of what the shell does; it is a test of what
|
||||||
|
# the string contains, and three shapes turned the whole guard off silently, each
|
||||||
|
# with exit 0 and no message:
|
||||||
|
#
|
||||||
|
# curl -d '{"body":"... MOSAIC_WRAPPER_OVERRIDE=1 ..."}' .../issues/1/comments
|
||||||
|
# a quoted BODY disabling the guard for its own write — and the bodies most
|
||||||
|
# likely to carry the token are this file's own documentation, a relayed
|
||||||
|
# block message, or a commit message quoting a previous refusal;
|
||||||
|
# NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl ...
|
||||||
|
# the token as another variable's VALUE, which sets nothing;
|
||||||
|
# ... MOSAIC_WRAPPER_OVERRIDE=10 ...
|
||||||
|
# `*=1*` matched `=10`, `=1x`, `=123`; the glob never bounded the value.
|
||||||
|
#
|
||||||
|
# A control that is off is worse than no control, because the block message is
|
||||||
|
# what tells an agent the control exists. So the override is now read
|
||||||
|
# POSITIONALLY, by the rule a shell uses: leading assignments only, up to the
|
||||||
|
# first token that is not one. A body can never occupy that position, and the
|
||||||
|
# value must be exactly 1.
|
||||||
|
#
|
||||||
|
# Deliberate cost, stated rather than discovered: `cd /x && MOSAIC_WRAPPER_OVERRIDE=1
|
||||||
|
# curl ...` is NOT honoured — only the head of the command is, and only its first
|
||||||
|
# line, because an override applies to the command it prefixes and not to a later
|
||||||
|
# one. Putting the override first is the remedy, and this direction fails closed.
|
||||||
|
override_prefixed() {
|
||||||
|
local first tok
|
||||||
|
first="${CMD%%$'\n'*}"
|
||||||
|
local IFS=$' \t'
|
||||||
|
set -f
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $first
|
||||||
|
set +f
|
||||||
|
for tok in "$@"; do
|
||||||
|
case "$tok" in
|
||||||
|
MOSAIC_WRAPPER_OVERRIDE=1) return 0 ;;
|
||||||
|
[A-Za-z_]*=*) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
override_prefixed && exit 0
|
||||||
|
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
|
||||||
|
|
||||||
|
# The wrappers this guard points at are its own siblings. Resolving relative to
|
||||||
|
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
|
||||||
|
# names the wrappers from the same install it was launched from, and that it
|
||||||
|
# still works from a repo checkout with no installed mosaic home (which is how it
|
||||||
|
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
|
||||||
|
# absolute path from somewhere unusual.
|
||||||
|
# $HOME is resolved HERE, once, before anything expands it. The previous attempt
|
||||||
|
# fixed the checkout arm's use of $HOME and left this one, four lines earlier,
|
||||||
|
# reading it raw — so under `set -u` a seat with no HOME still died before
|
||||||
|
# reaching the adjudication that was supposed to handle exactly that. Moving a
|
||||||
|
# fail-open earlier in the file is not closing it. There is now exactly one
|
||||||
|
# expansion of HOME in this script and it is guarded; every later use reads
|
||||||
|
# HOME_DIR / home_known instead, so a new use cannot reintroduce the abort
|
||||||
|
# without going through this block.
|
||||||
|
#
|
||||||
|
# Empty and unset are different values and neither one is a home directory. '/'
|
||||||
|
# is rejected for the same reason as '': every path is under it, so comparing
|
||||||
|
# against it stops discriminating at all.
|
||||||
|
HOME_DIR=""
|
||||||
|
home_known=0
|
||||||
|
case "${HOME-}" in
|
||||||
|
/?*) HOME_DIR="$HOME"; home_known=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
[ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git"
|
||||||
|
|
||||||
|
# ---- 1. checkout into $HOME ------------------------------------------------
|
||||||
|
# $HOME has to be RESOLVED before anything can be compared against it, and the
|
||||||
|
# first version interpolated it directly into the pattern. Both ways of it being
|
||||||
|
# absent were wrong, in OPPOSITE directions, which is why neither showed up as a
|
||||||
|
# simple "it stopped working":
|
||||||
|
#
|
||||||
|
# HOME unset under `set -u` the expansion aborts the script. A PreToolUse
|
||||||
|
# hook exiting nonzero-but-not-2 is a non-blocking error, so the
|
||||||
|
# checkout it was asked about is ALLOWED. The guard failed open in
|
||||||
|
# precisely the case where it could not answer the question.
|
||||||
|
# HOME='' the alternation gained an EMPTY branch — (~|\$HOME|)/ — which
|
||||||
|
# matches any slash at all, so a legitimate /src checkout was
|
||||||
|
# refused. Unusable in the other direction.
|
||||||
|
#
|
||||||
|
# Empty and unset are different values and neither one is a home directory. '/'
|
||||||
|
# is rejected for the same reason as '': every path is under it, so the
|
||||||
|
# comparison stops discriminating at all. Where the literal path cannot be
|
||||||
|
# established the `~` and `$HOME` spellings are still checked, and a checkout
|
||||||
|
# left unresolved BLOCKS rather than clears — a guard may not clear a question it
|
||||||
|
# was unable to ask. The blast radius of that fail-closed arm is exactly one
|
||||||
|
# command shape (clone / worktree add), not the session.
|
||||||
|
# Braces are a pair, not independently optional: `$HOME}` expands HOME and
|
||||||
|
# appends a literal `}`, while `${HOME` is not a valid expansion. Treating either
|
||||||
|
# as `${HOME}` would create a home path the shell never resolves.
|
||||||
|
home_re='~|\$HOME|\$\{HOME\}'
|
||||||
|
if [ "$home_known" -eq 1 ]; then
|
||||||
|
home_re="$home_re|$(printf '%s' "$HOME_DIR" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then
|
||||||
|
if [ "$home_known" -eq 0 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: this is a checkout, and \$HOME is unset or unusable in this shell.
|
||||||
|
|
||||||
|
The guard's job here is to answer one question — does this check out under
|
||||||
|
\$HOME — and it cannot answer it without a usable \$HOME. An unanswerable
|
||||||
|
question is not a cleared one, so this refuses rather than guesses. (\$HOME
|
||||||
|
empty, unset, and "/" are all treated the same way: none of them is a home
|
||||||
|
directory, and "/" would match every path there is.)
|
||||||
|
|
||||||
|
Set HOME to the account's home directory and re-run, or use the helper, which
|
||||||
|
derives the path and never consults \$HOME at all:
|
||||||
|
|
||||||
|
$W/mosaic-worktree.sh new <branch>
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# Any argument that resolves to $HOME itself or beneath it. The path-mode
|
||||||
|
# normalizer marks every unquoted POSIX shell word boundary, so the home token
|
||||||
|
# must be preceded by a boundary/start/assignment and followed by a boundary,
|
||||||
|
# slash (a descendant), or end. Arbitrary filename bytes remain content; this
|
||||||
|
# avoids over-blocking sibling names such as $HOME+bak or $HOME.bak.
|
||||||
|
if printf '%s' "$CMD_PATHS" | grep -Eq "(^|=|$PATH_WORD_BOUNDARY)($home_re)($PATH_WORD_BOUNDARY|/|$)"; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: this checks a repository out under \$HOME.
|
||||||
|
|
||||||
|
\$HOME holds configuration, credentials, state and caches. It does not hold
|
||||||
|
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
|
||||||
|
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
|
||||||
|
|
||||||
|
Use the helper, which derives the path so you do not have to choose one:
|
||||||
|
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
|
||||||
|
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||||
|
|
||||||
|
Worktrees, not clones: they share the object store, and \`git worktree list\`
|
||||||
|
makes every one of them enumerable — which is the only reason cleanup can
|
||||||
|
ever be safe.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2/3. provider API writes ---------------------------------------------
|
||||||
|
# A raw provider write this guard cares about is two things: a WRITE, and a URL
|
||||||
|
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
|
||||||
|
# are how you gather evidence — and the many endpoints with no wrapper flow
|
||||||
|
# through.
|
||||||
|
#
|
||||||
|
# It deliberately does NOT ask which program makes the call, or whether that
|
||||||
|
# program sits at shell command position. It used to, and that is the whole
|
||||||
|
# history of this file. Answering "is this code or is this data" from the text
|
||||||
|
# of a shell command required a skeleton with quoted spans and heredoc bodies
|
||||||
|
# removed, an invoker list for the forms where a shell executes quoted text, a
|
||||||
|
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
|
||||||
|
# backslash-newline joining. Five rounds of adversarial review put nineteen
|
||||||
|
# writes straight through it, and every one had the same shape: the client was
|
||||||
|
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
|
||||||
|
# command prefixes, option values, pipes into a shell, and finally command
|
||||||
|
# substitution inside the very quotes the skeleton was discarding:
|
||||||
|
# echo "$(curl -d@b .../issues/1/comments)"
|
||||||
|
# msg="$(curl -d@b .../issues/1/comments)"
|
||||||
|
# Classifying code against data in shell text with sed and awk is not a hard
|
||||||
|
# problem, it is the wrong problem. It was not even portable: under CI's busybox
|
||||||
|
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
|
||||||
|
# and the guard started refusing ordinary prose instead — which is the other way
|
||||||
|
# a control like this dies.
|
||||||
|
#
|
||||||
|
# So the client detection is gone, and with it that entire failure class: what
|
||||||
|
# is left cannot fail open by hiding the caller, because it never looks for one.
|
||||||
|
# It looks for the payload. Something that names a wrapped endpoint and carries
|
||||||
|
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
|
||||||
|
# nobody has thought of yet.
|
||||||
|
#
|
||||||
|
# The cost is real and belongs in the open, because over-blocking is how a hook
|
||||||
|
# gets switched off: QUOTING one of these calls on a Bash command line now
|
||||||
|
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
|
||||||
|
# an example into a file. There is no textual way to tell a quoted example from
|
||||||
|
# a quoted command — that is exactly the finding above — so the rule is the one
|
||||||
|
# an agent can hold in mind without a parser:
|
||||||
|
#
|
||||||
|
# do not put a raw write to a wrapped forge endpoint on a Bash command line,
|
||||||
|
# not even inside quotes.
|
||||||
|
#
|
||||||
|
# Write the example with a file-writing tool, or leave the body flag out of it.
|
||||||
|
# That is a deliberate narrowing of scope, not an oversight. This hook stops
|
||||||
|
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
|
||||||
|
# control nobody can trust the boundaries of.
|
||||||
|
#
|
||||||
|
# Scoped to commands that are provider-API-shaped, so nothing else is even
|
||||||
|
# considered. The first version of this scope gate asked only for `https?://`,
|
||||||
|
# and review found the absence shape had simply moved to the new boundary:
|
||||||
|
# gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE
|
||||||
|
# tea api -X POST repos/a/b/issues/1/comments -f body=x
|
||||||
|
# curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues
|
||||||
|
# all carry a real write to a wrapped endpoint and none carries a scheme, so the
|
||||||
|
# guard never asked the write question at all. Gate 7 covers raw provider CLIs,
|
||||||
|
# so these are in scope and the gate now names the shapes they come in.
|
||||||
|
#
|
||||||
|
# Then review found the same absence at the same boundary a second time, in the
|
||||||
|
# one provider whose paths carry no version marker at all:
|
||||||
|
# curl -X POST -d x api.github.com/repos/a/b/issues
|
||||||
|
# GitHub's API is `api.github.com/repos/...`; Gitea's is `/api/v1/repos/...`.
|
||||||
|
# Asking for `/api/v[0-9]` therefore admitted the schemeless Gitea write and
|
||||||
|
# excluded the schemeless GitHub one — a gate calibrated to one dialect's
|
||||||
|
# spelling rather than to what identifies a provider API. So the gate names both
|
||||||
|
# markers: a version segment, and the `/repos/` path that every forge API uses
|
||||||
|
# to address a repository.
|
||||||
|
#
|
||||||
|
# Adding alternatives to a scope gate can only make it stricter — it cannot
|
||||||
|
# create a new allow — which is why this is a list of triggers rather than a
|
||||||
|
# model of any one caller. Downstream, a block still requires a body flag AND
|
||||||
|
# either a mapped endpoint or an unreadable one, so widening the gate widens
|
||||||
|
# what is CONSIDERED, not what is refused.
|
||||||
|
#
|
||||||
|
# Residual, stated rather than implied: a caller who splits `/repos/` itself in
|
||||||
|
# a schemeless GitHub URL (`h=api.github.com/rep; q=os/a/b/issues`) leaves no
|
||||||
|
# literal marker anywhere and is out of scope. That is the same boundary as
|
||||||
|
# splitting the hostname — no longer a mistake anyone makes by accident.
|
||||||
|
#
|
||||||
|
# Boundary, deliberate and worth stating: this covers the `api` subcommand,
|
||||||
|
# which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`,
|
||||||
|
# `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb
|
||||||
|
# grammar, which is the parser mistake again in a new costume. Porcelain is a
|
||||||
|
# gate-7 gap for prose and review to hold, not this hook.
|
||||||
|
# ---- curl whose request lives in a file ------------------------------------
|
||||||
|
# curl takes its options from a file with -K/--config, and that file may carry
|
||||||
|
# the method, the body, the headers AND THE URL. That last one is why this test
|
||||||
|
# cannot live inside the API-shape gate below, which is where it was first put:
|
||||||
|
#
|
||||||
|
# curl --config /tmp/provider-write.cfg
|
||||||
|
#
|
||||||
|
# has no URL, no /repos/, no `gh api` — nothing API-shaped in the text at all —
|
||||||
|
# so it never entered the branch that was supposed to refuse it, and the guard
|
||||||
|
# reported clean on the exact capability the check exists to deny. The check was
|
||||||
|
# guarded by a condition the thing it guards against defeats. It is now asked of
|
||||||
|
# any curl, because "is this a provider call" is not answerable about a command
|
||||||
|
# whose URL is in a file, and a question that cannot be asked is not a question
|
||||||
|
# that came back clean.
|
||||||
|
#
|
||||||
|
# The spelling is deliberately loose. curl accepts the value attached to the
|
||||||
|
# short flag (`-K/tmp/req`, verified) and inside a bundle (`-sK /tmp/req`), and a
|
||||||
|
# guard that recognizes only the space- and equals-separated forms is defeated by
|
||||||
|
# deleting one character. Scoped to curl so that `eslint --config .eslintrc.json`
|
||||||
|
# and every other tool with a --config flag are untouched.
|
||||||
|
#
|
||||||
|
# curl is recognized as a NAME, through $CMD_NAMES and $NAME_PREFIX — see the
|
||||||
|
# comment on those at the top of the file for why matching the bare word, and
|
||||||
|
# then matching the unquoted basename, were both the same mistake at different
|
||||||
|
# depths. A wrapper script that execs curl on the operator's behalf is still
|
||||||
|
# invisible here, because neither the name nor the request appears in the
|
||||||
|
# command text at all. That is a limit of inspecting a command string rather
|
||||||
|
# than a defect this pattern can close, and it is stated rather than left for
|
||||||
|
# the next reader to find.
|
||||||
|
# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as
|
||||||
|
# the name — `--con"fig"` is one word spelling --config — and no review has
|
||||||
|
# raised it yet only because the name was the easier half to reach. Reading both
|
||||||
|
# halves the same way is the point of having one normalization.
|
||||||
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
|
||||||
|
&& printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: curl invocation whose request is supplied from a --config/-K file.
|
||||||
|
|
||||||
|
curl reads the request method, body, headers and even the URL from that file.
|
||||||
|
None of them appear in the command, so this guard cannot tell whether the call
|
||||||
|
is a read or a write, what endpoint it reaches, or whether it is a provider call
|
||||||
|
at all. An unreadable request is not a cleared one.
|
||||||
|
|
||||||
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
||||||
|
|
||||||
|
If this is a read, spell the request on the command line so it is legible. If it
|
||||||
|
is a write to a wrapped endpoint, use the wrapper. For a genuine gap no wrapper
|
||||||
|
can express, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first thing in the
|
||||||
|
command — it is read positionally, not matched as text).
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The scope gate comes in two halves because its two triggers are different
|
||||||
|
# kinds of thing, and collapsing them into one regex over one string is what
|
||||||
|
# hid the second instance of the caller-name defect for three review rounds.
|
||||||
|
#
|
||||||
|
# The URL markers are TEXT: a scheme, a version segment, a `/repos/` path. They
|
||||||
|
# are read from the command as written.
|
||||||
|
API_SHAPED='https?://|/api/v[0-9]|/repos/'
|
||||||
|
#
|
||||||
|
# The provider-CLI marker is a NAME, so it is read the way names are read here —
|
||||||
|
# through $CMD_NAMES, with $NAME_PREFIX. Asking for a bare `gh api` let every
|
||||||
|
# path spelling through the gate that decides whether write detection runs AT
|
||||||
|
# ALL, so `/usr/bin/gh api -X POST repos/a/b/issues -f title=x` was never even
|
||||||
|
# considered. No URL marker rescued it: provider CLI endpoints are spelled
|
||||||
|
# `repos/...` with no leading slash, so `/repos/` does not match them either.
|
||||||
|
# A scope gate that fails to admit is indistinguishable from an allow.
|
||||||
|
PROVIDER_CLI="${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)"
|
||||||
|
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \
|
||||||
|
|| printf '%s' "$CMD_NAMES" | grep -Eq "$PROVIDER_CLI"; then
|
||||||
|
|
||||||
|
# Write detection, now client-agnostic. Every spelling curl accepts, because
|
||||||
|
# the guard is defeated by the one spelling it does not know: `-d@body` (no
|
||||||
|
# space) and `--request=POST` (equals form) both slipped past the first
|
||||||
|
# version. Plus wget's forms and a library call, which a client-shaped test
|
||||||
|
# could not have seen at all.
|
||||||
|
#
|
||||||
|
# A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
|
||||||
|
# .../issues/1/comments` is therefore refused, which is a read wearing a
|
||||||
|
# write's flag. That direction is the acceptable one: it costs an override on
|
||||||
|
# a rare command, where the reverse costs a silent raw write.
|
||||||
|
is_write=0
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||||
|
# curl sends POST implicitly when handed a body, in any of these forms.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
|
||||||
|
# The provider CLIs POST implicitly the same way curl does, when handed a
|
||||||
|
# field. Matched only in `-f key=value` shape, so the far more common `rm -f`
|
||||||
|
# and `grep -f` cannot be read as a body. The trailing `[]` is the array
|
||||||
|
# spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and
|
||||||
|
# without it the key class stopped at the bracket and the field was not seen
|
||||||
|
# as a body at all — found while pinning the labels/assignees repros, both of
|
||||||
|
# which carry it.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1
|
||||||
|
# ...and a library call is a write without any flag at all.
|
||||||
|
#
|
||||||
|
# Second documented over-block, and broader than the body flags because it
|
||||||
|
# needs no flag: any text carrying `.post(` near a wrapped URL is refused,
|
||||||
|
# including prose that merely quotes it. That follows from the same rule as
|
||||||
|
# the quoted-curl cost above — the payload is judged, not the caller — and it
|
||||||
|
# is stated here so it is a known boundary rather than a surprise.
|
||||||
|
printf '%s' "$CMD" | grep -Eq -- \
|
||||||
|
'\.(post|put|patch|delete)\(' && is_write=1
|
||||||
|
|
||||||
|
if [ "$is_write" -eq 1 ]; then
|
||||||
|
# A percent-escape makes the endpoint unreadable HERE and perfectly readable
|
||||||
|
# to the PROVIDER, which is the whole hazard. Measured against the live
|
||||||
|
# forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and
|
||||||
|
# GET /repos/mosaicstack/stack/iss%75es/1174 both returned HTTP 200 with the
|
||||||
|
# same object. So `iss%75es` IS the wrapped endpoint by the only authority
|
||||||
|
# that gets a vote, while every literal comparison below sees a segment that
|
||||||
|
# matches nothing and clears the write.
|
||||||
|
#
|
||||||
|
# This refuses rather than decodes. A decoder has to be exactly right about
|
||||||
|
# depth (%2569 -> %69 -> i) and about which characters the provider
|
||||||
|
# normalizes, and being exactly right about someone else's parser is the
|
||||||
|
# mistake this file declines everywhere else. Refusing is correct at every
|
||||||
|
# depth at once.
|
||||||
|
#
|
||||||
|
# Scoped to WRITES. Reads are never blocked by this guard, and a query
|
||||||
|
# string carrying %20 is not a hazard — it is an ordinary URL. Putting this
|
||||||
|
# test on the whole API-shaped branch would have refused those too, which is
|
||||||
|
# how a guard earns being routed around.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq '%[0-9A-Fa-f][0-9A-Fa-f]'; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: provider API WRITE containing a percent-escape.
|
||||||
|
|
||||||
|
The provider decodes the path before routing; this guard compares it literally.
|
||||||
|
An encoded segment therefore reaches a wrapped endpoint while reading, here, as
|
||||||
|
a segment that matches nothing — /iss%75es/ and /issues/ were measured returning
|
||||||
|
the same object from the same repository.
|
||||||
|
|
||||||
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
||||||
|
|
||||||
|
Spell the path literally and re-run. If the escape is genuinely required and no
|
||||||
|
wrapper can express the call, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first
|
||||||
|
thing in the command — it is read positionally, not matched as text).
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The endpoint map, and the rule that keeps it honest: an arm exists here
|
||||||
|
# ONLY because a wrapper in this directory owns that call. It is an
|
||||||
|
# inventory, not a model — read off `ls tools/git/*.sh` and the flags each
|
||||||
|
# script accepts, so it can be re-derived and checked rather than believed.
|
||||||
|
# Second column is what the wrapper SPANS; a partial span must be stated in
|
||||||
|
# the block message, never rounded up to ownership of the whole endpoint.
|
||||||
|
#
|
||||||
|
# /pulls/{n}/reviews pr-review.sh full
|
||||||
|
# /pulls/{n}/merge pr-merge.sh full (-m method, -d)
|
||||||
|
# /issues/{n}/comments issue-comment.sh create only (POST)
|
||||||
|
# /issues|pulls/{n}/assignees issue-assign.sh full (-a, -r)
|
||||||
|
# /issues|pulls/{n}/labels issue-edit.sh sets the whole list;
|
||||||
|
# issue-assign.sh -l same
|
||||||
|
# /milestones/{n} milestone-close.sh CLOSE ONLY — title,
|
||||||
|
# description, due date
|
||||||
|
# are a wrapper gap
|
||||||
|
# /issues/{n} issue-edit.sh title/body/labels/
|
||||||
|
# milestone; close/reopen
|
||||||
|
# for state; assignee is
|
||||||
|
# issue-assign.sh
|
||||||
|
# /pulls/{n} pr-close.sh state only — title and
|
||||||
|
# body are a wrapper gap
|
||||||
|
# /pulls, /issues, the create wrappers full
|
||||||
|
# /milestones
|
||||||
|
#
|
||||||
|
# Owned by nothing, so they flow through: /issues/comments/{id} (a comment
|
||||||
|
# EDIT), /pulls/{n}/requested_reviewers, and the residue caught by
|
||||||
|
# subtraction below.
|
||||||
|
#
|
||||||
|
# Round seven had a single arm allowing EVERY path under a numbered issue or
|
||||||
|
# PR, on the reasoning that no wrapper owned any of them. Review showed that
|
||||||
|
# was false in this tree — issue-edit.sh takes --title/--body/--labels/
|
||||||
|
# --milestone and issue-assign.sh takes assignee/labels/milestone — so the
|
||||||
|
# guard was answering "allow" because wrapper ownership had been ASSUMED
|
||||||
|
# absent instead of looked up. That is the same absence-driven allow the
|
||||||
|
# whole file exists to remove, committed inside the fix for it. The lesson
|
||||||
|
# is not "block more"; it is that ownership is an inventory question and an
|
||||||
|
# inventory has to be read.
|
||||||
|
#
|
||||||
|
# Wrong advice remains its own defect — a block an agent cannot comply with
|
||||||
|
# teaches that the hook is broken and the override is routine, and a routine
|
||||||
|
# override is a guard that is off. So the fix is precision in BOTH
|
||||||
|
# directions: every arm names the wrapper that actually owns the call, and
|
||||||
|
# anything genuinely unowned still flows through (below).
|
||||||
|
#
|
||||||
|
# Round eight got the inventory right and the SPAN wrong, which review caught
|
||||||
|
# on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes
|
||||||
|
# state=closed, so it cannot express a title, description or due-date edit,
|
||||||
|
# and naming it there told an agent to use a wrapper that cannot make the
|
||||||
|
# call. "Which wrapper touches this endpoint" is the wrong question; "does
|
||||||
|
# the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only
|
||||||
|
# a slice, `alsoown` must say which slice and name the rest as a gap — the
|
||||||
|
# treatment /pulls/{n} already had, and that two other arms did not, so this
|
||||||
|
# was a consistency failure rather than a missing idea. Auditing every arm
|
||||||
|
# for span (not just the reported one) is what found requested_reviewers.
|
||||||
|
endpoint=""; wrapper=""; alsoown=""
|
||||||
|
case "$CMD" in
|
||||||
|
# Requesting a reviewer is not submitting one. pr-review.sh takes
|
||||||
|
# -a <action> -c <comment> and files a verdict; nothing in the tree adds a
|
||||||
|
# requested reviewer. Unowned, so it flows through — placed above the
|
||||||
|
# reviews arm so it cannot be refused with "use pr-review.sh".
|
||||||
|
*"/pulls/"*"/requested_reviewers"*) : ;;
|
||||||
|
*"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
|
||||||
|
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="pr-merge.sh" ;;
|
||||||
|
# A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the
|
||||||
|
# numbered issue, not a child of it. issue-comment.sh only creates, so
|
||||||
|
# nothing owns this one. Placed above the create arm so it cannot be
|
||||||
|
# refused with "use issue-comment.sh", which would be the wrong call.
|
||||||
|
*"/issues/comments/"*) : ;;
|
||||||
|
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="issue-comment.sh" ;;
|
||||||
|
*"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*)
|
||||||
|
endpoint="issue assignee"; wrapper="issue-assign.sh" ;;
|
||||||
|
*"/issues/"*"/labels"*|*"/pulls/"*"/labels"*)
|
||||||
|
endpoint="issue label"; wrapper="issue-edit.sh"
|
||||||
|
alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;;
|
||||||
|
*"/milestones/"[0-9]*) endpoint="milestone"; wrapper="milestone-close.sh"
|
||||||
|
alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title>
|
||||||
|
and sends state=closed. A milestone's title, description or due date is a real
|
||||||
|
wrapper gap: no tool in this tree edits them, and the override exists for it." ;;
|
||||||
|
# The numbered object itself. These two arms are the fuzzy ones — they
|
||||||
|
# match a number and then anything — so they are refined immediately
|
||||||
|
# below rather than trusted as written.
|
||||||
|
*"/issues/"[0-9]*) endpoint="issue edit"; wrapper="issue-edit.sh"
|
||||||
|
alsoown="issue-close.sh and issue-reopen.sh own the state change, and
|
||||||
|
issue-assign.sh owns the assignee, labels and milestone fields at this same
|
||||||
|
number — issue-edit.sh does not set an assignee." ;;
|
||||||
|
*"/pulls/"[0-9]*) endpoint="pull-request edit"; wrapper="pr-close.sh"
|
||||||
|
alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and
|
||||||
|
milestone are the ISSUE object on both providers, so issue-edit.sh and
|
||||||
|
issue-assign.sh own those at the same number. Nothing wraps a PR title/body
|
||||||
|
edit — that one is a real wrapper gap, and the override exists for it." ;;
|
||||||
|
*"/pulls"*) endpoint="pull request"; wrapper="pr-create.sh" ;;
|
||||||
|
*"/issues"*) endpoint="issue"; wrapper="issue-create.sh" ;;
|
||||||
|
*"/milestones"*) endpoint="milestone"; wrapper="milestone-create.sh" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Refine the two fuzzy arms, and note WHY this is a regex and not another
|
||||||
|
# case arm: `case` globs cannot express a path SEGMENT, so an allow arm
|
||||||
|
# written as *"/issues/"[0-9]*"/"* would clear
|
||||||
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"
|
||||||
|
# on the strength of a slash inside the body. An allow decided by a glob
|
||||||
|
# over the whole command is exactly the fail-open shape this file keeps
|
||||||
|
# finding; the regex pins the segment to the number.
|
||||||
|
#
|
||||||
|
# The residue is defined by SUBTRACTION rather than by listing provider API
|
||||||
|
# surface: every subresource a wrapper owns was consumed by an arm above, so
|
||||||
|
# whatever still carries /issues|pulls/{n}/<segment> here is owned by
|
||||||
|
# nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's
|
||||||
|
# files or commits. Listing them instead would rot the moment a provider
|
||||||
|
# adds one, and rot in the blocking direction with wrong advice.
|
||||||
|
# The refinement is an ALLOW, and an allow decided by a test over the whole
|
||||||
|
# command is the fail-open shape this file keeps rediscovering — the comment
|
||||||
|
# above says exactly that about `case` globs, and then the regex it replaced
|
||||||
|
# them with made the same mistake one level down. Asking "does a subresource
|
||||||
|
# appear ANYWHERE in this command" cleared a write on the strength of text
|
||||||
|
# that was not the endpoint:
|
||||||
|
#
|
||||||
|
# gh api -X PATCH repos/a/b/issues/1 -f body="see /pulls/2/files"
|
||||||
|
# gh api -X PATCH repos/a/b/issues/1 -f body="cf /issues/3/reactions"
|
||||||
|
#
|
||||||
|
# Both PATCH the numbered issue that issue-edit.sh owns, and both were
|
||||||
|
# allowed because a subresource appeared in the BODY. Same class as the
|
||||||
|
# backslash-newline case at the top of the file: the guard read the text as
|
||||||
|
# typed instead of the call being made.
|
||||||
|
#
|
||||||
|
# Extracting "the endpoint token" is the parser problem this file already
|
||||||
|
# refused to take on, so the test is inverted instead, which needs no parser:
|
||||||
|
# clear ONLY when every numbered-object occurrence in the command carries a
|
||||||
|
# subresource. One bare /issues/{n} anywhere means a wrapped call may be in
|
||||||
|
# play, and the block stands. Cost, in the same direction as every other
|
||||||
|
# trade here: writing to /issues/1/reactions while quoting /issues/2 is
|
||||||
|
# refused. Over-blocking costs an override on a rare command; the reverse
|
||||||
|
# cost is a silent raw write to a wrapped endpoint.
|
||||||
|
case "$endpoint" in
|
||||||
|
"issue edit"|"pull-request edit")
|
||||||
|
occ="$(printf '%s' "$CMD" | grep -oE '/(issues|pulls)/[0-9]+(/[A-Za-z_])?' || true)"
|
||||||
|
if [ -n "$occ" ] && ! printf '%s' "$occ" | grep -q '[0-9]$'; then
|
||||||
|
endpoint=""; wrapper=""; alsoown=""
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# An endpoint the guard cannot READ is an endpoint the guard must not CLEAR.
|
||||||
|
#
|
||||||
|
# Round one fixed one spelling of this and review immediately produced the
|
||||||
|
# general form: split the endpoint token itself across two variables —
|
||||||
|
# a=/api/v1/repos/o/r/iss; b=ues/1/comments
|
||||||
|
# curl -d@body "https://host${a}${b}"
|
||||||
|
# — and no fragment above ever appears contiguously. Chasing that with more
|
||||||
|
# fragments is unwinnable: the endpoint does not exist until the shell
|
||||||
|
# expands it, and this hook runs before that.
|
||||||
|
#
|
||||||
|
# So stop pretending to read it. If a write's endpoint contains an expansion,
|
||||||
|
# the guard has no endpoint to judge, and "no endpoint" must not mean
|
||||||
|
# "allowed" — that is the same absence-driven allow as the missing-wrapper
|
||||||
|
# case, wearing different clothes.
|
||||||
|
#
|
||||||
|
# SPAN, and the defect review found here: a fail-closed rule must cover the
|
||||||
|
# same surface as the block it guards. This test asked only for `https?://`
|
||||||
|
# while the scope gate above had already been widened to three shapes, so
|
||||||
|
# p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x
|
||||||
|
# p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}
|
||||||
|
# were in scope to be blocked, produced no readable endpoint, and then fell
|
||||||
|
# through to ALLOW — while the identical split behind a literal `https://`
|
||||||
|
# blocked. Same shape as the milestone arm one round earlier: the correct
|
||||||
|
# treatment already existed and was applied to one of the surfaces it
|
||||||
|
# covered. A control is only as wide as its narrowest arm.
|
||||||
|
#
|
||||||
|
# Three arms, one per shape the scope gate admits:
|
||||||
|
# A a scheme-bearing URL token carrying an expansion
|
||||||
|
# B a schemeless token carrying BOTH a forge fragment and an expansion
|
||||||
|
# C the endpoint argument of a provider-CLI `api` call carrying one
|
||||||
|
#
|
||||||
|
# Stated limits, because a control may not claim more than it measures. B
|
||||||
|
# requires the fragment and the expansion in the SAME shell token, so a
|
||||||
|
# caller who splits the hostname and `/api/` as well gets through. C reads
|
||||||
|
# the endpoint positionally — the first bare token after `api` and its option
|
||||||
|
# run — so an endpoint pushed past an option whose value itself contains
|
||||||
|
# whitespace is not seen. Both are deliberate: this hook stops mistakes, it
|
||||||
|
# is not a sandbox, and pretending otherwise is how you get a control nobody
|
||||||
|
# can trust the boundaries of.
|
||||||
|
#
|
||||||
|
# C's option run also accepts the bare `--` end-of-options marker, because
|
||||||
|
# review found that `gh api -X POST -- ${p}${q} -f title=x` walked straight
|
||||||
|
# past an option class that required a letter after the dashes. The marker is
|
||||||
|
# the one "option" that is not spelled like one, and a scanner that skips
|
||||||
|
# options had to be told that.
|
||||||
|
#
|
||||||
|
# Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`,
|
||||||
|
# `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would
|
||||||
|
# punish the safest way to pass a payload. Only the endpoint region counts.
|
||||||
|
URLTOK='[^[:space:]"'"'"'|;&)]*'
|
||||||
|
FORGE='(/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo)'
|
||||||
|
unreadable=0
|
||||||
|
if printf '%s' "$CMD" | grep -Eq "https?://$URLTOK"'[$`]' \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
|
||||||
|
printf '%s' "$CMD" | grep -Eq \
|
||||||
|
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
|
||||||
|
# THIRD name consumer, and the one that proves the point about a single
|
||||||
|
# site: while the scope gate above was repaired for path- and quote-dressed
|
||||||
|
# provider CLIs, this fail-closed refinement kept its own bare-name copy of
|
||||||
|
# the same regex against raw $CMD. A caller could therefore enter the scope
|
||||||
|
# gate through the fixed check and then fail to be recognized by the arm
|
||||||
|
# that refuses unreadable endpoints — name recognition differing between a
|
||||||
|
# gate and its own refinement, which is the defect one layer downstream.
|
||||||
|
# It reads $CMD_NAMES through $NAME_PREFIX like every other name check.
|
||||||
|
#
|
||||||
|
# The endpoint tail still asks $CMD, deliberately: this arm fires on an
|
||||||
|
# endpoint the guard CANNOT READ, and the expansion markers that make it
|
||||||
|
# unreadable are exactly the characters $CMD_NAMES removes. Reading the tail
|
||||||
|
# from the normalized copy would erase the evidence the check exists to find.
|
||||||
|
#
|
||||||
|
# The tail carries NO name of its own. Leaving one there was the same defect
|
||||||
|
# a third time in the same edit — the name gate would recognize `g"h" api`
|
||||||
|
# while the tail still demanded the undressed spelling, so the two halves
|
||||||
|
# disagreed about the same caller and the refinement failed open. Each half
|
||||||
|
# now asks exactly one question: the name gate asks WHO, from the normalized
|
||||||
|
# copy; the tail asks whether the ENDPOINT is readable, from the raw text.
|
||||||
|
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \
|
||||||
|
&& printf '%s' "$CMD" | grep -Eq \
|
||||||
|
'(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then
|
||||||
|
unreadable=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: raw provider API write whose endpoint this guard cannot read.
|
||||||
|
|
||||||
|
The endpoint is assembled from shell expansions, so the path it names does not
|
||||||
|
exist until the shell builds it — after this check runs. The guard cannot tell
|
||||||
|
whether it is a wrapped endpoint, and an unreadable endpoint is not a cleared
|
||||||
|
one.
|
||||||
|
|
||||||
|
$W/ <- the wrappers; use the one for the endpoint you are calling
|
||||||
|
|
||||||
|
If you are calling a wrapped endpoint (reviews, merges, comments, pulls,
|
||||||
|
issues, milestones), use the wrapper — it also resolves identity explicitly,
|
||||||
|
which matters on a host whose default provider login is an admin account.
|
||||||
|
|
||||||
|
If this is genuinely not a provider endpoint, either write the endpoint
|
||||||
|
literally so the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||||
|
A variable in the BODY is fine and does not trigger this; only the endpoint
|
||||||
|
itself has to be legible.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Block on the ENDPOINT, never on whether the wrapper file happens to exist.
|
||||||
|
# The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
|
||||||
|
# with a broken or absent install allowed exactly the raw writes the guard
|
||||||
|
# exists to stop — an absence-driven allow, and the second one found in this
|
||||||
|
# file. A missing wrapper is a broken install; it is not a licence to bypass
|
||||||
|
# gate 7. Say so, and say which is which.
|
||||||
|
if [ -n "$endpoint" ]; then
|
||||||
|
if [ -x "$W/$wrapper" ]; then
|
||||||
|
remedy="Use the wrapper the Constitution (gate 7) requires:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
Run \`$wrapper --help\` for the flags."
|
||||||
|
# Several wrappers can own one endpoint (labels are settable from both
|
||||||
|
# issue-edit.sh and issue-assign.sh; state has its own pair). Naming
|
||||||
|
# only one of them is how a correct block still ends up reading as
|
||||||
|
# wrong advice, so say which wrapper owns which part of the call.
|
||||||
|
[ -n "$alsoown" ] && remedy="$remedy
|
||||||
|
|
||||||
|
$alsoown"
|
||||||
|
else
|
||||||
|
remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
|
||||||
|
present or not executable at:
|
||||||
|
|
||||||
|
$W/$wrapper
|
||||||
|
|
||||||
|
That is a broken or incomplete install, not permission to send the call raw.
|
||||||
|
Repair the install (\`mosaic doctor\`) and use the wrapper."
|
||||||
|
[ -n "$alsoown" ] && remedy="$remedy
|
||||||
|
|
||||||
|
$alsoown"
|
||||||
|
fi
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: raw provider API write to the $endpoint endpoint.
|
||||||
|
|
||||||
|
$remedy
|
||||||
|
|
||||||
|
The wrappers are not a formality. They carry provider-dialect differences that
|
||||||
|
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
||||||
|
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
|
||||||
|
as PENDING. They also resolve identity explicitly, which matters on a host
|
||||||
|
where the default login is an admin account.
|
||||||
|
|
||||||
|
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
||||||
|
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
|
||||||
|
# Both spellings the trap arrives in: the JSON body `"event": "APPROVE"` and the
|
||||||
|
# provider-CLI field `-f event=APPROVE`. The trailing [^A-Z] is what keeps the
|
||||||
|
# correct value out of it — APPROVED must never match.
|
||||||
|
if printf '%s' "$CMD" | grep -Eq 'event"?[[:space:]]*[=:][[:space:]]*"?APPROVE([^A-Z]|$)'; then
|
||||||
|
cat <<EOF
|
||||||
|
BLOCKED: review event "APPROVE" is not valid on Gitea.
|
||||||
|
|
||||||
|
Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
|
||||||
|
files the review as PENDING, and then fails the submit endpoint with
|
||||||
|
422 "review stay pending" — so the verdict looks placed and is not.
|
||||||
|
|
||||||
|
("REQUEST_CHANGES" is spelled identically on both providers; only the approve
|
||||||
|
path carries this trap.)
|
||||||
|
|
||||||
|
Use $W/pr-review.sh, which sends the correct token for the detected provider.
|
||||||
|
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
|
||||||
|
before reporting a verdict placed.
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -91,10 +91,12 @@ fi
|
|||||||
|
|
||||||
if [[ -n "$dirty_files" ]]; then
|
if [[ -n "$dirty_files" ]]; then
|
||||||
echo " Modified files:"
|
echo " Modified files:"
|
||||||
echo "$dirty_files" | head -20 | while IFS= read -r line; do
|
mapfile -t dirty_lines <<<"$dirty_files"
|
||||||
echo " $line"
|
file_count="${#dirty_lines[@]}"
|
||||||
|
display_count=$((file_count < 20 ? file_count : 20))
|
||||||
|
for ((i = 0; i < display_count; i++)); do
|
||||||
|
echo " ${dirty_lines[$i]}"
|
||||||
done
|
done
|
||||||
file_count="$(echo "$dirty_files" | wc -l)"
|
|
||||||
if (( file_count > 20 )); then
|
if (( file_count > 20 )); then
|
||||||
echo " ... and $(( file_count - 20 )) more"
|
echo " ... and $(( file_count - 20 )) more"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ if jq -e '.next_task == "T-001"' "$capsule_file" >/dev/null 2>&1; then pass_case
|
|||||||
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
|
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
|
||||||
|
|
||||||
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||||
if [[ "$(printf '%s\n' "$codex_run_prompt" | head -n1)" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
if [[ "${codex_run_prompt%%$'\n'*}" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
||||||
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
|
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
|
||||||
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
|
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
|
||||||
|
|
||||||
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||||
if [[ "$(printf '%s\n' "$claude_run_prompt" | head -n1)" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
if [[ "${claude_run_prompt%%$'\n'*}" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Smoke test summary: pass=$PASS fail=$FAIL"
|
echo "Smoke test summary: pass=$PASS fail=$FAIL"
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
|
|||||||
make_board "$L" 6 1 400
|
make_board "$L" 6 1 400
|
||||||
before_l=$(cat "$L"); before_g=$(cat "$G")
|
before_l=$(cat "$L"); before_g=$(cat "$G")
|
||||||
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
||||||
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
grep -qi "dry run" <<<"$out" || note "dry-run did not announce itself"
|
||||||
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan"
|
grep -q "would roll" <<<"$out" || note "dry-run did not report a plan"
|
||||||
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
||||||
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ present=0
|
|||||||
|
|
||||||
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
|
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
|
||||||
pattern="${entry#*|}"
|
pattern="${entry#*|}"
|
||||||
if echo "$PRD_CONTENT" | grep -qiE "$pattern"; then
|
if grep -qiE "$pattern" <<<"$PRD_CONTENT"; then
|
||||||
present=$((present + 1))
|
present=$((present + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -169,13 +169,13 @@ main() {
|
|||||||
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
||||||
classify_surface() {
|
classify_surface() {
|
||||||
local p="$1"
|
local p="$1"
|
||||||
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
if grep -qiE 'auth|login|session|token|permission|rbac|credential|secret' <<<"$p"; then echo auth; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi
|
if grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed' <<<"$p"; then echo data; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi
|
if grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform' <<<"$p"; then echo infra; return; fi
|
||||||
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
if grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite' <<<"$p"; then echo build; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
if grep -qE '\.tsx|\.css|components/|apps/web/' <<<"$p"; then echo ui; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
if grep -qE '\.spec\.|\.test\.|__tests__/' <<<"$p"; then echo test; return; fi
|
||||||
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
if grep -qE '\.md$|docs/' <<<"$p"; then echo docs; return; fi
|
||||||
echo none
|
echo none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ JSON_INPUT=$(cat)
|
|||||||
if command -v jq &>/dev/null; then
|
if command -v jq &>/dev/null; then
|
||||||
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
||||||
else
|
else
|
||||||
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
file_path_pattern='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
||||||
|
if [[ "$JSON_INPUT" =~ $file_path_pattern ]]; then
|
||||||
|
FILE_PATH="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
FILE_PATH=""
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Only check TypeScript files
|
# Only check TypeScript files
|
||||||
@@ -53,7 +58,7 @@ OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
|
|||||||
if [ "${STATUS:-0}" -ne 0 ]; then
|
if [ "${STATUS:-0}" -ne 0 ]; then
|
||||||
# Filter output to only show errors related to the edited file (if possible)
|
# Filter output to only show errors related to the edited file (if possible)
|
||||||
BASENAME=$(basename "$FILE_PATH")
|
BASENAME=$(basename "$FILE_PATH")
|
||||||
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
RELEVANT=$(grep -A2 "$BASENAME" <<<"$OUTPUT" 2>/dev/null || sed -n '1,20p' <<<"$OUTPUT")
|
||||||
|
|
||||||
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||||
echo "$RELEVANT"
|
echo "$RELEVANT"
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-tools-index.sh — assert every shipped tool is discoverable from the
|
||||||
|
# resident documentation an agent actually has in context.
|
||||||
|
#
|
||||||
|
# WHY THIS GATE EXISTS
|
||||||
|
# --------------------
|
||||||
|
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
|
||||||
|
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
|
||||||
|
# undocumented ones was `pr-review.sh` — the wrapper that carries the
|
||||||
|
# APPROVED/APPROVE provider-dialect split.
|
||||||
|
#
|
||||||
|
# The observable consequence, on a live fleet host: an agent needing to place a
|
||||||
|
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
|
||||||
|
# host, and got HTTP 200 with the review silently filed PENDING — three times,
|
||||||
|
# because nothing about the failure pointed at the wrapper that already handled
|
||||||
|
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
|
||||||
|
# an index that said the tool did not exist.
|
||||||
|
#
|
||||||
|
# That is not a discipline problem and no amount of prose fixes it. A wrapper
|
||||||
|
# that is not in the resident index is, from inside a session, indistinguishable
|
||||||
|
# from a wrapper that was never written. So the invariant is mechanical:
|
||||||
|
#
|
||||||
|
# shipping a tool and documenting it are the same commit, or CI fails.
|
||||||
|
#
|
||||||
|
# WHAT IT CHECKS
|
||||||
|
# --------------
|
||||||
|
# forward every non-excluded tool in an ENFORCED suite is named in at least
|
||||||
|
# one index document (missing tool -> undiscoverable -> FAIL)
|
||||||
|
# reverse every `<name>.sh` an index document attributes to an enforced
|
||||||
|
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
|
||||||
|
#
|
||||||
|
# Suites outside the enforced set are reported with a coverage percentage but do
|
||||||
|
# not fail the build, so the ratchet can be tightened one suite per PR instead of
|
||||||
|
# landing as one unreviewable sweep. `--strict` fails on those too.
|
||||||
|
#
|
||||||
|
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
|
||||||
|
# would let an operator silence this gate by editing their own copy — the gate
|
||||||
|
# would then be strongest exactly where it is least needed and absent where it
|
||||||
|
# is needed most. The list is framework-owned and changes only through a
|
||||||
|
# reviewed PR.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
|
||||||
|
#
|
||||||
|
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Suites whose coverage is a HARD requirement. Add a suite here only together
|
||||||
|
# with the doc changes that make it pass.
|
||||||
|
#
|
||||||
|
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
|
||||||
|
# an undiscoverable git wrapper converts a hard gate into a coin flip.
|
||||||
|
ENFORCED_SUITES=(git)
|
||||||
|
|
||||||
|
# Files that are not agent-callable tools and must not be required in an index.
|
||||||
|
EXCLUDE_GLOBS=(
|
||||||
|
'test-*' # hermetic regression scripts, invoked by CI not by agents
|
||||||
|
'_*' # private helpers (_lib, _scripts internals)
|
||||||
|
'*.bak' # editor/installer debris
|
||||||
|
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
|
||||||
|
'README.md'
|
||||||
|
)
|
||||||
|
|
||||||
|
STRICT=0
|
||||||
|
SELF_TEST=0
|
||||||
|
TOOLS_DIR=""
|
||||||
|
DOCS=()
|
||||||
|
|
||||||
|
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
|
||||||
|
--doc) DOCS+=("${2:-}"); shift 2 ;;
|
||||||
|
--strict) STRICT=1; shift ;;
|
||||||
|
--self-test) SELF_TEST=1; shift ;;
|
||||||
|
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- location resolution ---------------------------------------------------
|
||||||
|
# Runs from two places with different layouts, and must not silently check the
|
||||||
|
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
|
||||||
|
resolve_locations() {
|
||||||
|
local here framework
|
||||||
|
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# .../framework/tools/quality/scripts -> .../framework
|
||||||
|
framework="$(cd -- "$here/../../.." && pwd)"
|
||||||
|
|
||||||
|
if [ -z "$TOOLS_DIR" ]; then
|
||||||
|
if [ -d "$framework/tools" ]; then
|
||||||
|
TOOLS_DIR="$framework/tools"
|
||||||
|
else
|
||||||
|
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||||
|
# The two layouts are mutually exclusive on purpose. Unioning them would let
|
||||||
|
# a well-maintained operator TOOLS.md on the developer's own machine mask a
|
||||||
|
# gap in the shipped defaults — the check would pass locally and the defect
|
||||||
|
# would still install on every other host. Repo layout wins when present.
|
||||||
|
if [ -f "$framework/defaults/TOOLS.md" ]; then
|
||||||
|
DOCS+=("$framework/defaults/TOOLS.md")
|
||||||
|
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
|
||||||
|
else
|
||||||
|
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
|
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
|
||||||
|
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
|
||||||
|
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_excluded() {
|
||||||
|
local name="$1" glob
|
||||||
|
for glob in "${EXCLUDE_GLOBS[@]}"; do
|
||||||
|
# shellcheck disable=SC2254 # glob is intentionally a pattern
|
||||||
|
case "$name" in $glob) return 0 ;; esac
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# A tool counts as documented when its basename appears anywhere in the corpus.
|
||||||
|
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
|
||||||
|
# about *presence*: the gate's job is "an agent can find it", not house style.
|
||||||
|
documented() { grep -qF -- "$1" "$CORPUS"; }
|
||||||
|
|
||||||
|
# ---- the check -------------------------------------------------------------
|
||||||
|
run_check() {
|
||||||
|
local rc=0 suite dir tool base enforced
|
||||||
|
|
||||||
|
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
|
||||||
|
cat "${DOCS[@]}" > "$CORPUS"
|
||||||
|
|
||||||
|
printf 'tools: %s\n' "$TOOLS_DIR"
|
||||||
|
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
for dir in "$TOOLS_DIR"/*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
suite="$(basename -- "$dir")"
|
||||||
|
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
|
||||||
|
[ "$STRICT" -eq 1 ] && enforced=1
|
||||||
|
case "$suite" in _*) continue ;; esac
|
||||||
|
|
||||||
|
local total=0 found=0
|
||||||
|
local -a suite_missing=() suite_noexec=()
|
||||||
|
for tool in "$dir"*.sh; do
|
||||||
|
[ -e "$tool" ] || continue
|
||||||
|
base="$(basename -- "$tool")"
|
||||||
|
is_excluded "$base" && continue
|
||||||
|
total=$((total + 1))
|
||||||
|
if documented "$base"; then
|
||||||
|
found=$((found + 1))
|
||||||
|
# Documented AND present is not enough. The index presents these as
|
||||||
|
# commands to run, and every caller — the wrapper guard included —
|
||||||
|
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
|
||||||
|
# documented, present, and dead: it reads as absent to every check that
|
||||||
|
# matters while scoring 100% here. That is a false green, which is worse
|
||||||
|
# than a red, so it fails rather than warns.
|
||||||
|
[ -x "$tool" ] || suite_noexec+=("$base")
|
||||||
|
else
|
||||||
|
suite_missing+=("$base")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$total" -eq 0 ] && continue
|
||||||
|
|
||||||
|
local pct=$(( found * 100 / total ))
|
||||||
|
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
|
||||||
|
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
||||||
|
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
||||||
|
rc=1
|
||||||
|
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
|
||||||
|
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
||||||
|
else
|
||||||
|
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reverse: an index that names a tool this suite does not have sends agents
|
||||||
|
# after something that cannot run. Only checked for enforced suites, where
|
||||||
|
# the naming is unambiguous enough to attribute.
|
||||||
|
if [ "$enforced" -eq 1 ]; then
|
||||||
|
local -a stale=()
|
||||||
|
local ref
|
||||||
|
while read -r ref; do
|
||||||
|
[ -n "$ref" ] || continue
|
||||||
|
is_excluded "$ref" && continue
|
||||||
|
[ -e "$dir$ref" ] || stale+=("$ref")
|
||||||
|
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
|
||||||
|
| sed "s|^$suite/||" | sort -u)
|
||||||
|
if [ ${#stale[@]} -gt 0 ]; then
|
||||||
|
printf 'FAIL %-12s stale index references (no such file): %s\n' \
|
||||||
|
"$suite" "${stale[*]}"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
|
||||||
|
tells it to use a wrapper it has no way to learn exists — it will reach for raw
|
||||||
|
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
|
||||||
|
|
||||||
|
Fix by naming each tool above in one of the index documents listed at the top,
|
||||||
|
in the same commit that ships it.
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
printf 'every enforced suite is fully discoverable.\n'
|
||||||
|
fi
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- self-test -------------------------------------------------------------
|
||||||
|
# Proves the gate can actually fail. A checker that only ever passes is
|
||||||
|
# indistinguishable from one that is not running, which is the failure mode this
|
||||||
|
# whole file exists to prevent — so it must demonstrate a red on demand.
|
||||||
|
self_test() {
|
||||||
|
local tmp rc
|
||||||
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
|
||||||
|
mkdir -p "$tmp/tools/git"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
||||||
|
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
|
||||||
|
|
||||||
|
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
||||||
|
# command-prefix assignment, so point the globals at the fixture directly.
|
||||||
|
TOOLS_DIR="$tmp/tools"
|
||||||
|
DOCS=("$tmp/doc.md")
|
||||||
|
|
||||||
|
# Case 1: fully documented -> pass.
|
||||||
|
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
||||||
|
if run_check >/dev/null; then
|
||||||
|
printf 'self-test 1/4 ok (complete index passes)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 2: an undocumented tool -> fail.
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 3: a stale index reference -> fail.
|
||||||
|
rm "$tmp/tools/git/undocumented-tool.sh"
|
||||||
|
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 4: documented, present, and NOT executable -> fail. Found by an
|
||||||
|
# independent reviewer: a 0644 wrapper scored 100% here while reading as
|
||||||
|
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
|
||||||
|
sed -i '/deleted-tool/d' "$tmp/doc.md"
|
||||||
|
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
|
||||||
|
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
|
||||||
|
rc=0; run_check >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
|
||||||
|
else
|
||||||
|
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$SELF_TEST" -eq 1 ]; then
|
||||||
|
self_test
|
||||||
|
else
|
||||||
|
resolve_locations
|
||||||
|
run_check
|
||||||
|
fi
|
||||||
@@ -26,6 +26,9 @@ chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
|
|||||||
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
||||||
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||||
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
||||||
|
chk "F1 fresh: Pi goal extension deploys under Mosaic runtime" \
|
||||||
|
"cmp -s '$T1/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||||
|
chk "F1 fresh: installer creates no nested main Pi config" "[ ! -e '$T1/.pi' ]"
|
||||||
|
|
||||||
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
||||||
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
||||||
@@ -89,6 +92,8 @@ chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.en
|
|||||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||||
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||||
|
chk "F6 reseed: Pi goal extension is refreshed from framework source" \
|
||||||
|
"cmp -s '$T6/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||||
|
|
||||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -176,8 +176,12 @@ run_snap() {
|
|||||||
|
|
||||||
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
||||||
snap_dir() {
|
snap_dir() {
|
||||||
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
local -a snapshots=()
|
||||||
| LC_ALL=C sort -r | head -1
|
mapfile -t snapshots < <(
|
||||||
|
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||||
|
| LC_ALL=C sort -r
|
||||||
|
)
|
||||||
|
printf '%s\n' "${snapshots[0]:-}"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
|||||||
# loop — which would make the control a false negative. A root dotfile is
|
# loop — which would make the control a false negative. A root dotfile is
|
||||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||||
# A bash signal trap that merely returns lets the script continue past the
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
# success. The earlier test used a child cp shim to signal its parent, making
|
||||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||||
# the trap and shows the buggy resume-to-success.
|
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||||
make_term_shim() {
|
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||||
local dir="$1"
|
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||||
cat > "$dir/cp" <<SHIM
|
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||||
#!/usr/bin/env bash
|
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||||
dest="\${@: -1}"
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
case "\$dest" in
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
*/$POISON_REL)
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
make_signal_installer() {
|
||||||
esac
|
local output="$1" handler="$2"
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
SHIM
|
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||||
chmod +x "$dir/cp"
|
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||||
|
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||||
|
local inject_close=' fi'
|
||||||
|
|
||||||
|
if ! awk \
|
||||||
|
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||||
|
-v handler="$handler" -v inject_open="$inject_open" \
|
||||||
|
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||||
|
$0 == target_cp {
|
||||||
|
print inject_open
|
||||||
|
print inject_kill
|
||||||
|
print inject_close
|
||||||
|
injection_sites++
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
$0 == target_trap {
|
||||||
|
print handler
|
||||||
|
handler_sites++
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||||
|
}
|
||||||
|
' "$INSTALL" > "$output"; then
|
||||||
|
rm -f "$output"
|
||||||
|
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$output"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||||
|
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||||
|
signal_fixture_ready() {
|
||||||
|
local fixture="$1" expected_handler="$2"
|
||||||
|
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||||
|
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||||
|
&& grep -Fqx "$expected_handler" "$fixture"
|
||||||
|
}
|
||||||
|
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||||
|
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||||
|
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"signaled_fixture_ready"
|
||||||
|
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"noexit_fixture_ready"
|
||||||
|
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||||
|
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||||
|
|
||||||
|
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||||
|
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||||
run_signal_upgrade() {
|
run_signal_upgrade() {
|
||||||
local installer="$1" H OUT SHIM rc
|
local installer="$1" H OUT rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
H=$(mktemp -d); OUT=$(mktemp)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
make_term_shim "$SHIM"
|
|
||||||
set +e
|
set +e
|
||||||
PATH="$SHIM:$ORIG_PATH" \
|
PATH="$ORIG_PATH" \
|
||||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
rm -rf "$SHIM"
|
|
||||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
}
|
}
|
||||||
|
|
||||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||||
|
chk "[signal] TERM handler observable fires exactly once" \
|
||||||
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
|
|||||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
chk "[control] TERM handler observable fires exactly once" \
|
||||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||||
"$INSTALL" > "$NOEXIT"
|
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||||
chk "[control] the exit-strip actually changed the installer" \
|
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
|
||||||
@@ -290,7 +336,7 @@ chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e ex
|
|||||||
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||||
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||||
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||||
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
|
SNAP_E="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG")"
|
||||||
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
||||||
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
||||||
chk "[reset-fail] operator secret value never appears in installer output" \
|
chk "[reset-fail] operator secret value never appears in installer output" \
|
||||||
@@ -307,12 +353,13 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
|||||||
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
||||||
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
||||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
orphan_snapshot="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null || true)"
|
||||||
|
[ -n "$orphan_snapshot" ] && rm -rf "$orphan_snapshot"
|
||||||
|
|
||||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -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)
|
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 ---
|
# --- 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/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/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
|
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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user