Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ab5161db | ||
|
|
ab2c15c0bf | ||
|
|
1ad328af63 | ||
|
|
41c11388e4 |
No files matched your search
+155
-15
@@ -1,16 +1,156 @@
|
||||
# Mosaic Stack standalone deployment (compose `stack` profile)
|
||||
# Copy to .env and adjust. Port overrides exist because the defaults
|
||||
# collide with common host services (and with the dev compose itself).
|
||||
PG_HOST_PORT=5433
|
||||
VALKEY_HOST_PORT=6380
|
||||
GATEWAY_HOST_PORT=14242
|
||||
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
|
||||
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic — Environment Variables Reference
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
# Lines beginning with # are comments; optional vars are commented out.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
|
||||
# All three paths are required when that overlay is used. Use a dedicated
|
||||
# next-based worktree, its canonical clone's .git directory, and the external
|
||||
# home of the unprivileged code-dogfood-01 functional seat.
|
||||
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
||||
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
|
||||
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
|
||||
|
||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||
# Full connection string used by the gateway, ORM, and migration runner.
|
||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
|
||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||
# PG_HOST_PORT=5433
|
||||
|
||||
|
||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||
VALKEY_URL=redis://localhost:6380
|
||||
|
||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||
# VALKEY_HOST_PORT=6380
|
||||
|
||||
|
||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
||||
GATEWAY_PORT=14242
|
||||
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Must include the web app origin in production.
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
|
||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||
# REQUIRED — random secret used to sign sessions and tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
|
||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Service name shown in traces
|
||||
OTEL_SERVICE_NAME=mosaic-gateway
|
||||
|
||||
|
||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
|
||||
|
||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
# EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
|
||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||
|
||||
# Cron schedule for summarization job (default: every 6 hours)
|
||||
# SUMMARIZATION_CRON=0 */6 * * *
|
||||
|
||||
# Cron schedule for log tier management (default: daily at 03:00)
|
||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||
|
||||
|
||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||
|
||||
# Comma-separated list of tool names available to non-admin users.
|
||||
# Leave unset to allow all tools for all authenticated users.
|
||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||
|
||||
# System prompt injected into every agent session (optional)
|
||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
|
||||
|
||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||
|
||||
|
||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||
|
||||
|
||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# The web login page discovers configured providers dynamically from
|
||||
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
|
||||
@@ -208,51 +208,6 @@ mosaic telemetry upload # Dry-run unless opted in
|
||||
|
||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
||||
|
||||
## Standalone container deployment
|
||||
|
||||
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
|
||||
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||
docker compose --profile stack up -d
|
||||
```
|
||||
|
||||
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
|
||||
worktree and its own read-only credential slot. It does not mount the fleet brain or
|
||||
any other seat. Prepare a `next`-based worktree and an unprivileged
|
||||
`code-dogfood-01` functional seat outside the container, then set these paths in
|
||||
`.env`:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
|
||||
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/code-dogfood-01
|
||||
```
|
||||
|
||||
The common Git directory must match the worktree's `.git` pointer. The seat home
|
||||
must contain only that seat's credential at
|
||||
`secrets/gitea-mosaicstack-code-dogfood-01.token`. Never place the token value in
|
||||
`.env`. Start the overlay with:
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.dogfood.yml \
|
||||
--profile stack up -d
|
||||
```
|
||||
|
||||
The overlay removes the general shell tool for every session, including admins.
|
||||
File tools stay inside the mounted checkout. Two dedicated delivery tools stage
|
||||
explicit paths, run the CI queue guard, push through `git-credential-mosaic`, and
|
||||
open PRs through `pr-create.sh`. They resolve only the `code-dogfood-01` slot and fail
|
||||
if it is absent. The overlay enables Docker's init process so the R4 helper can
|
||||
establish the gateway's seat lineage below PID 1.
|
||||
|
||||
This deployment route is separate from the local source-development restrictions
|
||||
below.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -27,11 +27,10 @@ import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { createBrainTools } from './tools/brain-tools.js';
|
||||
import { createCoordTools } from './tools/coord-tools.js';
|
||||
import { createDeliveryTools } from './tools/delivery-tools.js';
|
||||
import { createMemoryTools } from './tools/memory-tools.js';
|
||||
import { createFileTools } from './tools/file-tools.js';
|
||||
import { createGitTools } from './tools/git-tools.js';
|
||||
import { createShellToolsIfEnabled } from './tools/shell-tools.js';
|
||||
import { createShellTools } from './tools/shell-tools.js';
|
||||
import { createWebTools } from './tools/web-tools.js';
|
||||
import { createSearchTools } from './tools/search-tools.js';
|
||||
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
@@ -168,8 +167,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
),
|
||||
...createFileTools(sandboxDir),
|
||||
...createGitTools(sandboxDir),
|
||||
...createShellToolsIfEnabled(sandboxDir),
|
||||
...createDeliveryTools(sandboxDir),
|
||||
...createShellTools(sandboxDir),
|
||||
...createWebTools(),
|
||||
...createSearchTools(),
|
||||
];
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { createFileTools } from './file-tools.js';
|
||||
import { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
|
||||
import {
|
||||
createDeliveryTools,
|
||||
type DeliveryToolEnvironment,
|
||||
type ProcessResult,
|
||||
type ProcessRunner,
|
||||
} from './delivery-tools.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function textOf(result: unknown): string {
|
||||
const typed = result as { content: Array<{ text: string }> };
|
||||
return typed.content.map((item) => item.text).join('\n');
|
||||
}
|
||||
|
||||
async function execute(tool: ToolDefinition, params: Record<string, unknown>): Promise<unknown> {
|
||||
return (
|
||||
tool.execute as unknown as (id: string, input: Record<string, unknown>) => Promise<unknown>
|
||||
)('test-call', params);
|
||||
}
|
||||
|
||||
function ok(stdout = ''): ProcessResult {
|
||||
return { exitCode: 0, stdout, stderr: '', timedOut: false };
|
||||
}
|
||||
|
||||
function deliveryEnv(extra: Partial<DeliveryToolEnvironment> = {}): DeliveryToolEnvironment {
|
||||
return {
|
||||
AGENT_DELIVERY_ENABLED: 'true',
|
||||
MOSAIC_GIT_TOOLS_DIR: '/opt/mosaic/tools/git',
|
||||
MOSAIC_GIT_IDENTITY: 'code-dogfood-01',
|
||||
MOSAIC_AGENT_NAME: 'code-dogfood-01',
|
||||
MOSAIC_BRAIN_HOME: '/opt/mosaic/brain',
|
||||
MOSAIC_INTEGRATION_TRUNK: 'next',
|
||||
HOME: '/home/node',
|
||||
PATH: '/usr/bin:/bin',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('dogfood execution boundary', () => {
|
||||
it('removes shell_exec mechanically while its first-token bypass red control stays live', async () => {
|
||||
const sandbox = tempDir('mosaic-shell-boundary-');
|
||||
expect(createShellToolsIfEnabled(sandbox, { AGENT_SHELL_ENABLED: 'false' })).toEqual([]);
|
||||
|
||||
const redControl = createShellTools(sandbox)[0]!;
|
||||
const result = await execute(redControl, { command: 'env printf FIRST_TOKEN_BYPASS' });
|
||||
expect(textOf(result)).toContain('FIRST_TOKEN_BYPASS');
|
||||
});
|
||||
|
||||
it('refuses an outside-sandbox token-shaped read and proves the path guard is the enforcement', async () => {
|
||||
const root = tempDir('mosaic-file-boundary-');
|
||||
const sandbox = path.join(root, 'workspace', 'stack');
|
||||
const token = path.join(
|
||||
root,
|
||||
'brain',
|
||||
'fleet',
|
||||
'agents',
|
||||
'code-dogfood-01',
|
||||
'secrets',
|
||||
'gitea-mosaicstack-code-dogfood-01.token',
|
||||
);
|
||||
fs.mkdirSync(sandbox, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(token), { recursive: true });
|
||||
fs.writeFileSync(token, 'OUTSIDE_SANDBOX_SENTINEL');
|
||||
|
||||
const read = createFileTools(sandbox).find((tool) => tool.name === 'fs_read_file')!;
|
||||
const refused = await execute(read, { path: token });
|
||||
expect(textOf(refused)).toContain('Path escape attempt blocked');
|
||||
expect(textOf(refused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
|
||||
|
||||
fs.symlinkSync(token, path.join(sandbox, 'credential.token'));
|
||||
const symlinkRefused = await execute(read, { path: 'credential.token' });
|
||||
expect(textOf(symlinkRefused)).toContain('Path escape attempt blocked');
|
||||
expect(textOf(symlinkRefused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
|
||||
|
||||
const redRead = createFileTools(root).find((tool) => tool.name === 'fs_read_file')!;
|
||||
const redControl = await execute(redRead, { path: token });
|
||||
expect(textOf(redControl)).toContain('OUTSIDE_SANDBOX_SENTINEL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery tools', () => {
|
||||
it('stay absent unless explicitly enabled and reject identity mismatch', () => {
|
||||
const sandbox = tempDir('mosaic-delivery-disabled-');
|
||||
expect(createDeliveryTools(sandbox, {})).toEqual([]);
|
||||
expect(() =>
|
||||
createDeliveryTools(sandbox, deliveryEnv({ MOSAIC_AGENT_NAME: 'another-seat' })),
|
||||
).toThrow('matching safe MOSAIC agent and git identities');
|
||||
});
|
||||
|
||||
it('publishes through execFile-only git and queue operations with a scrubbed environment', async () => {
|
||||
const sandbox = tempDir('mosaic-delivery-publish-');
|
||||
fs.writeFileSync(path.join(sandbox, 'change.md'), 'change');
|
||||
const calls: Array<{ file: string; args: readonly string[]; env: NodeJS.ProcessEnv }> = [];
|
||||
const runner: ProcessRunner = async (file, args, options) => {
|
||||
calls.push({ file, args, env: options.env });
|
||||
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
|
||||
return ok();
|
||||
};
|
||||
const hostile = {
|
||||
...deliveryEnv(),
|
||||
BASH_ENV: '/tmp/injected',
|
||||
'BASH_FUNC_read%%': '() { :; }',
|
||||
GITEA_TOKEN: 'must-not-cross',
|
||||
} as DeliveryToolEnvironment;
|
||||
const publish = createDeliveryTools(sandbox, hostile, runner).find(
|
||||
(tool) => tool.name === 'git_publish_branch',
|
||||
)!;
|
||||
|
||||
const result = await execute(publish, {
|
||||
issue: 1487,
|
||||
paths: ['change.md'],
|
||||
commitMessage: 'docs: dogfood proof (#1487)',
|
||||
});
|
||||
expect(textOf(result)).toBe('Published branch feat/1487-dogfood-proof as code-dogfood-01.');
|
||||
|
||||
expect(calls.map((call) => call.file)).toEqual([
|
||||
'/usr/bin/git',
|
||||
'/usr/bin/git',
|
||||
'/usr/bin/git',
|
||||
'/opt/mosaic/tools/git/ci-queue-wait.sh',
|
||||
'/usr/bin/git',
|
||||
]);
|
||||
expect(calls[3]!.args).toEqual(['--purpose', 'push', '-B', 'feat/1487-dogfood-proof']);
|
||||
expect(calls[4]!.args).toEqual(['push', '--set-upstream', 'origin', 'feat/1487-dogfood-proof']);
|
||||
for (const call of calls) {
|
||||
expect(call.file).not.toMatch(/(?:^|\/)sh$/);
|
||||
expect(call.env).not.toHaveProperty('BASH_ENV');
|
||||
expect(Object.keys(call.env).some((key) => key.startsWith('BASH_FUNC_'))).toBe(false);
|
||||
expect(call.env).not.toHaveProperty('GITEA_TOKEN');
|
||||
expect(call.env.MOSAIC_GIT_IDENTITY).toBe('code-dogfood-01');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens PRs only through pr-create.sh against next', async () => {
|
||||
const sandbox = tempDir('mosaic-delivery-pr-');
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const runner: ProcessRunner = async (file, args) => {
|
||||
calls.push({ file, args });
|
||||
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
|
||||
return ok('https://git.mosaicstack.dev/mosaicstack/stack/pulls/999\n');
|
||||
};
|
||||
const openPr = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
|
||||
(tool) => tool.name === 'git_open_pull_request',
|
||||
)!;
|
||||
|
||||
const result = await execute(openPr, {
|
||||
issue: 1487,
|
||||
title: 'docs: dogfood proof',
|
||||
body: 'Measured from the in-stack agent.',
|
||||
});
|
||||
expect(textOf(result)).toContain('/pulls/999');
|
||||
expect(calls[1]!.file).toBe('/opt/mosaic/tools/git/pr-create.sh');
|
||||
expect(calls[1]!.args).toEqual([
|
||||
'-t',
|
||||
'docs: dogfood proof',
|
||||
'-b',
|
||||
'Measured from the in-stack agent.',
|
||||
'-B',
|
||||
'next',
|
||||
'-H',
|
||||
'feat/1487-dogfood-proof',
|
||||
'-i',
|
||||
'1487',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks publish paths outside the sandbox before staging', async () => {
|
||||
const root = tempDir('mosaic-delivery-path-');
|
||||
const sandbox = path.join(root, 'sandbox');
|
||||
const outside = path.join(root, 'outside.md');
|
||||
fs.mkdirSync(sandbox);
|
||||
fs.writeFileSync(outside, 'OUTSIDE_DELIVERY_SENTINEL');
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const runner: ProcessRunner = async (file, args) => {
|
||||
calls.push({ file, args });
|
||||
return args[0] === 'branch' ? ok('feat/1487-dogfood-proof\n') : ok();
|
||||
};
|
||||
const publish = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
|
||||
(tool) => tool.name === 'git_publish_branch',
|
||||
)!;
|
||||
|
||||
const result = await execute(publish, {
|
||||
issue: 1487,
|
||||
paths: [outside],
|
||||
commitMessage: 'docs: must not publish',
|
||||
});
|
||||
expect(textOf(result)).toContain('Path escape attempt blocked');
|
||||
expect(textOf(result)).not.toContain('OUTSIDE_DELIVERY_SENTINEL');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { guardPath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const PROCESS_TIMEOUT_MS = 120_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024;
|
||||
const SAFE_IDENTITY = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const SAFE_BRANCH = /^(?:feat|fix|docs|test)\/[a-z0-9][a-z0-9._/-]*$/i;
|
||||
|
||||
export interface ProcessResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export type ProcessRunner = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
|
||||
) => Promise<ProcessResult>;
|
||||
|
||||
export interface DeliveryToolEnvironment {
|
||||
AGENT_DELIVERY_ENABLED?: string;
|
||||
MOSAIC_GIT_TOOLS_DIR?: string;
|
||||
MOSAIC_GIT_IDENTITY?: string;
|
||||
MOSAIC_AGENT_NAME?: string;
|
||||
MOSAIC_BRAIN_HOME?: string;
|
||||
MOSAIC_CREDENTIAL_SPOOL?: string;
|
||||
MOSAIC_CREDENTIAL_LINEAGE_FENCE?: string;
|
||||
MOSAIC_INTEGRATION_TRUNK?: string;
|
||||
HOME?: string;
|
||||
PATH?: string;
|
||||
LANG?: string;
|
||||
LC_ALL?: string;
|
||||
}
|
||||
|
||||
function runProcess(
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
|
||||
): Promise<ProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(file, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
let outputBytes = 0;
|
||||
|
||||
const append = (current: string, chunk: Buffer): string => {
|
||||
const remaining = MAX_OUTPUT_BYTES - outputBytes;
|
||||
if (remaining <= 0) return current;
|
||||
outputBytes += chunk.length;
|
||||
return current + chunk.subarray(0, remaining).toString();
|
||||
};
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout = append(stdout, chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr = append(stderr, chunk);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode: null, stdout, stderr: `${stderr}${String(error)}`, timedOut });
|
||||
});
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode, stdout, stderr, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanEnvironment(env: DeliveryToolEnvironment): NodeJS.ProcessEnv {
|
||||
const clean: NodeJS.ProcessEnv = {
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
for (const key of [
|
||||
'HOME',
|
||||
'PATH',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'MOSAIC_GIT_IDENTITY',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_BRAIN_HOME',
|
||||
'MOSAIC_CREDENTIAL_SPOOL',
|
||||
'MOSAIC_CREDENTIAL_LINEAGE_FENCE',
|
||||
] as const) {
|
||||
const value = env[key];
|
||||
if (value !== undefined) clean[key] = value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function textResult(text: string): {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details: undefined;
|
||||
} {
|
||||
return { content: [{ type: 'text', text }], details: undefined };
|
||||
}
|
||||
|
||||
function describeFailure(label: string, result: ProcessResult): string {
|
||||
if (result.timedOut) return `${label} timed out`;
|
||||
const diagnostic = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
|
||||
return `${label} failed (exit ${result.exitCode ?? 'null'}): ${diagnostic}`;
|
||||
}
|
||||
|
||||
function currentBranchPattern(issue: number): RegExp {
|
||||
return new RegExp(`^(?:feat|fix|docs|test)/${issue}(?:[-/].+)$`, 'i');
|
||||
}
|
||||
|
||||
export function createDeliveryTools(
|
||||
sandboxDir: string,
|
||||
sourceEnv: DeliveryToolEnvironment = process.env,
|
||||
runner: ProcessRunner = runProcess,
|
||||
): ToolDefinition[] {
|
||||
if (sourceEnv.AGENT_DELIVERY_ENABLED !== 'true') return [];
|
||||
|
||||
const identity = sourceEnv.MOSAIC_GIT_IDENTITY ?? '';
|
||||
const agentName = sourceEnv.MOSAIC_AGENT_NAME ?? '';
|
||||
const toolsDir = sourceEnv.MOSAIC_GIT_TOOLS_DIR ?? '';
|
||||
const baseBranch = sourceEnv.MOSAIC_INTEGRATION_TRUNK ?? 'next';
|
||||
if (!SAFE_IDENTITY.test(identity) || identity !== agentName) {
|
||||
throw new Error('Delivery tools require matching safe MOSAIC agent and git identities');
|
||||
}
|
||||
if (!path.isAbsolute(toolsDir)) {
|
||||
throw new Error('Delivery tools require an absolute MOSAIC_GIT_TOOLS_DIR');
|
||||
}
|
||||
if (!SAFE_BRANCH.test(`feat/${baseBranch}`) || baseBranch.includes('/')) {
|
||||
throw new Error('Delivery tools require a safe integration branch name');
|
||||
}
|
||||
|
||||
const env = cleanEnvironment(sourceEnv);
|
||||
const queueGuard = path.join(toolsDir, 'ci-queue-wait.sh');
|
||||
const prCreate = path.join(toolsDir, 'pr-create.sh');
|
||||
|
||||
const run = (file: string, args: readonly string[], timeoutMs = PROCESS_TIMEOUT_MS) =>
|
||||
runner(file, args, { cwd: sandboxDir, env, timeoutMs });
|
||||
|
||||
const readBranch = async (): Promise<{ branch?: string; error?: string }> => {
|
||||
const result = await run('/usr/bin/git', ['branch', '--show-current'], 15_000);
|
||||
if (result.exitCode !== 0) return { error: describeFailure('git branch', result) };
|
||||
const branch = result.stdout.trim();
|
||||
if (!SAFE_BRANCH.test(branch))
|
||||
return { error: `Unsafe delivery branch: ${branch || '<empty>'}` };
|
||||
if (branch === baseBranch || branch === 'main') {
|
||||
return { error: `Refusing delivery from protected branch ${branch}` };
|
||||
}
|
||||
return { branch };
|
||||
};
|
||||
|
||||
const publish: ToolDefinition = {
|
||||
name: 'git_publish_branch',
|
||||
label: 'Publish Git Branch',
|
||||
description:
|
||||
'Stage explicit files in the current sandbox branch, commit them as the dedicated dogfood identity, run the CI queue guard, and push the branch. No shell or raw provider API is used.',
|
||||
parameters: Type.Object({
|
||||
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
|
||||
paths: Type.Array(Type.String(), {
|
||||
minItems: 1,
|
||||
maxItems: 100,
|
||||
description: 'Files to stage, relative to the sandbox root',
|
||||
}),
|
||||
commitMessage: Type.String({ minLength: 1, maxLength: 4000 }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { issue, paths, commitMessage } = params as {
|
||||
issue: number;
|
||||
paths: string[];
|
||||
commitMessage: string;
|
||||
};
|
||||
const branchResult = await readBranch();
|
||||
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
|
||||
const branch = branchResult.branch;
|
||||
if (!currentBranchPattern(issue).test(branch)) {
|
||||
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
|
||||
}
|
||||
|
||||
const relativePaths: string[] = [];
|
||||
try {
|
||||
const sandboxRoot = guardPath('.', sandboxDir);
|
||||
for (const candidate of paths) {
|
||||
const resolved = guardPath(candidate, sandboxDir);
|
||||
const relative = path.relative(sandboxRoot, resolved);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new SandboxEscapeError(candidate, sandboxDir, resolved);
|
||||
}
|
||||
relativePaths.push(relative);
|
||||
}
|
||||
} catch (error) {
|
||||
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const add = await run('/usr/bin/git', ['add', '--', ...relativePaths], 30_000);
|
||||
if (add.exitCode !== 0) return textResult(`Error: ${describeFailure('git add', add)}`);
|
||||
|
||||
const commit = await run(
|
||||
'/usr/bin/git',
|
||||
[
|
||||
'-c',
|
||||
`user.name=${identity}`,
|
||||
'-c',
|
||||
`user.email=${identity}@mosaic.invalid`,
|
||||
'commit',
|
||||
'-m',
|
||||
commitMessage,
|
||||
'--',
|
||||
...relativePaths,
|
||||
],
|
||||
60_000,
|
||||
);
|
||||
if (commit.exitCode !== 0)
|
||||
return textResult(`Error: ${describeFailure('git commit', commit)}`);
|
||||
|
||||
const queue = await run(queueGuard, ['--purpose', 'push', '-B', branch]);
|
||||
if (queue.exitCode !== 0) {
|
||||
return textResult(`Error: ${describeFailure('CI queue guard', queue)}`);
|
||||
}
|
||||
|
||||
const push = await run(
|
||||
'/usr/bin/git',
|
||||
['push', '--set-upstream', 'origin', branch],
|
||||
PROCESS_TIMEOUT_MS,
|
||||
);
|
||||
if (push.exitCode !== 0) return textResult(`Error: ${describeFailure('git push', push)}`);
|
||||
|
||||
return textResult(`Published branch ${branch} as ${identity}.`);
|
||||
},
|
||||
};
|
||||
|
||||
const openPr: ToolDefinition = {
|
||||
name: 'git_open_pull_request',
|
||||
label: 'Open Pull Request',
|
||||
description:
|
||||
'Open a pull request from the current sandbox branch through the Mosaic pr-create wrapper. The wrapper targets the configured integration branch and links the tracking issue.',
|
||||
parameters: Type.Object({
|
||||
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
|
||||
title: Type.String({ minLength: 1, maxLength: 240 }),
|
||||
body: Type.String({ maxLength: 20_000 }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { issue, title, body } = params as { issue: number; title: string; body: string };
|
||||
const branchResult = await readBranch();
|
||||
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
|
||||
const branch = branchResult.branch;
|
||||
if (!currentBranchPattern(issue).test(branch)) {
|
||||
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
|
||||
}
|
||||
|
||||
const result = await run(prCreate, [
|
||||
'-t',
|
||||
title,
|
||||
'-b',
|
||||
body,
|
||||
'-B',
|
||||
baseBranch,
|
||||
'-H',
|
||||
branch,
|
||||
'-i',
|
||||
String(issue),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
return textResult(`Error: ${describeFailure('pr-create wrapper', result)}`);
|
||||
}
|
||||
return textResult(result.stdout.trim() || `Pull request opened from ${branch}.`);
|
||||
},
|
||||
};
|
||||
|
||||
return [publish, openPr];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardWritePath(path, baseDir);
|
||||
safePath = guardPathUnsafe(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export { createBrainTools } from './brain-tools.js';
|
||||
export { createCoordTools } from './coord-tools.js';
|
||||
export { createDeliveryTools } from './delivery-tools.js';
|
||||
export { createFileTools } from './file-tools.js';
|
||||
export { createGitTools } from './git-tools.js';
|
||||
export { createSearchTools } from './search-tools.js';
|
||||
export { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
|
||||
export { createShellTools } from './shell-tools.js';
|
||||
export { createWebTools } from './web-tools.js';
|
||||
export { createSkillTools } from './skill-tools.js';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
@@ -101,55 +101,4 @@ describe('guardPath', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a symlink inside the sandbox that resolves outside it', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
|
||||
try {
|
||||
const target = path.join(outside, 'credential.token');
|
||||
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
|
||||
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
|
||||
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardWritePath', () => {
|
||||
it('allows a new file under an existing real sandbox directory', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
try {
|
||||
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
|
||||
try {
|
||||
const target = path.join(outside, 'credential.token');
|
||||
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
|
||||
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
|
||||
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects new files under a directory symlink that leaves the sandbox', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
|
||||
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,47 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
function isContained(candidate: string, root: string): boolean {
|
||||
return candidate === root || candidate.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
|
||||
/**
|
||||
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
||||
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
||||
*
|
||||
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
||||
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
||||
* within the user path — so symlink escape attempts are caught too.
|
||||
*
|
||||
* @param userPath - The path provided by the agent (may be relative or absolute)
|
||||
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
||||
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbsolute = path.resolve(sandboxDir);
|
||||
if (!isContained(resolved, sandboxAbsolute)) {
|
||||
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||
|
||||
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
||||
// For the user path, we check containment BEFORE resolving symlinks in the path
|
||||
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
||||
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an existing path and verify both its lexical path and real symlink
|
||||
* target remain inside the sandbox.
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
const resolvedReal = fs.realpathSync.native(resolved);
|
||||
if (!isContained(resolvedReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
|
||||
}
|
||||
return resolvedReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a writable file path whose parent already exists. Existing targets
|
||||
* are resolved fully. New targets use the real parent directory, which blocks
|
||||
* writes through a parent symlink that leaves the sandbox.
|
||||
*/
|
||||
export function guardWritePath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
let writableReal: string;
|
||||
try {
|
||||
writableReal = fs.realpathSync.native(resolved);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') throw error;
|
||||
const parentReal = fs.realpathSync.native(path.dirname(resolved));
|
||||
writableReal = path.join(parentReal, path.basename(resolved));
|
||||
}
|
||||
if (!isContained(writableReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
|
||||
}
|
||||
return writableReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
|
||||
* targets, where the path may name a deleted file and Git does not dereference
|
||||
* a tracked symlink.
|
||||
* Validates a path without resolving symlinks in the user-provided portion.
|
||||
* Use for paths that may not exist yet (creates, writes).
|
||||
*
|
||||
* Performs a lexical containment check only using path.resolve.
|
||||
*/
|
||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||
return assertLexicalContainment(userPath, sandboxDir);
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbs = path.resolve(sandboxDir);
|
||||
|
||||
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
|
||||
@@ -128,14 +128,6 @@ function runCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export function createShellToolsIfEnabled(
|
||||
sandboxDir: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ToolDefinition[] {
|
||||
if (env['AGENT_SHELL_ENABLED'] === 'false') return [];
|
||||
return createShellTools(sandboxDir);
|
||||
}
|
||||
|
||||
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||
const defaultCwd = sandboxDir ?? process.cwd();
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -68,7 +67,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
EnrollmentModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import supertest from 'supertest';
|
||||
import { unseal } from '@mosaicstack/auth';
|
||||
import {
|
||||
agentAuditEvents,
|
||||
agentIdempotencyFence,
|
||||
agentOutbox,
|
||||
agents,
|
||||
and,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
providerCredentials,
|
||||
runPgliteMigrations,
|
||||
sql,
|
||||
users,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
|
||||
import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import {
|
||||
EnrollmentRepository,
|
||||
type EnrollAgentInput,
|
||||
type EnrollmentResult,
|
||||
type EnrolledAgentView,
|
||||
} from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the agent enrollment family (M4-4b) — design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 1–9 and
|
||||
* 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level
|
||||
* constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts.
|
||||
*
|
||||
* The suite runs the REAL repository/service/controller graph over PGlite,
|
||||
* with only AuthGuard overridden (a session store is out of scope; the
|
||||
* override binds request.user exactly as the real guard does). The §6.3
|
||||
* static companions — no `any`-typed boundary pass-through, a single audit
|
||||
* emitter (EnrollmentRepository.appendEvent) — are code-surface properties
|
||||
* reviewed on the PR, not runtime probes.
|
||||
*/
|
||||
describe('enrollment commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let app: NestFastifyApplication;
|
||||
let http: ReturnType<typeof supertest>;
|
||||
let repo: EnrollmentRepository;
|
||||
let previousAuthSecret: string | undefined;
|
||||
|
||||
const OWNER = 'enr-owner';
|
||||
const ADMIN = 'enr-admin';
|
||||
const STRANGER = 'enr-stranger';
|
||||
const HARNESS = 'fake-harness';
|
||||
/** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */
|
||||
const SECRET = `enr-secret-value-${randomUUID()}`;
|
||||
|
||||
/** The HTTP-leg acting user; the overridden guard binds it per request. */
|
||||
let currentUserId = OWNER;
|
||||
|
||||
const enrollInput = (overrides: Partial<EnrollAgentInput> = {}): EnrollAgentInput => ({
|
||||
actorId: OWNER,
|
||||
harness: HARNESS,
|
||||
name: `Agent ${randomUUID().slice(0, 8)}`,
|
||||
persona: null,
|
||||
model: 'anthropic/claude-test',
|
||||
provider: `prov-${randomUUID().slice(0, 8)}`,
|
||||
credential: { mode: 'intake', type: 'api_key', value: SECRET },
|
||||
idempotencyKey: randomUUID(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function expectOk<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectFail<T>(
|
||||
result: EnrollmentResult<T>,
|
||||
error: string,
|
||||
): { ok: false; error: string; message: string; correlationId: string } {
|
||||
if (result.ok) throw new Error(`expected ${error}, got ok`);
|
||||
expect(result.error).toBe(error);
|
||||
return result;
|
||||
}
|
||||
|
||||
const fenceForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, key));
|
||||
|
||||
const eventsForAgent = (agentId: string) =>
|
||||
handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId));
|
||||
|
||||
const agentsNamed = (name: string) =>
|
||||
handle.db.select().from(agents).where(eq(agents.name, name));
|
||||
|
||||
const credentialsFor = (userId: string, provider: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(providerCredentials)
|
||||
.where(
|
||||
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
|
||||
);
|
||||
|
||||
const allOutbox = () => handle.db.select().from(agentOutbox);
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||
process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key';
|
||||
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: HARNESS }));
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [EnrollmentController],
|
||||
providers: [
|
||||
EnrollmentRepository,
|
||||
EnrollmentService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
{ provide: HARNESS_REGISTRY, useValue: registry },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: ExecutionContext): boolean => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: unknown }>();
|
||||
request.user = { id: currentUserId };
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
// Mirror main.ts exactly — the closure witnesses depend on these options.
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
http = supertest(app.getHttpServer());
|
||||
repo = moduleRef.get(EnrollmentRepository);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await app?.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||
});
|
||||
|
||||
// ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ────
|
||||
|
||||
it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => {
|
||||
expect(await handle.db.select().from(agents)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentOutbox)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §5.1 never-echo + §5.2 sealed single-copy ─────────────────────────────
|
||||
|
||||
it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => {
|
||||
const logSink: string[] = [];
|
||||
const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) =>
|
||||
vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => {
|
||||
logSink.push(args.map(String).join(' '));
|
||||
}),
|
||||
);
|
||||
try {
|
||||
currentUserId = OWNER;
|
||||
const provider = `prov-echo-${randomUUID().slice(0, 8)}`;
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'Echo Probe',
|
||||
persona: 'a persona',
|
||||
model: 'anthropic/claude-test',
|
||||
provider,
|
||||
credential: { mode: 'intake', type: 'api_key', value: SECRET },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.text).not.toContain(SECRET);
|
||||
const agentId = (res.body as { agent: EnrolledAgentView }).agent.id;
|
||||
|
||||
const events = await eventsForAgent(agentId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(JSON.stringify(events)).not.toContain(SECRET);
|
||||
expect(JSON.stringify(await allOutbox())).not.toContain(SECRET);
|
||||
const fences = await handle.db
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(eq(agentIdempotencyFence.outcomeAgentId, agentId));
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(JSON.stringify(fences)).not.toContain(SECRET);
|
||||
expect(logSink.join('\n')).not.toContain(SECRET);
|
||||
|
||||
// §5.2 sealed single-copy: exactly one provider_credentials row, sealed
|
||||
// at rest, and it round-trips through unseal — no plaintext column.
|
||||
const creds = await credentialsFor(OWNER, provider);
|
||||
expect(creds).toHaveLength(1);
|
||||
expect(creds[0]?.encryptedValue).not.toBe(SECRET);
|
||||
expect(creds[0]?.encryptedValue).not.toContain(SECRET);
|
||||
expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET);
|
||||
} finally {
|
||||
logSpies.forEach((spy) => spy.mockRestore());
|
||||
}
|
||||
});
|
||||
|
||||
it('the agents table itself has no credential-bearing column (§5.2)', async () => {
|
||||
const result = (await handle.db.execute(
|
||||
sql`select column_name from information_schema.columns where table_name = 'agents'`,
|
||||
)) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>;
|
||||
const names = (result.rows ?? result).map((row) => row.column_name);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
for (const name of names) {
|
||||
expect(name).not.toMatch(/credential|secret|token|api_key/i);
|
||||
}
|
||||
});
|
||||
|
||||
// ── §5.3 reference resolution ─────────────────────────────────────────────
|
||||
|
||||
it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => {
|
||||
const input = enrollInput({ credential: { mode: 'reference' } });
|
||||
const result = await repo.enroll(input);
|
||||
expectFail(result, 'precondition_failed');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves a reference credential stored earlier for (actor, provider)', async () => {
|
||||
const provider = `prov-ref-${randomUUID().slice(0, 8)}`;
|
||||
const seeded = expectOk(await repo.enroll(enrollInput({ provider })));
|
||||
const result = expectOk(
|
||||
await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })),
|
||||
);
|
||||
expect(result.agent.id).not.toBe(seeded.agent.id);
|
||||
expect(await credentialsFor(OWNER, provider)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── §5.4 harness refusals, both codes ────────────────────────────────────
|
||||
|
||||
it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => {
|
||||
const blank = await repo.enroll(enrollInput({ harness: ' ' }));
|
||||
expectFail(blank, 'validation_failed');
|
||||
const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' }));
|
||||
expectFail(miss, 'precondition_failed');
|
||||
|
||||
currentUserId = OWNER;
|
||||
const httpBlank = await http.post('/api/enrollment/agents').send({
|
||||
harness: '',
|
||||
name: 'H',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
expect(httpBlank.status).toBe(400);
|
||||
});
|
||||
|
||||
// ── §5.5 idempotency set (contract 3 §4.3) ───────────────────────────────
|
||||
|
||||
it('actor-bound replay returns the recorded outcome and executes nothing new', async () => {
|
||||
const input = enrollInput();
|
||||
const first = expectOk(await repo.enroll(input));
|
||||
const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() }));
|
||||
expect(replay.agent.id).toBe(first.agent.id);
|
||||
|
||||
expect(await agentsNamed(input.name)).toHaveLength(1);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
|
||||
const events = await eventsForAgent(first.agent.id);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
|
||||
// A passing replay appends exactly the non-mutation access event.
|
||||
const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed');
|
||||
expect(replayed).toHaveLength(1);
|
||||
expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined();
|
||||
});
|
||||
|
||||
it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => {
|
||||
const input = enrollInput();
|
||||
expectOk(await repo.enroll(input));
|
||||
const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` });
|
||||
const failure = expectFail(mismatch, 'conflict');
|
||||
expect(failure.message).toBe('idempotency conflict');
|
||||
});
|
||||
|
||||
it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => {
|
||||
const modeInput = enrollInput();
|
||||
expectOk(await repo.enroll(modeInput));
|
||||
await handle.db
|
||||
.update(agentIdempotencyFence)
|
||||
.set({ replayMode: 'shared' })
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey));
|
||||
const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict');
|
||||
|
||||
const scopeInput = enrollInput();
|
||||
expectOk(await repo.enroll(scopeInput));
|
||||
await handle.db
|
||||
.update(agentIdempotencyFence)
|
||||
.set({ authorizationScope: 'some-other-scope' })
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey));
|
||||
const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict');
|
||||
|
||||
expect(modeFailure.message).toBe(scopeFailure.message);
|
||||
});
|
||||
|
||||
it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => {
|
||||
const input = enrollInput();
|
||||
expectOk(await repo.enroll(input));
|
||||
const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict');
|
||||
expect(failure.message).toBe('idempotency conflict');
|
||||
});
|
||||
|
||||
it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => {
|
||||
const input = enrollInput();
|
||||
const first = expectOk(await repo.enroll(input));
|
||||
// Simulate the legacy CRUD DELETE path removing the outcome agent: the
|
||||
// submitter no longer holds read authority on the referenced row.
|
||||
await handle.db.delete(agents).where(eq(agents.id, first.agent.id));
|
||||
expectFail(await repo.enroll(input), 'conflict');
|
||||
});
|
||||
|
||||
it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => {
|
||||
const input = enrollInput({ replayMode: 'shared' });
|
||||
expectFail(await repo.enroll(input), 'validation_failed');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
|
||||
currentUserId = OWNER;
|
||||
const key = randomUUID();
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'Shared Probe',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: key,
|
||||
replayMode: 'shared',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await fenceForKey(key)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => {
|
||||
const input = enrollInput();
|
||||
const [a, b] = await Promise.all([
|
||||
repo.enroll(input),
|
||||
repo.enroll({ ...input, correlationId: randomUUID() }),
|
||||
]);
|
||||
const okA = expectOk(a);
|
||||
const okB = expectOk(b);
|
||||
expect(okA.agent.id).toBe(okB.agent.id);
|
||||
expect(await agentsNamed(input.name)).toHaveLength(1);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
|
||||
const events = await eventsForAgent(okA.agent.id);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── §5.6 same-tx atomicity fault injection ───────────────────────────────
|
||||
|
||||
it('rolls everything back on failure at each write point — no orphan credential survives', async () => {
|
||||
const injectionPoints = [
|
||||
'writeSealedCredential',
|
||||
'insertAgentRow',
|
||||
'insertFenceRow',
|
||||
'appendEvent',
|
||||
'insertOutboxRow',
|
||||
] as const;
|
||||
|
||||
for (const point of injectionPoints) {
|
||||
const input = enrollInput();
|
||||
const spy = vi.spyOn(repo, point).mockImplementationOnce(() => {
|
||||
throw new Error(`injected ${point} fault`);
|
||||
});
|
||||
try {
|
||||
const result = await repo.enroll(input);
|
||||
expectFail(result, 'internal_fault');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
// Injection at fence/audit/outbox fires AFTER the sealed credential
|
||||
// write's statement ran — the rollback must leave no orphan row.
|
||||
expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── §5.8 is_system closure ───────────────────────────────────────────────
|
||||
|
||||
it('rejects an is_system injection attempt at the DTO boundary', async () => {
|
||||
currentUserId = OWNER;
|
||||
const key = randomUUID();
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'System Probe',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: key,
|
||||
isSystem: true,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await fenceForKey(key)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §5.9 correlation + no-existence-oracle ───────────────────────────────
|
||||
|
||||
it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => {
|
||||
const correlationId = randomUUID();
|
||||
const input = enrollInput({ correlationId });
|
||||
const result = expectOk(await repo.enroll(input));
|
||||
expect(result.correlationId).toBe(correlationId);
|
||||
const events = await eventsForAgent(result.agent.id);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.correlationId).toBe(correlationId);
|
||||
const outboxRows = await handle.db
|
||||
.select()
|
||||
.from(agentOutbox)
|
||||
.where(eq(agentOutbox.eventId, events[0]?.id as string));
|
||||
expect(outboxRows).toHaveLength(1);
|
||||
expect(outboxRows[0]?.correlationId).toBe(correlationId);
|
||||
|
||||
// Refusals carry the correlation envelope too (contract 5 §4.3).
|
||||
const refusal = expectFail(
|
||||
await repo.enroll({ ...input, name: 'changed name', correlationId }),
|
||||
'conflict',
|
||||
);
|
||||
expect(refusal.correlationId).toBe(correlationId);
|
||||
});
|
||||
|
||||
it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => {
|
||||
const enrolled = expectOk(await repo.enroll(enrollInput()));
|
||||
const correlationId = randomUUID();
|
||||
const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId));
|
||||
expect(asOwner.correlationId).toBe(correlationId);
|
||||
expect(asOwner.agent.id).toBe(enrolled.agent.id);
|
||||
const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id));
|
||||
expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
currentUserId = OWNER;
|
||||
const wire = randomUUID();
|
||||
const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { correlationId: string }).correlationId).toBe(wire);
|
||||
});
|
||||
|
||||
it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => {
|
||||
const enrolled = expectOk(await repo.enroll(enrollInput()));
|
||||
|
||||
currentUserId = STRANGER;
|
||||
const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`);
|
||||
const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`);
|
||||
expect(unauthorized.status).toBe(404);
|
||||
expect(missing.status).toBe(404);
|
||||
const strip = (body: Record<string, unknown>): Record<string, unknown> =>
|
||||
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
|
||||
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
|
||||
strip(missing.body as Record<string, unknown>),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §5.11 fail-closed ────────────────────────────────────────────────────
|
||||
|
||||
it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => {
|
||||
const before = (await handle.db.select().from(agents)).length;
|
||||
const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => {
|
||||
throw new Error('injected store outage');
|
||||
});
|
||||
try {
|
||||
expectFail(await repo.enroll(enrollInput()), 'internal_fault');
|
||||
} finally {
|
||||
txSpy.mockRestore();
|
||||
}
|
||||
const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => {
|
||||
throw new Error('injected store outage');
|
||||
});
|
||||
try {
|
||||
expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault');
|
||||
} finally {
|
||||
selectSpy.mockRestore();
|
||||
}
|
||||
expect((await handle.db.select().from(agents)).length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js';
|
||||
import { EnrollmentRepository } from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* The agent enrollment command family's closed HTTP surface (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command,
|
||||
* one query. Authentication failures are the guard's (401); everything else
|
||||
* is the repository's closed enum mapped by EnrollmentService.
|
||||
*/
|
||||
@Controller('api/enrollment')
|
||||
@UseGuards(AuthGuard)
|
||||
export class EnrollmentController {
|
||||
constructor(
|
||||
private readonly repository: EnrollmentRepository,
|
||||
private readonly service: EnrollmentService,
|
||||
) {}
|
||||
|
||||
/** agent.enroll (§3.1). */
|
||||
@Post('agents')
|
||||
async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.enroll({
|
||||
actorId: user.id,
|
||||
harness: dto.harness,
|
||||
name: dto.name,
|
||||
persona: dto.persona ?? null,
|
||||
model: dto.model,
|
||||
provider: dto.provider,
|
||||
credential: dto.credential,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
correlationId: dto.correlationId,
|
||||
replayMode: dto.replayMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */
|
||||
@Get('agents/:id')
|
||||
async getEnrollment(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query() query: GetEnrollmentQueryDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.getEnrollment(user.id, id, query.correlationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Agent enrollment command DTOs (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2,
|
||||
* contract 5 §4.1 typed boundary).
|
||||
*
|
||||
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so
|
||||
* closure is contract surface here exactly as in the hierarchy DTOs:
|
||||
* - EnrollAgentDto declares NO isSystem field — `is_system` is never
|
||||
* settable through this command (design §3.1 rule 4); the pipe refuses it.
|
||||
* - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3
|
||||
* §4.3), so a shared declaration is refused `validation_failed` at the
|
||||
* boundary, executes nothing, and records no fence row (design §3.1).
|
||||
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
||||
* assertion proves the pipe sees the decorators.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Credential input, discriminated on `mode` (design §3.1):
|
||||
* - `{ mode: 'reference' }` — a stored credential for (actor, provider)
|
||||
* must already exist; `type`/`value` must be ABSENT (the repository
|
||||
* refuses a reference that smuggles a value).
|
||||
* - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed
|
||||
* into the credential store inside the enrollment transaction and is
|
||||
* never echoed anywhere (§3.1 rule 1).
|
||||
*/
|
||||
export class EnrollCredentialDto {
|
||||
@IsIn(['reference', 'intake'])
|
||||
mode!: 'reference' | 'intake';
|
||||
|
||||
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
|
||||
@IsIn(['api_key'])
|
||||
type?: 'api_key';
|
||||
|
||||
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4096)
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class EnrollAgentDto {
|
||||
/** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
harness!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/** Stored as the agent's system prompt; null/absent leaves it unset. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
persona?: string | null;
|
||||
|
||||
/** Provider-qualified model id. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
model!: string;
|
||||
|
||||
/** Names the credential's provider. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
provider!: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EnrollCredentialDto)
|
||||
credential!: EnrollCredentialDto;
|
||||
|
||||
/** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */
|
||||
@IsUUID()
|
||||
idempotencyKey!: string;
|
||||
|
||||
/** Optional; generated when absent (contract 5 §4.3). */
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
correlationId?: string;
|
||||
|
||||
/** Only 'actor-bound' is admissible on this family — see module doc. */
|
||||
@IsOptional()
|
||||
@IsIn(['actor-bound'])
|
||||
replayMode?: 'actor-bound';
|
||||
}
|
||||
|
||||
/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */
|
||||
export class GetEnrollmentQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
correlationId?: string;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentRepository } from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* Agent enrollment command family (M4-4b; design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports
|
||||
* HarnessModule for the live harness registry — the validation source for
|
||||
* the `harness` field (a well-formed name the registry does not know is a
|
||||
* precondition failure). EnrollmentRepository is the family's sole writer;
|
||||
* every mutation runs fence-check → mutate → audit + outbox in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
imports: [HarnessModule],
|
||||
controllers: [EnrollmentController],
|
||||
providers: [EnrollmentRepository, EnrollmentService],
|
||||
exports: [EnrollmentRepository],
|
||||
})
|
||||
export class EnrollmentModule {}
|
||||
@@ -1,538 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { seal } from '@mosaicstack/auth';
|
||||
import {
|
||||
agentAuditEvents,
|
||||
agentIdempotencyFence,
|
||||
agentOutbox,
|
||||
agents,
|
||||
and,
|
||||
eq,
|
||||
providerCredentials,
|
||||
users,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import type { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
|
||||
|
||||
/**
|
||||
* Agent enrollment command repository (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4
|
||||
* envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4).
|
||||
*
|
||||
* The ONLY writer of the enrollment family's tables (`agent_audit_events`,
|
||||
* `agent_outbox`, `agent_idempotency_fence`) and the only path that sets
|
||||
* `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction:
|
||||
* fence check → (replay | credential handling → agent insert → fence insert →
|
||||
* audit event + outbox), so state, fence, event, and outbox commit or roll
|
||||
* back together (§3.1 rule 6).
|
||||
*
|
||||
* Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no
|
||||
* hierarchy grant is consulted because v1 enrollment binds no hierarchy node.
|
||||
* The recorded fence authorization scope is therefore the constant
|
||||
* platform-user identity domain (§3.1 rule 5).
|
||||
*
|
||||
* Never-echo (§3.1 rule 1): the credential value reaches exactly one sink —
|
||||
* the sealed store write — and appears in no result, audit payload, outbox
|
||||
* row, or log line. Log lines here carry correlation ids and error names
|
||||
* only, never request fields.
|
||||
*
|
||||
* The single-write helper methods (writeSealedCredential, insertAgentRow,
|
||||
* insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition;
|
||||
* the atomicity witnesses (§5.6) spy on them to inject faults at each write
|
||||
* point without any test-only production switch.
|
||||
*/
|
||||
|
||||
export const ENROLLMENT_OPERATION = 'agent.enroll';
|
||||
/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */
|
||||
const AUTHORIZATION_SCOPE = 'platform-user';
|
||||
/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */
|
||||
const CONFLICT_MESSAGE = 'idempotency conflict';
|
||||
/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */
|
||||
const NOT_FOUND_MESSAGE = 'agent not found';
|
||||
|
||||
/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */
|
||||
export type EnrollmentErrorCode =
|
||||
| 'validation_failed'
|
||||
| 'authentication_failed'
|
||||
| 'authorization_refused'
|
||||
| 'not_found'
|
||||
| 'conflict'
|
||||
| 'precondition_failed'
|
||||
| 'internal_fault';
|
||||
|
||||
export interface EnrollmentFailure {
|
||||
readonly ok: false;
|
||||
readonly error: EnrollmentErrorCode;
|
||||
readonly message: string;
|
||||
/** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export type EnrollmentResult<T> =
|
||||
| ({ readonly ok: true; readonly correlationId: string } & T)
|
||||
| EnrollmentFailure;
|
||||
|
||||
/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */
|
||||
export interface EnrolledAgentView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly status: string;
|
||||
readonly harness: string | null;
|
||||
readonly persona: string | null;
|
||||
readonly ownerId: string | null;
|
||||
readonly enrolledAt: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface EnrollCredentialInput {
|
||||
readonly mode: 'reference' | 'intake';
|
||||
readonly type?: 'api_key';
|
||||
readonly value?: string;
|
||||
}
|
||||
|
||||
export interface EnrollAgentInput {
|
||||
readonly actorId: string;
|
||||
readonly harness: string;
|
||||
readonly name: string;
|
||||
readonly persona?: string | null;
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly credential: EnrollCredentialInput;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId?: string;
|
||||
/** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */
|
||||
readonly replayMode?: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type FenceRow = typeof agentIdempotencyFence.$inferSelect;
|
||||
|
||||
/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */
|
||||
class ConcurrentEnrollmentError extends Error {
|
||||
constructor() {
|
||||
super('concurrent enrollment lost the fence race');
|
||||
this.name = 'ConcurrentEnrollmentError';
|
||||
}
|
||||
}
|
||||
|
||||
function agentView(row: AgentRow): EnrolledAgentView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
harness: row.harness,
|
||||
persona: row.systemPrompt,
|
||||
ownerId: row.ownerId,
|
||||
enrolledAt: row.enrolledAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
interface NormalizedEnrollment {
|
||||
readonly actorId: string;
|
||||
readonly harness: string;
|
||||
readonly name: string;
|
||||
readonly persona: string | null;
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly credential: EnrollCredentialInput;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the
|
||||
* credential value by construction: it covers mode and declared type only —
|
||||
* plaintext never reaches the hash.
|
||||
*/
|
||||
function digestOf(
|
||||
input: Omit<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
|
||||
): string {
|
||||
const canonical = canonicalJson({
|
||||
harness: input.harness,
|
||||
name: input.name,
|
||||
persona: input.persona,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: { mode: input.credential.mode, type: input.credential.type ?? null },
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentRepository {
|
||||
private readonly logger = new Logger(EnrollmentRepository.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||
) {}
|
||||
|
||||
async enroll(input: EnrollAgentInput): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const correlationId = input.correlationId ?? randomUUID();
|
||||
const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({
|
||||
ok: false,
|
||||
error,
|
||||
message,
|
||||
correlationId,
|
||||
});
|
||||
|
||||
const harness = input.harness.trim();
|
||||
const name = input.name.trim();
|
||||
if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty');
|
||||
if (name.length === 0 || name.length > 200) {
|
||||
return fail('validation_failed', 'name must be non-empty and at most 200 characters');
|
||||
}
|
||||
if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') {
|
||||
// Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row.
|
||||
return fail('validation_failed', 'replayMode must be actor-bound');
|
||||
}
|
||||
if (input.credential.mode === 'reference') {
|
||||
if (input.credential.type !== undefined || input.credential.value !== undefined) {
|
||||
return fail('validation_failed', 'a reference credential carries no type or value');
|
||||
}
|
||||
} else if (
|
||||
input.credential.type !== 'api_key' ||
|
||||
typeof input.credential.value !== 'string' ||
|
||||
input.credential.value.length === 0
|
||||
) {
|
||||
return fail('validation_failed', 'an intake credential requires type api_key and a value');
|
||||
}
|
||||
// Syntactic validity ends above; a well-formed name the live registry
|
||||
// does not know is a precondition failure (§3.1 table).
|
||||
if (!this.registry.has(harness)) {
|
||||
return fail('precondition_failed', 'harness is not registered');
|
||||
}
|
||||
|
||||
const normalized: NormalizedEnrollment = {
|
||||
actorId: input.actorId,
|
||||
harness,
|
||||
name,
|
||||
persona: input.persona ?? null,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: input.credential,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId,
|
||||
digest: digestOf({
|
||||
harness,
|
||||
name,
|
||||
persona: input.persona ?? null,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: input.credential,
|
||||
}),
|
||||
};
|
||||
|
||||
// Two attempts: a fence-race loser's transaction rolls back and the retry
|
||||
// resolves through the replay path against the winner's committed row —
|
||||
// or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A
|
||||
// unique-violation race never surfaces as an unhandled internal fault.
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized));
|
||||
} catch (error) {
|
||||
if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue;
|
||||
if (error instanceof ConcurrentEnrollmentError) {
|
||||
return fail('conflict', CONFLICT_MESSAGE);
|
||||
}
|
||||
// §4.4 fail-closed: whatever broke, the transaction rolled back and
|
||||
// the refusal is the internal-fault class — no fallback write or read.
|
||||
this.logger.error(
|
||||
`agent.enroll failed closed (correlation=${correlationId}): ${
|
||||
error instanceof Error ? error.name : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return fail('internal_fault', 'internal fault');
|
||||
}
|
||||
}
|
||||
return fail('internal_fault', 'internal fault');
|
||||
}
|
||||
|
||||
private async enrollTx(
|
||||
tx: Tx,
|
||||
input: NormalizedEnrollment,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const fence = await this.fenceFor(tx, input.idempotencyKey);
|
||||
if (fence) return this.replay(tx, fence, input);
|
||||
|
||||
if (input.credential.mode === 'reference') {
|
||||
// §3.1 rule 3: the reference must resolve for (actor, provider).
|
||||
const existing = await tx
|
||||
.select({ id: providerCredentials.id })
|
||||
.from(providerCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(providerCredentials.userId, input.actorId),
|
||||
eq(providerCredentials.provider, input.provider),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'precondition_failed',
|
||||
message: 'credential reference does not resolve',
|
||||
correlationId: input.correlationId,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// §3.1 rule 2: sealed-store write inside THIS transaction — a later
|
||||
// failure rolls it back, leaving no orphan credential.
|
||||
await this.writeSealedCredential(
|
||||
tx,
|
||||
input.actorId,
|
||||
input.provider,
|
||||
input.credential.value as string,
|
||||
);
|
||||
}
|
||||
|
||||
const agentRow = await this.insertAgentRow(tx, input);
|
||||
const fenceRow = await this.insertFenceRow(tx, input, agentRow.id);
|
||||
if (!fenceRow) {
|
||||
// A same-(operation, key) winner committed first; abandon our writes.
|
||||
throw new ConcurrentEnrollmentError();
|
||||
}
|
||||
await this.appendEvent(tx, {
|
||||
eventType: 'agent.enrolled',
|
||||
actorId: input.actorId,
|
||||
agentId: agentRow.id,
|
||||
correlationId: input.correlationId,
|
||||
// §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material.
|
||||
payload: {
|
||||
harness: input.harness,
|
||||
provider: input.provider,
|
||||
name: input.name,
|
||||
credentialMode: input.credential.mode,
|
||||
},
|
||||
});
|
||||
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay path (§3.1 rule 5): a fresh submission of a recorded
|
||||
* (operation, key). The actor is re-authorized exactly as a fresh
|
||||
* submission (v1: authenticated actor — the guard already ran); then mode,
|
||||
* scope, digest, and recorded-actor equality; then target-result read
|
||||
* authority (owner or admin) on the referenced agent. ANY failure refuses
|
||||
* with the single bounded conflict shape — constant, identifying no record.
|
||||
* A passing replay executes nothing and appends only the non-mutation
|
||||
* access event (with its outbox record — one outbox row per event).
|
||||
*/
|
||||
private async replay(
|
||||
tx: Tx,
|
||||
fence: FenceRow,
|
||||
input: NormalizedEnrollment,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const collision: EnrollmentFailure = {
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: CONFLICT_MESSAGE,
|
||||
correlationId: input.correlationId,
|
||||
};
|
||||
if (fence.replayMode !== 'actor-bound') return collision;
|
||||
if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision;
|
||||
if (fence.payloadDigest !== input.digest) return collision;
|
||||
if (fence.actorId !== input.actorId) return collision;
|
||||
|
||||
const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1);
|
||||
const agentRow = rows[0];
|
||||
if (!agentRow) return collision;
|
||||
const authorized =
|
||||
agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId));
|
||||
if (!authorized) return collision;
|
||||
|
||||
await this.appendEvent(tx, {
|
||||
eventType: 'agent.enrollment.replayed',
|
||||
actorId: input.actorId,
|
||||
agentId: agentRow.id,
|
||||
correlationId: input.correlationId,
|
||||
payload: { fenceId: fence.id },
|
||||
});
|
||||
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
|
||||
}
|
||||
|
||||
/**
|
||||
* agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and
|
||||
* missing fold to the same not_found wire shape (no existence oracle).
|
||||
*/
|
||||
async getEnrollment(
|
||||
actorId: string,
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const resolvedCorrelation = correlationId ?? randomUUID();
|
||||
try {
|
||||
const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
const authorized =
|
||||
row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId));
|
||||
if (authorized) {
|
||||
return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
message: NOT_FOUND_MESSAGE,
|
||||
correlationId: resolvedCorrelation,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${
|
||||
error instanceof Error ? error.name : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: 'internal_fault',
|
||||
message: 'internal fault',
|
||||
correlationId: resolvedCorrelation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async fenceFor(tx: Tx, idempotencyKey: string): Promise<FenceRow | null> {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(
|
||||
and(
|
||||
eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION),
|
||||
eq(agentIdempotencyFence.idempotencyKey, idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed intake write, mirroring ProviderCredentialsService.store semantics
|
||||
* (seal-at-rest, one row per (userId, provider)) but on the enrollment
|
||||
* transaction (§3.1 rule 2). The plaintext exists only in this frame.
|
||||
*/
|
||||
async writeSealedCredential(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
provider: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const encryptedValue = seal(value);
|
||||
await tx
|
||||
.insert(providerCredentials)
|
||||
.values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null })
|
||||
.onConflictDoUpdate({
|
||||
target: [providerCredentials.userId, providerCredentials.provider],
|
||||
set: {
|
||||
credentialType: 'api_key',
|
||||
encryptedValue,
|
||||
metadata: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise<AgentRow> {
|
||||
const rows = await tx
|
||||
.insert(agents)
|
||||
.values({
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
harness: input.harness,
|
||||
systemPrompt: input.persona,
|
||||
// §3.1 rule 4: owner is the authenticated actor; is_system stays default false.
|
||||
ownerId: input.actorId,
|
||||
enrolledAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error('agent insert returned no row');
|
||||
return row;
|
||||
}
|
||||
|
||||
async insertFenceRow(
|
||||
tx: Tx,
|
||||
input: NormalizedEnrollment,
|
||||
outcomeAgentId: string,
|
||||
): Promise<FenceRow | null> {
|
||||
const rows = await tx
|
||||
.insert(agentIdempotencyFence)
|
||||
.values({
|
||||
operation: ENROLLMENT_OPERATION,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
actorId: input.actorId,
|
||||
authorizationScope: AUTHORIZATION_SCOPE,
|
||||
payloadDigest: input.digest,
|
||||
replayMode: 'actor-bound',
|
||||
outcomeAgentId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */
|
||||
async appendEvent(
|
||||
tx: Tx,
|
||||
input: {
|
||||
eventType: 'agent.enrolled' | 'agent.enrollment.replayed';
|
||||
actorId: string;
|
||||
agentId: string;
|
||||
correlationId: string;
|
||||
payload: Record<string, unknown>;
|
||||
causationId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const inserted = await tx
|
||||
.insert(agentAuditEvents)
|
||||
.values({
|
||||
eventType: input.eventType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
payload: input.payload,
|
||||
})
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (!event) throw new Error('agent audit event insert returned no row');
|
||||
await this.insertOutboxRow(tx, event.id, input.correlationId);
|
||||
}
|
||||
|
||||
async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise<void> {
|
||||
await tx.insert(agentOutbox).values({ eventId, correlationId });
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
EnrollmentErrorCode,
|
||||
EnrollmentFailure,
|
||||
EnrollmentResult,
|
||||
} from './enrollment.repository.js';
|
||||
|
||||
/**
|
||||
* Maps enrollment result unions onto the closed HTTP status set (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5
|
||||
* §4.2). Every refusal body carries the correlation id (contract 5 §4.3
|
||||
* end-to-end traceability) alongside the enum code. `not_found` carries one
|
||||
* fixed message for every cause — missing agent and unauthorized caller are
|
||||
* indistinguishable on the wire (§3.2).
|
||||
*/
|
||||
const HTTP_STATUS: Record<EnrollmentErrorCode, HttpStatus> = {
|
||||
validation_failed: HttpStatus.BAD_REQUEST,
|
||||
authentication_failed: HttpStatus.UNAUTHORIZED,
|
||||
authorization_refused: HttpStatus.FORBIDDEN,
|
||||
not_found: HttpStatus.NOT_FOUND,
|
||||
conflict: HttpStatus.CONFLICT,
|
||||
precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
internal_fault: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
unwrap<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: EnrollmentFailure): HttpException {
|
||||
const status = HTTP_STATUS[failure.error];
|
||||
return new HttpException(
|
||||
{
|
||||
statusCode: status,
|
||||
error: failure.error,
|
||||
message: failure.message,
|
||||
correlationId: failure.correlationId,
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,6 @@ import {
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
import {
|
||||
EnrollAgentDto,
|
||||
EnrollCredentialDto,
|
||||
GetEnrollmentQueryDto,
|
||||
} from './enrollment/enrollment.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -110,31 +105,6 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'EnrollAgentDto',
|
||||
target: EnrollAgentDto,
|
||||
properties: [
|
||||
'harness',
|
||||
'name',
|
||||
'persona',
|
||||
'model',
|
||||
'provider',
|
||||
'credential',
|
||||
'idempotencyKey',
|
||||
'correlationId',
|
||||
'replayMode',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'EnrollCredentialDto',
|
||||
target: EnrollCredentialDto,
|
||||
properties: ['mode', 'type', 'value'],
|
||||
},
|
||||
{
|
||||
name: 'GetEnrollmentQueryDto',
|
||||
target: GetEnrollmentQueryDto,
|
||||
properties: ['correlationId'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Explicit, single-seat dogfood mode for stack-containerization B2.
|
||||
# Use with docker-compose.yml. The base stack remains credential-free.
|
||||
services:
|
||||
gateway:
|
||||
# The R4 credential helper establishes ownership from process ancestry and
|
||||
# intentionally does not trust PID 1. Keep gateway Node below Docker's init.
|
||||
init: true
|
||||
environment:
|
||||
# Identity and credential layout match a fleet seat. This fixed name prevents
|
||||
# an operator from mounting one seat while attributing actions to another.
|
||||
MOSAIC_AGENT_NAME: code-dogfood-01
|
||||
MOSAIC_GIT_IDENTITY: code-dogfood-01
|
||||
MOSAIC_BRAIN_HOME: /opt/mosaic/brain
|
||||
AGENT_FILE_SANDBOX_DIR: /workspace/stack
|
||||
# Disable the general shell before admin/user allowlist resolution. Delivery
|
||||
# uses execFile-only tools bound to the queue and PR wrappers below.
|
||||
AGENT_SHELL_ENABLED: 'false'
|
||||
AGENT_DELIVERY_ENABLED: 'true'
|
||||
MOSAIC_GIT_TOOLS_DIR: /opt/mosaic/tools/git
|
||||
MOSAIC_INTEGRATION_TRUNK: next
|
||||
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,git_publish_branch,git_open_pull_request
|
||||
volumes:
|
||||
# Mount a dedicated worktree, never the canonical clone or divergent local main.
|
||||
- type: bind
|
||||
source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree}
|
||||
target: /workspace/stack
|
||||
# A Git worktree's .git file points into the canonical clone's common Git
|
||||
# directory. Mount that directory at its original absolute path so Git can
|
||||
# resolve the pointer. File tools cannot traverse outside /workspace/stack.
|
||||
- type: bind
|
||||
source: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
|
||||
target: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
|
||||
# Only this seat home enters the container. Other fleet credentials stay outside.
|
||||
- type: bind
|
||||
source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external code-dogfood-01 seat directory}
|
||||
target: /opt/mosaic/brain/fleet/agents/code-dogfood-01
|
||||
read_only: true
|
||||
@@ -47,44 +47,6 @@ services:
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: 'true'
|
||||
|
||||
|
||||
gateway:
|
||||
# Standalone-tier application service (compose `stack` profile).
|
||||
# Default image = local build of docker/gateway.Dockerfile (works with
|
||||
# no registry auth); override GATEWAY_IMAGE to a CI-published sha tag
|
||||
# for registry deploys (git.mosaicstack.dev/mosaicstack/stack/gateway:sha-XXXXXXX).
|
||||
profiles: [stack]
|
||||
image: ${GATEWAY_IMAGE:-mosaic-gateway:dev}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/gateway.Dockerfile
|
||||
ports:
|
||||
- '${GATEWAY_HOST_PORT:-14242}:14242'
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
VALKEY_URL: valkey://valkey:6379
|
||||
# The compose IS the standalone tier by declaration (mode contract:
|
||||
# mode chosen at install); pinning skips cross-container probe races.
|
||||
MOSAIC_STORAGE_TIER: standalone
|
||||
# Standalone-tier secrets: generated at install (see .env.example).
|
||||
# Enterprise tier replaces these with Vault/Openbao plumbing.
|
||||
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET:?set in .env — openssl rand -hex 32}'
|
||||
volumes:
|
||||
- gateway_workspaces:/opt/mosaic/.workspaces
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:14242/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
gateway_workspaces:
|
||||
pg_data:
|
||||
valkey_data:
|
||||
@@ -29,29 +29,11 @@ ENV NODE_ENV=production
|
||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||
# packages was declined so routine base-image security updates remain maintainable.
|
||||
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
|
||||
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
|
||||
RUN apk add --no-cache bash curl git jq python3 \
|
||||
# git was declined so routine base-image security updates remain maintainable.
|
||||
RUN apk add --no-cache git \
|
||||
&& mkdir -p /opt/mosaic/.workspaces \
|
||||
&& chown -R node:node /opt/mosaic /app
|
||||
ENV MOSAIC_ROOT=/opt/mosaic
|
||||
# Dogfood agents use the same fail-closed credential helper, queue guard, and
|
||||
# PR-create wrapper as fleet seats. Copy only those operations and their shared
|
||||
# dependencies. Merge and infrastructure tools stay out of the image.
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/ci-queue-wait.sh /opt/mosaic/tools/git/ci-queue-wait.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic
|
||||
# R4 hardening (P0-SEC, brain 15f6979a): the credential helper is a pair.
|
||||
# python entrypoint (allowlist envp, execve boundary) + the bash implementation
|
||||
# it execs. The entrypoint derives the .impl path from its own directory, so the
|
||||
# pair sits side by side; system gitconfig keeps pointing at the entrypoint.
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic.impl /opt/mosaic/tools/git/git-credential-mosaic.impl
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/_lib/credentials.sh /opt/mosaic/tools/_lib/credentials.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/structure/validate-repo-json.sh /opt/mosaic/tools/structure/validate-repo-json.sh
|
||||
RUN git config --system credential.helper /opt/mosaic/tools/git/git-credential-mosaic
|
||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"summary": "No important actionable issues found. Helper failures remain terminal, and the previously reported token-export regression is fixed. Bash syntax, package JSON, and caller-token export checks passed. The full regression suite was not run because the sandbox is read-only.",
|
||||
"verdict": "approve",
|
||||
"confidence": 0.88,
|
||||
"findings": [],
|
||||
"stats": {
|
||||
"files_reviewed": 6,
|
||||
"blockers": 0,
|
||||
"should_fix": 0,
|
||||
"suggestions": 0
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"summary": "No confident security findings in the six supplied changed files. Seat lookups use the production helper, helper failures remain terminal, and returned credentials are validated. Bash syntax checks passed. Runtime regression tests were not run because the sandbox is read-only.",
|
||||
"risk_level": "none",
|
||||
"confidence": 0.87,
|
||||
"findings": [],
|
||||
"stats": {
|
||||
"files_reviewed": 6,
|
||||
"critical": 0,
|
||||
"high": 0,
|
||||
"medium": 0,
|
||||
"low": 0
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# #1311 Credential seat-store alignment
|
||||
|
||||
## Scope
|
||||
|
||||
Restore the missing `load_credentials` Gitea seat-slot behavior in the Stack framework. A known fleet seat must obtain its token through the production credential helper, while its Gitea URL remains provider configuration. A missing seat slot must fail closed and never fall back to the service store.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Base: `2101c9b4468b22f57b2f02bb2c9da12067225819` (`origin/next`).
|
||||
- Historical branch `origin/fix/credentials-gitea-seat-slots` contains the pre-refactor direct-loader fix, but it predates the current Python wrapper and ancestry fence.
|
||||
- Red reproduction: `test-credentials-gitea-seats.sh` failed on base for populated seats, empty-seat no-fallback, and cross-seat ancestry cases.
|
||||
- Canonical source: `packages/mosaic/framework/`. The package installer and `mosaic-doctor` describe the shipped framework as the source for deployed framework tools. The brain runtime copy is an estate runtime copy, not this framework change's source.
|
||||
|
||||
## Decision
|
||||
|
||||
The loader delegates seat token resolution to its sibling `git-credential-mosaic` production entrypoint. It does not invoke `.impl` directly or read a seat slot itself. This preserves the wrapper's environment sanitization and the implementation's process-ancestry fence. Non-seat and no-identity service-store behavior, non-Gitea services, and pre-existing `GITEA_TOKEN` precedence remain unchanged. The loader continues to export a caller-supplied token so child tool processes receive it.
|
||||
|
||||
## Validation and review
|
||||
|
||||
- The final hermetic matrix passes for Mosaic and USC seat slots, empty and cross-seat refusals, service-store paths, explicit and unexported caller tokens, and Woodpecker isolation. It fails against `origin/next` for the expected missing behavior.
|
||||
- `bash -n` passes for the loader and new suite. The framework test-enumeration guard passes with the new suite enumerated.
|
||||
- `pnpm --dir packages/mosaic run test:framework-shell` reaches `invariant_r_unittest.py` and stops on its existing host-runtime check: the test expects Pi `0.84.1` while this host reports `1.0.3`. No file in that invariant or its runtime probe changed in this task. The suite passes its earlier systemd check when the user-bus environment is supplied.
|
||||
- Initial independent code review found that the new conditional stopped exporting a caller-supplied unexported `GITEA_TOKEN`. The fix exports it in both Gitea arms and adds Mosaic/USC child-process regressions. Re-review approved with no findings. Independent security review reported risk level `none`.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Forge wrapper review round 1 (#1841)
|
||||
|
||||
Reviewed candidate: 55d6cc18e378306146505303c9b73da01164a3a9.
|
||||
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
|
||||
Implementation checkpoint: 8325df706 (new commits, no history rewrite).
|
||||
|
||||
The security and code findings are addressed by the following commits and controls.
|
||||
All provider fixtures use synthetic credentials; no test writes to a live forge.
|
||||
|
||||
| Finding | Fix commit | Regression evidence |
|
||||
| --- | --- | --- |
|
||||
| S1 | d93960111 | `Policy.test_head_and_uncommitted_policy_cannot_authorize_merge`: a committed head and uncommitted declaration cannot widen the pinned target policy; absent policy remains squash-only; unavailable target objects refuse. |
|
||||
| S2 | d93960111 | `CLI.test_missing_identity_refuses_before_requests`, `test_mutation_verifies_actor`, `test_enterprise_auth_uses_explicit_host_store`: no inherited provider login authority. |
|
||||
| S3 | d93960111 | `Auth.test_sigterm_leaves_no_credential_files`: anonymous descriptor delivery, no token in argv, no named credential file after termination. |
|
||||
| S4 | d93960111 | `ReviewRegressions.test_malformed_integration_is_unknown`: empty, false, string and malformed objects refuse; only null means absence. |
|
||||
| S5 | d93960111 | `ReviewRegressions.test_null_thread_is_not_resolved`: resolution must be boolean. |
|
||||
| S6 | d93960111; fixture completion 02c7f2b18 and 8325df706 | `Auth.test_no_identity_cannot_borrow_shared_or_git_config`, `test_host_bound_credential_and_xtrace`, `test_provider_store_separates_seat_and_service`; explicit-actor fixture migration. |
|
||||
| C1 | d93960111 | `ReviewRegressions.test_same_sha_retarget_snapshot_refused`, `test_retarget_merge_never_writes`: full head/base ref, SHA and repository identity stay pinned across policy, aggregation and pre-write/readback checks. |
|
||||
| C2 | e62adbcc2 | `Parity.test_independent_paths_match_expected_timeline_verdicts`, `test_profiles_use_same_independent_entry_paths`, `test_ready_and_stop_oracle`, `test_missing_notifier_cannot_arm`: separate wake/status and notifier/predicate/reread paths, recorded calls, independent expected verdicts and no writes. |
|
||||
| C3 | e62adbcc2, 02c7f2b18 | Offline schema fixtures in `test-detect-platform-outside-repo.sh`, `test-issue-create-body-safety.sh`, `test-pr-metadata-gitea.sh`; unexpected network clients denied in the isolated sweep. |
|
||||
| C4 | e62adbcc2 | `test-pr-skill-contract.py`: optional operator-local forbidden patterns default to empty; generic placeholder control. |
|
||||
| C5 | e62adbcc2 | `git diff --check 924438016e2eb1c2ca352bfbf9107143b8b342bb`: exit 0. |
|
||||
|
||||
The policy adapter reads the declaration with `git show` from the provider's pinned
|
||||
base commit in the origin-matched object store. It never consults the PR checkout's
|
||||
policy or invokes its local declaration loader for mutation authority. The installed
|
||||
validator still checks the target declaration. Missing target objects fail closed.
|
||||
|
||||
GitHub uses the explicit host-bound credential transport rather than inherited CLI
|
||||
sessions. Every authenticated adapter path requires an actor, and mutation transport
|
||||
verifies the authenticated account first. The inherited credential resolver no longer
|
||||
uses shared tokens or git-config identity. Anonymous pipes carry authorization while
|
||||
stdin remains available for request JSON. Credential tracing is disabled in a subshell.
|
||||
|
||||
The independent parity oracle was also run against two scratch-copy mutants. The
|
||||
unmodified oracle exited 0; replacing ready with waiting exited 1; removing the
|
||||
full-stop scheduled-integration guard exited 1. The mutation-control driver exited 0
|
||||
only after asserting those child results. Live T3 and notifier delivery remain unverified.
|
||||
|
||||
## Verification
|
||||
|
||||
The per-suite results are recorded under `review_round_1` in
|
||||
[the evidence file](1841-forge-wrapper-evidence.json). Python suites all exit 0:
|
||||
103 adapter contract cases, 10 CLI cases, 4 authentication cases, 4 guard cases,
|
||||
6 policy cases, 5 skill cases and 6 parity cases (138 runner cases total).
|
||||
The declaration validator passes 101 assertions; Ajv schema and negative controls
|
||||
exit 0. Bash syntax checks pass for all 107 git shell files. Full ShellCheck exits 1
|
||||
on both the prior candidate (106 files) and the revised tree (107 files), with no
|
||||
new file/code/message diagnostics. Corrected credential fixtures separately exit 0.
|
||||
|
||||
Workspace preflight, typecheck, lint and format checks exit 0. The three package
|
||||
skill/manifest Vitest suites pass 97 tests. No dependencies were installed during
|
||||
this review round; the existing system ShellCheck binary was used. Generated
|
||||
push-guard fixtures now use task scratch storage and clean up instead of leaving
|
||||
malformed JSON beneath the source tree.
|
||||
|
||||
The isolated shell sweep ran all 67 suites: 65 passed, the milestone baseline failed,
|
||||
and the issue-close fixture still depended on a shared token. Commit 8325df706
|
||||
converted that final fixture to a private synthetic actor; its isolated rerun and
|
||||
ShellCheck both exit 0. There are therefore 66 passing suites after the targeted
|
||||
correction, with the original sweep codes and rerun preserved separately.
|
||||
|
||||
The milestone-create usage suite still exits 1: missing title reaches the no-origin
|
||||
error before the expected usage exit 2. The same isolated suite was run against the
|
||||
pinned base and exits 1 there. This baseline failure is separate from the fixes.
|
||||
|
||||
## Follow-up and limits
|
||||
|
||||
S7 is intentionally excluded: inherited deployment-specific host/service literals
|
||||
remain in `tools/git/detect-platform.sh` and `tools/git/README.md`. The eventual PR
|
||||
body must list their cleanup under Follow-up, per the review scope decision.
|
||||
|
||||
Qualified older native review mutations and unverifiable scheduled-integration
|
||||
operations remain unsupported as previously documented. No server upgrade or
|
||||
repository setting change was attempted. Live provider mutation, real integration
|
||||
scheduling, CI delivery and notifier delivery are unverified. Independent review of
|
||||
the new head, PR creation and merge remain separate gates.
|
||||
|
||||
No additional local fixes were carried in this round. The original carried fix
|
||||
remains Python import isolation in the shared detection/auth helper; no wholesale
|
||||
operator-local tool directory was copied.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Forge wrapper review round 2 (#1841)
|
||||
|
||||
Reviewed candidate: a193cf28458a7fc08f9ff4d6395526c5efd7918c.
|
||||
Original base: 924438016e2eb1c2ca352bfbf9107143b8b342bb.
|
||||
|
||||
## Fixes and regression controls
|
||||
|
||||
R2-C1 and R2-S2 are fixed in bec37b6fd. Public GitHub REST and GraphQL use
|
||||
`https://api.github.com`; enterprise REST uses `/api/v3` and GraphQL uses
|
||||
`/api/graphql` on the declared host. Credential lookup remains bound to the
|
||||
logical forge host. The CLI fake accepts only test-declared complete URLs and
|
||||
rejects unexpected schemes, authorities, paths and queries. Its public and
|
||||
enterprise snapshot regression requires a ready verdict, not merely equal
|
||||
results from two paths.
|
||||
|
||||
Every nonzero curl exit now refuses before HTTP status or body parsing, including
|
||||
when stdout contains a complete-looking successful HTTP response. The transport
|
||||
contract covers exit 28 and 56 for reads and writes on public GitHub, enterprise
|
||||
GitHub, Gitea and Forgejo; successful controls remain accepted. Failed writes
|
||||
retain an unknown outcome. The focused URL and curl controls each failed with
|
||||
exit 1 before the corresponding fix and passed with exit 0 afterward.
|
||||
|
||||
R2-S1 extends the earlier S6 fix to the complete shell credential lifetime. The
|
||||
round-1 direct resolver test did not cover caller-side command-substitution
|
||||
tracing; its earlier S6 completion claim was too broad. All 13 token-consuming
|
||||
shell wrappers now execute credential resolution and all consumers in a private
|
||||
subprocess with xtrace disabled. The caller never owns those token variables,
|
||||
and tracing is never restored while they exist. The shared request helper sends
|
||||
an escaped authentication config over an anonymous descriptor, preserves request
|
||||
stdin, checks curl's exit status and closes the descriptor. Named authentication
|
||||
config files and raw authentication headers in exec argv have been removed from
|
||||
these consumers. The existing merge transport continues to use anonymous stdin
|
||||
config and retains its once-only credential resolution.
|
||||
|
||||
`Auth.test_all_legacy_callers_keep_credentials_private_under_xtrace` executes an
|
||||
authenticated endpoint in each wrapper under `bash -x`, records only safe
|
||||
observations and checks stderr and argv for exposure. An inventory assertion
|
||||
requires controls for every shell token consumer. Quote/backslash escaping is
|
||||
checked independently. Scratch-copy mutants that remove metadata's private
|
||||
boundary or put its token in curl argv are detected. Existing termination and
|
||||
identity-refusal controls remain. The edit wrapper's signal test also caught a
|
||||
subprocess signal-target regression; it now targets its actual Bash process and
|
||||
preserves termination status without continuing to mutate.
|
||||
|
||||
Legacy stateful server fixtures now accept readable anonymous descriptors rather
|
||||
than requiring regular authentication files. Review readback cleanup and edit
|
||||
signal tests pass; these continue to assert persisted state, exact identity and
|
||||
failure behavior. No live provider writes are involved.
|
||||
|
||||
R2-C3 updates the README to one current credential contract: private untraced
|
||||
credential lifetime and anonymous authentication config delivery. Temporary files
|
||||
are limited to response and policy data in approved scratch storage.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
The per-command results and finding mapping are recorded in the `review_round_2`
|
||||
section of [the evidence file](1841-forge-wrapper-evidence.json).
|
||||
|
||||
No dependencies were installed. No additional operator-local fixes were copied.
|
||||
The inherited deployment-specific literals identified as S7 remain deferred to
|
||||
the eventual PR's Follow-up section. Live provider writes, scheduler behavior,
|
||||
notifier delivery, terminal-green CI and independent review of this revised head
|
||||
remain unverified. This round does not create a PR or merge a branch.
|
||||
|
||||
The final isolated run contains 75 suites: all seven Python suites pass (146
|
||||
runner cases), 66 of 67 git shell suites pass, and declaration validation passes.
|
||||
The shell driver exits 1 solely for the separately reproduced milestone-create
|
||||
missing-title baseline failure. Schema and negative controls, watch parity and
|
||||
its mutant controls pass with external network clients denied. Bash syntax
|
||||
passes all 107 shell files. Full ShellCheck exits 1 for both the prior candidate
|
||||
and this tree; the final targeted callback annotation removes the last new
|
||||
file/code/message diagnostic. Existing diagnostics remain.
|
||||
|
||||
Workspace preflight, typecheck, lint and format checks exit 0. The three package
|
||||
skill/manifest Vitest suites pass 97 tests. `git diff --check` against the original
|
||||
base exits 0. Commit hooks emit existing template configuration warnings, then
|
||||
exit 0; hooks were not bypassed.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,79 +0,0 @@
|
||||
# Forge wrapper gaps (#1841)
|
||||
|
||||
Baseline: 924438016e2eb1c2ca352bfbf9107143b8b342bb, fetched integration head.
|
||||
|
||||
The implementation keeps provider APIs in wrappers, workflow authority in skills and
|
||||
notifications in transport adapters. No live provider mutation is part of fixture tests.
|
||||
Independent candidate review and integration remain separate delivery gates.
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
- A: versioned capability/status envelope, exact-head checks and watch predicate.
|
||||
- B: complete review/comment enumeration and verified native replies/resolution.
|
||||
- C: declared merge methods and separate automatic-integration inspection/control.
|
||||
- D: direct-write guard coverage, CI-wait mapping, compatibility fixtures and documentation.
|
||||
- Skills: packaged workflows/dependencies with upstream attribution and transport parity tests.
|
||||
|
||||
The optional schema-v3 merge policy was approved during implementation. Absent policy
|
||||
remains squash-only. Task, promotion and explicit absorb mappings constrain method requests;
|
||||
provider allow_* settings further restrict them. Invalid/unknown policy never grants authority.
|
||||
Schema 1/2 stay readable; version-3 consumers retain branch gates.
|
||||
|
||||
Gitea/Forgejo scheduled merge state remains unsupported because inspected schemas lack
|
||||
readback. Native review mutations remain unsupported on the older inspected profiles.
|
||||
Unknown capabilities and required evidence fail closed. App-bound required GitHub checks
|
||||
remain unknown pending a source-specific matcher. Live authentication, repository settings,
|
||||
provider mutations and notifier delivery are unverified.
|
||||
|
||||
## Compatibility and safety work
|
||||
|
||||
Source is upstream framework content. Local fixes are evaluated individually; Python import
|
||||
isolation was carried into the shared detection/auth helper. No wholesale local tool copy was
|
||||
used. Additional fixes bind metadata detection to authority, quote declaration values as shell
|
||||
words, and execute legacy credential/CI bridges from a safe working directory.
|
||||
|
||||
Legacy fixture clients now explicitly supply public API metadata instead of relying on host
|
||||
name guessing. The no-PyYAML fixture uses an isolated no-site interpreter, because production
|
||||
-I intentionally ignores PYTHONPATH. The obsolete aggregate-only CI fixture migrated to the
|
||||
normalized exact-head/empty-check controls. The unknown-version validator fixture moved to
|
||||
version 4 when version 3 became supported.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
Focused adapter, shell-client, policy, guard, skill and parity tests pass. The declaration suite
|
||||
also passes its schema-version migration controls. New shell entry points pass ShellCheck.
|
||||
Preflight, workspace typecheck and lint pass after installing locked dependencies. The package
|
||||
dry-run includes the new skills, licenses and helper assets.
|
||||
|
||||
Final shell sweep ran 66 suites. After the isolated declaration rerun, 65 passed; the
|
||||
milestone-create missing-title usage case remains exit 1 and reproduces on the pinned base.
|
||||
Its provider/no-origin error precedes the expected usage exit 2. This unrelated baseline
|
||||
failure is documented instead of absorbed. An earlier issue-close failure under ambient
|
||||
host state disappeared in the fully isolated run.
|
||||
|
||||
All 68 declaration consumption assertions pass. The unknown-version fixture now uses v4;
|
||||
the v3 validator and shell-quoting control pass. New shell entry points pass ShellCheck;
|
||||
changed existing wrappers have no diagnostics beyond the measured baseline. All 106 git
|
||||
shell files pass bash syntax checks. Package skill/manifest tests pass 97 cases, and the real
|
||||
catalog discovers all six promoted skills. Publish-list assertions retain source and licenses
|
||||
and exclude test bytecode, with a two-entry bytecode negative control before the exclusion.
|
||||
|
||||
See 1841-forge-wrapper-evidence.json for per-suite exit codes and file mapping. Locked workspace
|
||||
dependencies were installed for required push checks. ShellCheck was extracted into temporary
|
||||
scratch without a system install. No services, containers, migration runner, application server,
|
||||
local framework rollout or provider mutation test was started. Independent review, PR, CI,
|
||||
integration and release remain pending. A host notifier is an explicit optional prerequisite
|
||||
outside supported T3 notifications; this change does not install one.
|
||||
|
||||
Parity tests replay normalized provider evidence through mocked transports. They do not prove
|
||||
live T3 support or notifier delivery. A failed required read blocks either workflow; unsupported
|
||||
schedule inspection blocks full-stop in either transport. Scheduling success is never a merge
|
||||
completion claim.
|
||||
|
||||
## Review round 2
|
||||
|
||||
Public/enterprise API routing, transport-error refusal and complete shell
|
||||
credential privacy are recorded in [round-2 evidence](1841-forge-review-round-2.md).
|
||||
The round-1 S6 claim is corrected there: resolver tracing alone did not protect
|
||||
caller-side token assignments. Review fixes remain on the original branch, with
|
||||
new commits and no history rewrite; PR creation and merge remain held.
|
||||
@@ -46,7 +46,7 @@ whitelisted — see the tool header.
|
||||
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
|
||||
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
|
||||
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
|
||||
| portainer | `tools/portainer/*.sh` | Optional Docker Swarm tools when a Portainer credential is available |
|
||||
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |
|
||||
| coolify | `tools/coolify/*.sh` | **DEPRECATED** — superseded by Portainer; do not use for new deployments |
|
||||
| authentik | `tools/authentik/*.sh` | identity (users/groups/apps/flows) |
|
||||
| cloudflare | `tools/cloudflare/*.sh` | DNS (zones/records; `-a` instance) |
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic — fleet launcher (shipped-first, split-home safe).
|
||||
#
|
||||
# T110 / P5-RM-009 stack side. Carries the T106 brain launcher contract
|
||||
# (shipped-first, worktree dev opt-in, OFF pass-through, typed failure) with
|
||||
# one split-home correction: the SHIPPED npm mosaic is resolved from the real
|
||||
# user's home (passwd database), never from $HOME. Under split-home seat
|
||||
# layouts HOME is a seat home: it carries no npm prefix, and a
|
||||
# $HOME/.npm-global there would be a plantable descriptor, so the $HOME
|
||||
# candidate is consulted only when the passwd lookup itself fails, and then
|
||||
# only with a full symlink-component refusal (secure descriptor traversal).
|
||||
#
|
||||
# Contract:
|
||||
# 1. SHIPPED npm mosaic is the default. Candidate order:
|
||||
# a. <real-home>/.npm-global/bin/mosaic — real home from the passwd
|
||||
# database. The final component may be npm's own bin symlink into
|
||||
# lib/node_modules; that indirection is npm's layout, not a plant.
|
||||
# b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup
|
||||
# fails, and then only when the candidate is a trusted-shape
|
||||
# absolute path: relative HOME and parent-escape (..) components
|
||||
# are refused outright, and every remaining component must be a
|
||||
# non-symlink (secure descriptor traversal). Refused candidates
|
||||
# are never executed.
|
||||
# 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is
|
||||
# explicitly set. Health-checked via --version; ANY doubt (absent,
|
||||
# unreadable, or failing) falls back to the shipped npm mosaic with a
|
||||
# warning on stderr. With no environment set, worktree candidates are
|
||||
# never consulted — stale worktree builds cannot regain precedence.
|
||||
# 3. MOSAIC_FLEET_CLI_OFF keeps its pass-through semantics: set (any
|
||||
# value) forces pure pass-through. The dev path is not consulted even
|
||||
# when MOSAIC_CLI_WORKTREE is also set.
|
||||
# 4. Typed failure: with no runnable candidate the launcher prints one
|
||||
# stderr line naming what was checked and exits 127.
|
||||
# 5. NEVER writes to the mosaic home or the npm prefix. Deployment to the
|
||||
# fleet goes through the real channel (PR to next -> mosaic update).
|
||||
#
|
||||
# Env:
|
||||
# MOSAIC_CLI_WORKTREE dev opt-in: path to a stack worktree whose
|
||||
# packages/mosaic/dist/cli.js is used (health-checked,
|
||||
# shipped fallback on doubt)
|
||||
# MOSAIC_FLEET_CLI_OFF set (any value) to force pure pass-through
|
||||
#
|
||||
# Component walk note: the descriptor guard splits on "/" without quoting so
|
||||
# multi-byte HOME paths with spaces are not supported for the FALLBACK
|
||||
# candidate; the passwd candidate needs no walk (trusted derivation).
|
||||
|
||||
set -u
|
||||
|
||||
fail() {
|
||||
echo "mosaic: $*" >&2
|
||||
exit 127
|
||||
}
|
||||
|
||||
# Real user home from the passwd database (HOME-independent).
|
||||
real_home() {
|
||||
getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6
|
||||
}
|
||||
|
||||
# True when any component of an ABSOLUTE candidate path is a symlink. Only
|
||||
# ever called after fallback_candidate_usable's absolute-shape check.
|
||||
path_has_symlink_component() {
|
||||
local path="$1" dir base acc="" part
|
||||
dir="$(dirname -- "$path")"
|
||||
base="$(basename -- "$path")"
|
||||
local IFS='/'
|
||||
for part in $dir; do
|
||||
acc="$acc/$part"
|
||||
[ -L "$acc" ] && return 0
|
||||
done
|
||||
[ -L "$dir/$base" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reject the untrusted $HOME fallback candidate unless it is a trusted-shape
|
||||
# absolute path: absolute, no parent-escape (..) components, and no symlink
|
||||
# components anywhere on the path. Every rejection is named on stderr so the
|
||||
# typed failure explains itself. This is the launcher's descriptor guard; the
|
||||
# suite's mutation control (guard bypassed) must plant-exec, proving the guard
|
||||
# is what stands between a hostile HOME and code execution.
|
||||
fallback_candidate_usable() {
|
||||
local candidate="$1"
|
||||
case "$candidate" in
|
||||
/*) ;;
|
||||
*)
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if printf '%s' "$candidate" | grep -qE '(^|/)\.\.(/|$)'; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2
|
||||
return 1
|
||||
fi
|
||||
if path_has_symlink_component "$candidate"; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Print shipped candidates in contract order. Refusals are reported on stderr
|
||||
# so the typed failure names the cause.
|
||||
shipped_candidates() {
|
||||
local rh home_candidate
|
||||
rh="$(real_home)"
|
||||
if [ -n "$rh" ]; then
|
||||
printf '%s\n' "$rh/.npm-global/bin/mosaic"
|
||||
return 0
|
||||
fi
|
||||
# passwd lookup failed: the only fallback is $HOME, descriptor-guarded.
|
||||
if [ -n "${HOME:-}" ]; then
|
||||
home_candidate="$HOME/.npm-global/bin/mosaic"
|
||||
if fallback_candidate_usable "$home_candidate"; then
|
||||
printf '%s\n' "$home_candidate"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_shipped() {
|
||||
local candidate
|
||||
while IFS= read -r candidate; do
|
||||
[ -n "$candidate" ] || continue
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(shipped_candidates)
|
||||
return 1
|
||||
}
|
||||
|
||||
# Dev opt-in only: an explicit MOSAIC_CLI_WORKTREE reaches the worktree build,
|
||||
# and pure pass-through (MOSAIC_FLEET_CLI_OFF) outranks it.
|
||||
if [ -n "${MOSAIC_CLI_WORKTREE:-}" ] && [ -z "${MOSAIC_FLEET_CLI_OFF:-}" ]; then
|
||||
CLI="$MOSAIC_CLI_WORKTREE/packages/mosaic/dist/cli.js"
|
||||
if [ -r "$CLI" ]; then
|
||||
if v="$(node "$CLI" --version 2>/dev/null)" && [ -n "$v" ]; then
|
||||
exec node "$CLI" "$@"
|
||||
fi
|
||||
echo "mosaic: worktree build at $CLI failed its health check; using shipped npm mosaic" >&2
|
||||
else
|
||||
echo "mosaic: worktree build at $CLI absent or unreadable; using shipped npm mosaic" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
SHIPPED="$(resolve_shipped)" || true
|
||||
if [ -n "${SHIPPED:-}" ]; then
|
||||
exec "$SHIPPED" "$@"
|
||||
fi
|
||||
|
||||
fail "no runnable CLI (shipped npm mosaic absent from the passwd-home npm prefix and \$HOME; worktree build requires MOSAIC_CLI_WORKTREE)"
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic suite for the fleet/bin/mosaic launcher (T110 / P5-RM-009).
|
||||
#
|
||||
# Arms cover the plan acceptance: split-home shipped-first positive, typed
|
||||
# failure on missing candidates, stale-worktree non-precedence, OFF
|
||||
# pass-through, and secure-descriptor refusal on the untrusted $HOME
|
||||
# fallback. No network, no real npm install, no node package build: the
|
||||
# "shipped mosaic" is a stub script and getent is PATH-stubbed (set
|
||||
# GETENT_STUB=fail to make the passwd lookup fail, exercising the guarded
|
||||
# $HOME fallback).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
LAUNCHER="$SCRIPT_DIR/mosaic"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$LAUNCHER" ] || fail "missing launcher"
|
||||
[ -x "$LAUNCHER" ] || fail "launcher is not executable"
|
||||
bash -n "$LAUNCHER" || fail "launcher fails bash -n"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
REAL_HOME="$WORK/real-home"
|
||||
SEAT_HOME="$WORK/seat-home"
|
||||
STUB_BIN="$WORK/stub-bin"
|
||||
mkdir -p "$REAL_HOME/.npm-global/bin" "$SEAT_HOME" "$STUB_BIN"
|
||||
|
||||
cat >"$REAL_HOME/.npm-global/bin/mosaic" <<'SH'
|
||||
#!/bin/sh
|
||||
echo "0.0.0-shipped-stub"
|
||||
SH
|
||||
chmod +x "$REAL_HOME/.npm-global/bin/mosaic"
|
||||
|
||||
# PATH-stubbed getent: reports the real home for the current uid, unless
|
||||
# GETENT_STUB=fail is in the launcher environment (exercises the guarded
|
||||
# $HOME fallback path).
|
||||
cat >"$STUB_BIN/getent" <<SH
|
||||
#!/bin/sh
|
||||
if [ "\${GETENT_STUB:-}" = "fail" ]; then exit 2; fi
|
||||
if [ "\$1" = "passwd" ]; then
|
||||
echo "stub:x:$(id -u):$(id -g):stub:$REAL_HOME:/bin/sh"
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
SH
|
||||
chmod +x "$STUB_BIN/getent"
|
||||
|
||||
run_launcher() { # run_launcher <home> [VAR=value ...] -- [args...]
|
||||
local home="$1"; shift
|
||||
[ "${1:-}" = "--" ] && shift
|
||||
env -i PATH="$STUB_BIN:/usr/bin:/bin" HOME="$home" TERM="${TERM:-dumb}" "$LAUNCHER" "$@"
|
||||
}
|
||||
|
||||
# A1 — acceptance 1: split-home positive. HOME is an empty seat home; the
|
||||
# shipped mosaic resolves through the passwd real home.
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A1 split-home positive: got '$out', want shipped stub version"
|
||||
|
||||
# A3 — acceptance 3: a stale worktree build is NEVER consulted without the
|
||||
# explicit opt-in, even when a worktree exists on disk.
|
||||
WT="$WORK/stale-wt"
|
||||
mkdir -p "$WT/packages/mosaic/dist"
|
||||
printf 'console.log("0.0.0-stale-worktree")\n' >"$WT/packages/mosaic/dist/cli.js"
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A3 stale worktree regained precedence without opt-in: got '$out'"
|
||||
|
||||
# A2 (opt-in healthy) — explicit MOSAIC_CLI_WORKTREE reaches the worktree.
|
||||
out="$(printf '' | env MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-stale-worktree" ] || fail "A2 opt-in worktree not used: got '$out'"
|
||||
|
||||
# A2b (opt-in unhealthy) — absent dist falls back to shipped with a warning.
|
||||
out2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>/dev/null)"
|
||||
[ "$out2" = "0.0.0-shipped-stub" ] || fail "A2b unhealthy worktree fallback output: '$out2'"
|
||||
err2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
case "$err2" in *"absent or unreadable"*|*"health check"*) ;; *) fail "A2b unhealthy worktree fallback warning missing: '$err2'" ;; esac
|
||||
|
||||
# A4 — OFF pass-through: worktree opt-in is ignored when OFF is set.
|
||||
out="$(printf '' | env MOSAIC_FLEET_CLI_OFF=1 MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A4 OFF did not force pass-through: got '$out'"
|
||||
|
||||
# A5 — acceptance 4: typed failure when no candidate exists (passwd lookup
|
||||
# fails, seat home carries no npm prefix). Expect 127 + documented message.
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A5 typed failure rc: got $rc, want 127"
|
||||
case "$err" in *"no runnable CLI"*) ;; *) fail "A5 typed failure message missing: '$err'" ;; esac
|
||||
|
||||
# A6 — secure descriptor traversal, ABSOLUTE symlink plant (corrected per
|
||||
# rev-code-02 B3: the symlink points at $PLANT/.npm-global so the candidate
|
||||
# resolves EXACTLY to the planted executable). passwd lookup fails and a
|
||||
# symlink-planted $HOME/.npm-global is refused without execution.
|
||||
PLANT="$WORK/planted-target"
|
||||
mkdir -p "$PLANT/.npm-global/bin"
|
||||
cat >"$PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/planted-sentinel"
|
||||
echo "0.0.0-planted"
|
||||
SH
|
||||
chmod +x "$PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$PLANT/.npm-global" "$SEAT_HOME/.npm-global"
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A6 planted descriptor was followed (rc $rc, out '$err')"
|
||||
case "$err" in *"symlink component"*) ;; *) fail "A6 refusal diagnostic missing: '$err'" ;; esac
|
||||
[ ! -e "$WORK/planted-sentinel" ] || fail "A6 planted mosaic EXECUTED"
|
||||
|
||||
# A6b — mutation control (rev-code-02 B3): a copy of the launcher with the
|
||||
# descriptor guard bypassed MUST execute the plant under the identical hostile
|
||||
# arm. If the mutant stays clean, the plant path is wrong and A6 proves
|
||||
# nothing.
|
||||
MUTANT="$WORK/mutant-mosaic"
|
||||
sed 's/if fallback_candidate_usable "\$home_candidate"; then/if true; then/' "$LAUNCHER" >"$MUTANT"
|
||||
chmod +x "$MUTANT"
|
||||
[ "$(grep -c 'if true; then' "$MUTANT")" -eq 1 ] || fail "A6b mutant not created (guard call not replaced)"
|
||||
set +e
|
||||
mout="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
mrc=$?
|
||||
set -e
|
||||
[ "$mrc" = "0" ] || fail "A6b mutant did not execute the plant (rc $mrc, out '$mout') - A6 proves nothing"
|
||||
[ -e "$WORK/planted-sentinel" ] || fail "A6b mutant ran but sentinel absent - plant path wrong, A6 proves nothing"
|
||||
|
||||
# A7 — relative-HOME hostile arm (rev-code-02 B2): a relative HOME whose name
|
||||
# is a symlink in the launcher CWD must be refused outright, never resolved
|
||||
# against the working directory.
|
||||
CWD_SANDBOX="$WORK/cwd-sandbox"
|
||||
REL_PLANT="$WORK/relative-plant"
|
||||
mkdir -p "$CWD_SANDBOX" "$REL_PLANT/.npm-global/bin"
|
||||
cat >"$REL_PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/relative-sentinel"
|
||||
echo "0.0.0-relative-planted"
|
||||
SH
|
||||
chmod +x "$REL_PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$REL_PLANT" "$CWD_SANDBOX/relative-home"
|
||||
set +e
|
||||
rout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rrc=$?
|
||||
set -e
|
||||
[ "$rrc" = "127" ] || fail "A7 relative HOME was followed (rc $rrc, out '$rout')"
|
||||
case "$rout" in *"relative path"*) ;; *) fail "A7 relative-refusal diagnostic missing: '$rout'" ;; esac
|
||||
[ ! -e "$WORK/relative-sentinel" ] || fail "A7 relative plant EXECUTED"
|
||||
|
||||
# A7b — mutation control for the absolute-shape check: the same mutant (guard
|
||||
# bypassed) MUST execute the relative plant under the identical arm.
|
||||
set +e
|
||||
rmout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
rmrc=$?
|
||||
set -e
|
||||
[ "$rmrc" = "0" ] || fail "A7b mutant did not execute the relative plant (rc $rmrc, out '$rmout') - A7 proves nothing"
|
||||
[ -e "$WORK/relative-sentinel" ] || fail "A7b mutant ran but relative sentinel absent - arm wrong, A7 proves nothing"
|
||||
|
||||
# A8 — parent-escape hostile arm (rev-code-02 delta, B2 remains): an absolute
|
||||
# HOME containing a literal '..' component must be refused by the
|
||||
# parent-escape check — the traversal would otherwise land on a planted tree
|
||||
# OUTSIDE the seat home with no symlink involved.
|
||||
ESC_BASE="$WORK/escape-base"
|
||||
ESC_TARGET="$WORK/escape-target"
|
||||
mkdir -p "$ESC_BASE" "$ESC_TARGET/.npm-global/bin"
|
||||
cat >"$ESC_TARGET/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/escape-sentinel"
|
||||
echo "0.0.0-escape-planted"
|
||||
SH
|
||||
chmod +x "$ESC_TARGET/.npm-global/bin/mosaic"
|
||||
set +e
|
||||
eout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
erc=$?
|
||||
set -e
|
||||
[ "$erc" = "127" ] || fail "A8 parent-escape HOME was followed (rc $erc, out '$eout')"
|
||||
case "$eout" in *"parent-escape component"*) ;; *) fail "A8 parent-escape diagnostic missing: '$eout'" ;; esac
|
||||
[ ! -e "$WORK/escape-sentinel" ] || fail "A8 escape plant EXECUTED"
|
||||
|
||||
# A8b — mutation control: the guard-bypassed copy MUST execute the parent-
|
||||
# escape plant under the identical arm (sentinel present, rc 0), proving the
|
||||
# parent-escape check is what stands.
|
||||
set +e
|
||||
emout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
emrc=$?
|
||||
set -e
|
||||
[ "$emrc" = "0" ] || fail "A8b mutant did not execute the escape plant (rc $emrc, out '$emout') - A8 proves nothing"
|
||||
[ -e "$WORK/escape-sentinel" ] || fail "A8b mutant ran but escape sentinel absent - arm wrong, A8 proves nothing"
|
||||
|
||||
echo "mosaic launcher suite: all arms passed"
|
||||
@@ -46,12 +46,7 @@ systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
# fleet/bin is exact-entry on purpose (T110 B1): the estate's fleet/bin carries
|
||||
# operator-owned executables this package does not ship; a subtree glob here
|
||||
# would make keep-mode update prune them.
|
||||
fleet/README.md
|
||||
fleet/bin/mosaic
|
||||
fleet/bin/test-mosaic-launcher.sh
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
|
||||
@@ -53,23 +53,21 @@ sends, it does not auto-reply.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| rc | Meaning |
|
||||
| --- | -------------------------------------------------------------------------------------------- |
|
||||
| 0 | delivered or queued |
|
||||
| 1 | target session not found |
|
||||
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
|
||||
| 3 | usage error (bad class, missing `-s`) |
|
||||
| rc | Meaning |
|
||||
| --- | ---------------------------------------------- |
|
||||
| 0 | delivered or queued |
|
||||
| 1 | target session not found |
|
||||
| 2 | text reached the pane but is **still a draft** |
|
||||
| 3 | usage error (bad class, missing `-s`) |
|
||||
|
||||
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
|
||||
Confirm instead:
|
||||
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
|
||||
instead:
|
||||
|
||||
```bash
|
||||
tmux capture-pane -p -t <session>:0.0 | tail -20
|
||||
```
|
||||
|
||||
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
|
||||
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
|
||||
report and investigate the pane.
|
||||
rc=2 is the normal result when the target is an idle pi seat.
|
||||
|
||||
## Durable comms
|
||||
|
||||
|
||||
@@ -136,8 +136,7 @@ The human is escalation-only for missing access, hard policy conflicts, or irrev
|
||||
|
||||
### Supported Targets
|
||||
|
||||
- **Docker Swarm**: If a stack README documents `docker stack deploy` on the manager, use that deploy path and its stated verification procedure.
|
||||
- **Portainer (optional)**: Use only when the estate holds a Portainer credential. Do not propose Portainer otherwise. Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
|
||||
- **Portainer**: Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
|
||||
- **Coolify**: Deploy via `~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>`, then verify with `service-status.sh`.
|
||||
- **Vercel**: Deploy via `vercel` CLI or connected Git integration, then verify preview/production URL health.
|
||||
- **Other SaaS providers**: Use provider CLI/API/runbook with the same validation and rollback gates.
|
||||
|
||||
@@ -30,25 +30,18 @@ supply it explicitly on any host where the provider CLI's default account is an
|
||||
| `issue-close.sh` | Close an issue |
|
||||
| `issue-reopen.sh` | Reopen a closed issue |
|
||||
|
||||
| Pull requests | |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| `pr-create.sh` | Open a pull request |
|
||||
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
|
||||
| `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 |
|
||||
| `pr-status.sh` | Aggregate exact-head PR readiness and required evidence. |
|
||||
| `pr-watch-check.sh` | Evaluate the watch predicate from a status snapshot. |
|
||||
| `pr-review-comments.sh` | Enumerate review comments with provider-qualified IDs. |
|
||||
| `pr-review-reply.sh` | Reply to a review thread when the provider supports it. |
|
||||
| `pr-review-resolve.sh` | Resolve or reopen a supported review thread. |
|
||||
| `pr-auto-merge.sh` | Inspect or control supported automatic merge scheduling. |
|
||||
| `pr-merge-queue.sh` | Inspect or control supported native merge-queue membership. |
|
||||
| Pull requests | |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `pr-create.sh` | Open a pull request |
|
||||
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
|
||||
| `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 | |
|
||||
| --------------------- | ------------------ |
|
||||
@@ -67,12 +60,10 @@ supply it explicitly on any host where the provider CLI's default account is an
|
||||
| `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 |
|
||||
| `forge-capabilities.sh` | Report versioned provider capabilities and unsupported operations. |
|
||||
| `fixture-schema-client.sh` | Offline test helper returning a synthetic public schema; rejects other requests. |
|
||||
| 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 | |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
@@ -235,11 +226,11 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
|
||||
### Repo Structure Declaration (T51)
|
||||
|
||||
```bash
|
||||
# Validate a .mosaic/repo.json declaration (schema v1/v2/v3, host:/ grammar,
|
||||
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
|
||||
# ref grammar, cross-field rules, remote normalization; spec §5)
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
|
||||
|
||||
# CI authoring rule: new/edited declarations must be schema_version 2 or 3
|
||||
# CI authoring rule: new/edited declarations must be schema_version 2
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
|
||||
|
||||
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
|
||||
@@ -369,7 +360,3 @@ mosaic-ensure-excalidraw --check # verify registration
|
||||
- Prefer `trash` over `rm` when available — recoverable beats gone forever
|
||||
- Never run destructive commands without explicit instruction
|
||||
- Write it down — "mental notes" don't survive session restarts; files do
|
||||
|
||||
Repository declarations also support schema v3 with an optional, strictly validated merge policy.
|
||||
See [the git wrapper merge-policy contract](../tools/git/README.md#repository-merge-policy-schema-version-3).
|
||||
Absent policy remains squash-only; command-line method requests do not grant merge authority.
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
name: mosaic-deploy
|
||||
description: 'Full end-to-end deployment flow: push branch → open PR → wait for CI → merge → deploy using the path documented by the stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature.'
|
||||
description: 'Full end-to-end deploy flow for Mosaic Stack projects: push branch → open PR → wait for CI → merge → redeploy Portainer stack. Use when deploying a feature branch to production or staging, or when asked to ship a completed feature. Orchestrates mosaic-gitea, mosaic-woodpecker, and mosaic-portainer skills.'
|
||||
---
|
||||
|
||||
# mosaic-deploy
|
||||
|
||||
End-to-end deployment flow.
|
||||
End-to-end deployment flow for Mosaic Stack projects.
|
||||
|
||||
## Full Deploy Sequence
|
||||
|
||||
```
|
||||
push branch → open PR → CI passes → merge → documented deploy path
|
||||
push branch → open PR → CI passes → merge → portainer redeploy
|
||||
```
|
||||
|
||||
### Step 1: Push branch and open PR
|
||||
@@ -49,32 +49,25 @@ review. Fix the cause; never route around it with a raw API call, a shared
|
||||
credential, or `force_merge`. Exceptional cases go to the operator or the
|
||||
coordinating seat, still merged through the wrapper.
|
||||
|
||||
### Step 4: Deploy Through the Documented Path
|
||||
|
||||
Read the stack README before deploying:
|
||||
|
||||
- If it documents `docker stack deploy` on the manager, use that deploy path and its verification procedure.
|
||||
- Use Portainer only when the estate holds a Portainer credential. Do not propose Portainer otherwise.
|
||||
|
||||
For an authorized Portainer deployment:
|
||||
### Step 4: Redeploy Portainer stack
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||
```
|
||||
|
||||
Check a Portainer deployment:
|
||||
Check deployment:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50
|
||||
```
|
||||
|
||||
## Optional Portainer Stack Map
|
||||
## Stack Name Map
|
||||
|
||||
For deployments that use Portainer, maintain a project → stack-name mapping in a
|
||||
skills-local override of this skill (local copies take precedence over the shipped
|
||||
canonical one). Example shape:
|
||||
Maintain your estate's project → stack-name mapping in a skills-local override of
|
||||
this skill (local copies take precedence over the shipped canonical one). Example
|
||||
shape:
|
||||
|
||||
| Project | Stack Name |
|
||||
| ------------ | ----------------- |
|
||||
@@ -84,6 +77,6 @@ canonical one). Example shape:
|
||||
## Notes
|
||||
|
||||
- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+
|
||||
- Docker Swarm image pinning: `-p` does not change a digest-pinned image. Follow the stack README's documented deployment procedure.
|
||||
- Docker Swarm image pinning: if `-p` doesn't pull a new image, SSH to the Docker node (e.g. `node-01`) and run `docker pull <image>` manually, then redeploy
|
||||
- Worktrees: all coding work in `~/src/<repo>-worktrees/<task-slug>`, never in main checkout
|
||||
- Always clean up worktree after push: `git worktree remove ~/src/<repo>-worktrees/<task-slug>`
|
||||
@@ -1,8 +1,93 @@
|
||||
---
|
||||
name: mosaic-gitea
|
||||
description: Use when an older workflow requests the Mosaic Gitea wrapper reference.
|
||||
description: Interact with Gitea repositories on git.mosaicstack.dev — create/merge/close PRs, manage issues, milestones, and CI queue waits. Use when working with Mosaic Stack git repos. Wraps scripts in ~/.config/mosaic/tools/git/. Works on both Gitea and GitHub (auto-detected via detect-platform.sh).
|
||||
---
|
||||
|
||||
# Git wrapper compatibility
|
||||
# mosaic-gitea
|
||||
|
||||
Use [ms-git-operations](../ms-git-operations/SKILL.md) for the provider-neutral wrapper contract and capability table. Repository policy and provider capabilities determine behavior. For PR waiting use [ms-babysit-pr](../ms-babysit-pr/SKILL.md). Do not keep separate forge-specific write recipes.
|
||||
Git operations via Mosaic wrapper scripts. Platform-aware (Gitea or GitHub).
|
||||
|
||||
## Setup
|
||||
|
||||
Scripts auto-detect platform from git remote. Run from inside the repo directory.
|
||||
|
||||
Credentials come from the framework credentials loader (never from a shared env
|
||||
file):
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh
|
||||
load_credentials gitea-mosaicstack
|
||||
# Exports: GITEA_TOKEN, GITEA_URL
|
||||
```
|
||||
|
||||
## Script Reference
|
||||
|
||||
All scripts in `~/.config/mosaic/tools/git/`.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
| Script | Purpose | Key flags |
|
||||
| ---------------- | ----------------------- | -------------------------------------------------------- |
|
||||
| `pr-create.sh` | Create a PR | `-t "Title" [-b "Body"] [-B base] [-H head] [-i issue#]` |
|
||||
| `pr-list.sh` | List open PRs | `[-s state]` |
|
||||
| `pr-view.sh` | View PR details | `-n <pr#>` |
|
||||
| `pr-merge.sh` | Squash-merge a PR | `-n <pr#> [-d]` (delete branch) |
|
||||
| `pr-close.sh` | Close a PR | `-n <pr#>` |
|
||||
| `pr-diff.sh` | Show PR diff | `-n <pr#>` |
|
||||
| `pr-review.sh` | Submit a review | `-n <pr#> [-a approve\|request-changes]` |
|
||||
| `pr-ci-wait.sh` | Wait for CI on PR | `-n <pr#>` |
|
||||
| `pr-metadata.sh` | Get PR metadata as JSON | `-n <pr#>` |
|
||||
|
||||
### Issues
|
||||
|
||||
| Script | Purpose | Key flags |
|
||||
| ------------------ | --------------- | --------------------------------------------------- |
|
||||
| `issue-create.sh` | Create an issue | `-t "Title" [-b "Body"] [-l labels] [-m milestone]` |
|
||||
| `issue-list.sh` | List issues | `[-s state] [-l label]` |
|
||||
| `issue-view.sh` | View issue | `-n <issue#>` |
|
||||
| `issue-close.sh` | Close issue | `-n <issue#>` |
|
||||
| `issue-comment.sh` | Add comment | `-n <issue#> -c "Comment"` |
|
||||
| `issue-assign.sh` | Assign issue | `-n <issue#> -u username` |
|
||||
| `issue-edit.sh` | Edit issue | `-n <issue#> [-t title] [-b body]` |
|
||||
|
||||
### Milestones
|
||||
|
||||
| Script | Purpose | Key flags |
|
||||
| --------------------- | ---------------- | ---------------------------- |
|
||||
| `milestone-create.sh` | Create milestone | `-t "Title" [-d "due date"]` |
|
||||
| `milestone-list.sh` | List milestones | — |
|
||||
| `milestone-close.sh` | Close milestone | `-n <number>` |
|
||||
|
||||
### CI / Queue
|
||||
|
||||
| Script | Purpose | Key flags |
|
||||
| -------------------- | ---------------------- | --------------------------- |
|
||||
| `ci-queue-wait.sh` | Wait for CI queue slot | `[-t timeout] [-B branch]` |
|
||||
| `detect-platform.sh` | Detect git platform | outputs `gitea` or `github` |
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Create PR from current branch:**
|
||||
|
||||
```bash
|
||||
cd ~/src/<repo>
|
||||
~/.config/mosaic/tools/git/pr-create.sh -t "feat: my feature" -b "Description" -i <issue#>
|
||||
```
|
||||
|
||||
**Merge a PR (squash, with CI queue guard):**
|
||||
|
||||
```bash
|
||||
cd ~/src/<repo>
|
||||
~/.config/mosaic/tools/git/pr-merge.sh -n <pr#> -d
|
||||
```
|
||||
|
||||
Branch protection is a gate, not an obstacle: if it blocks a merge, fix the cause —
|
||||
a failing check, a moved head, or a missing review. Never bypass it with a raw
|
||||
API call, a shared credential, or `force_merge`. Exceptional cases go to the
|
||||
operator or the coordinating seat, still merged through the wrapper.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mosaic policy: squash merges only, targeting `main`
|
||||
- Gitea SSH: `[email protected]` → resolves via `~/.ssh/config` to the Gitea host's SSH port (e.g. `gitea.example.internal:2222`)
|
||||
- Workers push branches and open PRs but **never merge** — orchestrator handles merges
|
||||
@@ -1,19 +1,15 @@
|
||||
---
|
||||
name: mosaic-portainer
|
||||
description: Manage Docker Swarm stacks through Portainer when a Portainer credential is available. Use when asked to list, start, stop, redeploy, or check logs through Portainer.
|
||||
description: Manage Portainer stacks on the Mosaic infrastructure. Use when asked to list, start, stop, redeploy, or check logs of Docker Swarm stacks via Portainer. Wraps scripts in ~/.config/mosaic/tools/portainer/. Requires load_credentials portainer first.
|
||||
---
|
||||
|
||||
# mosaic-portainer
|
||||
|
||||
Manage Portainer stacks through supplied scripts.
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Portainer is optional. Use this skill only when the estate holds a Portainer credential. If a stack README documents `docker stack deploy` on the manager, that is the deploy path. Do not propose Portainer otherwise.
|
||||
Manage Portainer stacks via pre-built Mosaic scripts.
|
||||
|
||||
## Setup
|
||||
|
||||
After confirming a Portainer credential is available, load it before running scripts:
|
||||
Always load credentials before running scripts:
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh
|
||||
@@ -37,11 +33,11 @@ All scripts live in `~/.config/mosaic/tools/portainer/`.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Redeploy a stack through Portainer:**
|
||||
**Redeploy a stack with fresh images:**
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n mosaic-stack -p
|
||||
```
|
||||
|
||||
**Check all stack statuses:**
|
||||
@@ -55,10 +51,12 @@ source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 100
|
||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n mosaic-stack -l 100
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `stack-redeploy.sh -p` does not override a digest-pinned image. Follow the stack README's documented deployment procedure for pinned images.
|
||||
- Credentials are loaded through `load_credentials portainer`.
|
||||
- Portainer URL: `https://portainer.example.internal:9443`
|
||||
- Primary Docker host: `node-01`, managed via Portainer agent
|
||||
- Docker Swarm image updates: `stack-redeploy.sh -p` does NOT guarantee new image pull if digest is pinned; SSH to node and `docker pull` first if needed
|
||||
- Credentials: `load_credentials portainer` (framework credentials store)
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Theo Browne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: ms-babysit-pr
|
||||
description: Use when the user asks to monitor, watch or babysit a pull request through review and CI.
|
||||
---
|
||||
|
||||
# Babysit a PR
|
||||
|
||||
Follow ms-full-send's proof and authority gates, ms-git-operations and ms-watch. Monitoring grants no merge authority. Full-stop never merges or schedules.
|
||||
|
||||
Handle current events before waiting. Read `pr-status.sh -n NUMBER --json`, pin head/base, then enumerate `pr-review-comments.sh -n NUMBER --json` completely. Verify findings against source, including old and resolved/outdated comments. Fix real in-scope defects, prove changes and refresh independent review on the new head. Use `pr-review-reply.sh --comment-id ID --body-file PATH --expect-head SHA --json` for native replies and `pr-review-resolve.sh --thread-id ID --resolve --expect-head SHA --json` for authorized resolution. PR number and repository targeting are required too. Never substitute a PR-level issue comment for a native reply. Unsupported conversation operations and incomplete lists leave explicit blockers.
|
||||
|
||||
T3 linking and notifications are optional transports. When available, register the URL with `link_pull_request`, verify it through `list_thread_pull_requests`, and report failures. They never supply readiness truth.
|
||||
|
||||
- Delegated children return URL, head SHA and pending evidence to their parent. They establish no competing watches.
|
||||
- If `watch_pull_request` supports the forge, handle current events, arm one watch and yield. On wake, reread pr-status. A runtime error is not proof of unsupported forge; report it without creating a second owner.
|
||||
- If T3 watching is absent or explicitly unsupported, use one bounded ms-watch over `pr-watch-check.sh -n NUMBER --baseline ABS_JSON --json`, with explicit host/repo. Store a private baseline envelope per owner/host/repo/PR. The predicate performs one snapshot and never runs pr-ci-wait or another blocking wait.
|
||||
- Watch terminal success, failure, cancellation, closure, merge and relevant changes. Preserve retry metadata on rate limits. Never convert a read error to unchanged pending.
|
||||
- No supported notifier means a watch-delivery blocker. Do not claim a shell polling loop is equivalent delivery.
|
||||
|
||||
Wake verdicts are `merged`, `closed_unmerged`, `ready_to_merge`, `waiting`, `blocked` or `timed_out`. Failed CI is blocked. Unknown required evidence blocks readiness. Full-stop hands off a ready candidate; full-send may merge only with explicit role authority. Both paths use the same snapshot and exit/error mapping.
|
||||
|
||||
Call `unwatch_pull_request` or stop the fallback watch before final handoff. Report URL, head/base/merge SHAs, checks, review verdict and SHA, thread completeness, integration state, blockers, authority and watch ownership. Scheduling success is not a terminal merge result.
|
||||
|
||||
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/babysit-pr) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Theo Browne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: ms-council
|
||||
description: Use when the user asks for a council or an independent panel of reviews on one candidate.
|
||||
---
|
||||
|
||||
# Council
|
||||
|
||||
Collect independent reviews of one exact candidate. Follow ms-full-send's identity, proof, storage and repository boundaries. Council grants no merge authority.
|
||||
|
||||
1. Pin candidate SHA, scope, acceptance criteria and raw test evidence. Resolve known defects first unless the requested review is about replacing the design.
|
||||
2. Use the requested membership, subject to available policy and budget. Otherwise choose independent capable reviewers from the live model catalog. Do not hardcode coding/review roles by provider. Report unavailable models and explicit policy fallbacks.
|
||||
3. In T3, use orchestrator_capabilities for live provider/model IDs and option descriptors. Delegate a separate read-only task per member, with explicit target options and retained taskId/clientRequestId. Elsewhere use supported delegation with equivalent scope. Do not create top-level threads for child reviews. Children neither mutate nor watch the candidate.
|
||||
4. Give each member the same evidence without other members' findings. Require paths, severity, confidence, reproducible findings, verdict, reviewed SHA and optional score. Scores never replace approval.
|
||||
5. Reproduce findings, fix accepted defects, explain rejected suggestions and preserve dissent. Changed candidates invalidate previous exact-head approval. Each new T3 round is a fresh delegate_task including original brief, prior findings/responses and unresolved objections, with a new stable round key.
|
||||
6. Through pr-edit.sh, record reviewed SHA, verdicts, findings, dissent, round count and optional scores while preserving existing PR content. Without a PR return the same evidence to the coordinator. Missing scores remain missing; do not invent them. A co-author is not independent.
|
||||
|
||||
Follow the repository's review gates and authority assignments. Budget/access exhaustion leaves an explicit incomplete verdict, never an approval.
|
||||
|
||||
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/council) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Theo Browne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: ms-full-send
|
||||
description: Use when the user explicitly asks for full send or unattended end-to-end delivery.
|
||||
---
|
||||
|
||||
# Full send
|
||||
|
||||
Own the requested change through the repository's declared completion gates. Invocation authorizes routine delivery within scope. It never waives independent review, hooks, protected operations or the assigned merge role. Repository instructions and the operator's authority boundary apply.
|
||||
|
||||
Use ms-git-operations for every forge action. Inspect existing worktrees and the declared integration branch first. Preserve other authors' work. Use the acting identity's own credential. Keep generated data on operator-approved disk-backed storage and set TMPDIR, TMP and TEMP before tools that create temporary files. Never force-push or bypass hooks.
|
||||
|
||||
1. Finish the scoped change and prove its behavior. Record baseline and candidate SHAs, test commands and actual exit codes. Follow the repository's delivery and review guides when present.
|
||||
2. Obtain independent review on the exact candidate SHA. Select available models according to applicable policy, without hardcoding a provider/model division of labor. A co-author cannot approve the work. Use ms-council when a panel is requested. Changed candidates require fresh review evidence.
|
||||
3. Run `ci-queue-wait.sh --purpose push` before an ordinary task-branch push. Create or edit the PR through wrappers, with explicit head/base and concise verification evidence.
|
||||
4. Follow ms-babysit-pr for checks, findings and waiting. Require `pr-status.sh -n NUMBER --expect-head SHA --json` to return a complete, ready snapshot. Enumerate inline findings through `pr-review-comments.sh`; use native reply and resolution wrappers with exact-head pins. Unsupported operations leave explicit missing evidence, never fabricated issue-comment replies.
|
||||
5. Revalidate the current intended base, independent review SHA, required checks, thread completeness and mergeability immediately before integration. Resolve the declared merge method; default remains squash when no merge policy is declared. `--method` requests a method and cannot override repository policy. Use `pr-merge.sh -n NUMBER --expect-head SHA --json`, within assigned merge authority. Workers return the candidate to the coordinator when the coordinator owns integration.
|
||||
6. Verify merged SHA and required post-merge CI, then close tracking only when authorized. Merge, deployment and product acceptance are distinct states. Report remaining gates explicitly.
|
||||
|
||||
Auto-merge and queue enabling can merge immediately. They require explicit merge authority, declared method resolution and verified server-side protection against stale approvals. They are off by default. Never use scheduling to bypass final-head review or failed gates.
|
||||
|
||||
Report URL, head/base/merge SHAs, checks, reviewer verdict and reviewed SHA, thread completeness, auto/queue state, blockers, authority mode and watch ownership. A high review score, scheduling success or an open PR is not delivery completion. Retire any owned watch before final handoff.
|
||||
|
||||
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-send) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Theo Browne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: ms-full-stop
|
||||
description: Use when the user says full stop or asks for a reviewed PR ready for them to merge.
|
||||
---
|
||||
|
||||
# Full stop
|
||||
|
||||
Follow ms-full-send's preparation, implementation, proof, independent review and ms-babysit-pr workflow. These handoff limits override integration. Never merge, enable auto-merge, enqueue a PR, push the integration/default branch, or delegate any of those actions. Only scoped task-branch commits and ordinary pushes are authorized.
|
||||
|
||||
Before editing an existing PR, before every push, and at handoff, inspect `pr-auto-merge.sh -n NUMBER --action inspect --json` and `pr-merge-queue.sh -n NUMBER --action inspect --json`. Establish disabled/absent states from verified readback. An unsupported inspection of a scheduler with raw scheduling support is unknown safety evidence, never proof it is disabled. Unknown, access-denied and unsupported inspection block dependent work identically inside and outside T3. For a new branch without a PR, record that no PR exists and inspect as soon as one is created.
|
||||
|
||||
Disable/dequeue only when explicit authority permits cancellation; re-read afterward. Otherwise report the blocker to the operator/coordinator. Inspection and cancellation never authorize enabling. Direct-commit repositories receive a prepared diff and evidence within the declared handoff boundary.
|
||||
|
||||
At handoff, fetch the intended base and reread `pr-status.sh -n NUMBER --expect-head SHA --json`. Require independent review on that exact SHA, complete required checks and conversations, open/non-draft state and clean mergeability. Report `ready_to_merge`, `waiting`, `blocked`, `closed_unmerged`, `merged` or `timed_out` from measured evidence. `ready_to_merge` means handoff, never permission to merge. A merge performed by someone else is reported as observed, not performed.
|
||||
|
||||
Retire/unwatch before handoff. Include URL or prepared diff, head/base/merge SHAs, checks, reviewer verdict and SHA, thread completeness, auto/queue states, blockers, authority mode and watch ownership. Readiness is not delivery completion.
|
||||
|
||||
Adapted from [t3dotgg/tstack](https://github.com/t3dotgg/tstack/tree/1085050f3baed1ac724caa4a8166145fb2ac88af/skills/full-stop) (MIT), revision 1085050f3baed1ac724caa4a8166145fb2ac88af. See [LICENSE](LICENSE).
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: ms-git-operations
|
||||
description: Use for git-host operations, pull requests, issues, milestones, reviews and CI queue guards.
|
||||
---
|
||||
|
||||
# Git operations
|
||||
|
||||
Use installed `tools/git/` wrappers for provider operations. They own API dialects and identity; skills own workflow authority. Never bypass wrappers, hooks, repository protection or exact-head gates. Use the acting identity's own credential without persisting it in shared git config. Missing credentials fail closed. Read wrapper help; do not invent flags.
|
||||
|
||||
Authenticated normalized adapters require explicit `MOSAIC_GIT_IDENTITY` and a host-bound token for every provider. Missing identity refuses before requests; mutations verify the authenticated actor. Shared token variables, git config identity and inherited provider logins grant no authority. See the wrapper reference for credential provisioning.
|
||||
|
||||
New PR commands accept `-n NUMBER --host HOST --repo OWNER/REPO --json`, or infer an unambiguous origin. Review mutations require `--expect-head FULL_SHA`. Python helper calls use import isolation. Preserve legacy wrapper codes unless using the versioned JSON contract. See [wrapper reference](../../tools/git/README.md) for full schemas and permissions.
|
||||
|
||||
| Operation | Wrapper | GitHub | Qualified Gitea/Forgejo |
|
||||
| ------------------------------ | ----------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Capabilities | forge-capabilities.sh | supported | versioned profiles |
|
||||
| Snapshot / predicate | pr-status.sh / pr-watch-check.sh | supported | partial fields explicit |
|
||||
| Inline enumeration | pr-review-comments.sh | supported | supported |
|
||||
| Native reply / resolution | pr-review-reply.sh / pr-review-resolve.sh | supported | Gitea 28.2 supported; older inspected profiles unsupported |
|
||||
| Merge methods | pr-merge.sh --json | merge, squash, rebase | also rebase-merge and fast-forward-only |
|
||||
| Auto-merge / queue | pr-auto-merge.sh / pr-merge-queue.sh | conditional on settings and verified protection | unsupported without documented state readback |
|
||||
| Create / edit / review / close | existing pr wrappers | retained | retained API-compatible dispatch |
|
||||
| CI guard / wait | ci-queue-wait.sh / pr-ci-wait.sh | retained separate contracts | retained separate contracts |
|
||||
|
||||
Envelope v1 contains schema_version, operation, provider, host, repo, number, head_sha, data and error. Exit codes: 0 verified result, 1 unchanged pending predicate, 2 usage, 3 POLICY-REFUSED or gate failure, 4 unsupported method/operation or forge setting, 5 access/auth, 6 malformed/incomplete/unverified, 75 transient/unknown outcome, 124 timeout. Error contains kind, message, retry_after_seconds and write_outcome. A 401/403/404 is not capability evidence. Never blindly retry an ambiguous mutation.
|
||||
|
||||
Merge policy is read from the trusted target/base commit pinned by the provider, never from the PR head or uncommitted checkout. Fetch that revision first; missing objects refuse. Head and base ref, SHA and repository must remain unchanged through policy, readiness and the final mutation check.
|
||||
|
||||
Merge policy lives in optional schema-v3 repo.json `merge`, validated through repo-decl.sh and its shared validator. allowed*methods is a non-empty subset of squash, merge, rebase, rebase-merge, fast-forward-only; default_method must belong to it. Optional shapes map task, promotion and absorb to allowed methods. No policy means squash only. An explicit method must be allowed and match any shape mapping. Forge allow*\* settings are intersected afterward; never change them. Absorb requires an explicit shape mapping. Task requests cannot invent integration authority.
|
||||
|
||||
Inspect auto-merge/queue separately from enabling/cancellation. Enable/enqueue can merge immediately and require explicit merge authority plus server-enforced checks, reviews and stale-approval dismissal. Full-stop never enables. Unknown schedule state blocks full-stop even when raw scheduling endpoints exist.
|
||||
|
||||
Use ms-babysit-pr and ms-watch for waiting. The CI queue guard controls push/merge admission, not a provider PR merge queue. Run `ci-queue-wait.sh --purpose push` before ordinary push. Required negative or unknown evidence closes the gate.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: ms-watch
|
||||
description: Use before creating a watch or arranging a later check.
|
||||
---
|
||||
|
||||
# Watch
|
||||
|
||||
Use an existing owner/notifier first. If one command answers now, run it. Durable recurring work needs a scheduler; this bounded watch is for a wait owned by the current agent.
|
||||
|
||||
For PRs use ms-babysit-pr. Prefer a supported T3 watch. Otherwise verify an operator-provisioned notifier such as `tools/agent-watch/agent-watch.sh` with a unique owner-scoped name, absolute condition paths, interval at least ten seconds and a finite timeout. The notifier is an optional runtime prerequisite, not a bundled guarantee. If it is absent or cannot deliver to this agent, report a watch-delivery blocker. Do not create a polling loop in the agent turn.
|
||||
|
||||
The condition returns 0 for a relevant event/terminal result, 1 for unchanged pending, and any other code for a broken read. Preserve errors and retry metadata. Never mask errors with grep/test, watch only for green, or call a blocking CI wait inside the predicate.
|
||||
|
||||
Use `pr-watch-check.sh -n NUMBER --repo OWNER/REPO --host HOST --baseline ABS_JSON --json`. Initialize the baseline from a successful pr-status envelope in private disk-backed storage. Keep it separate per owner/host/repo/PR. Verify the control case returns 1 before arming. Watch timeout maps to timed_out, provider errors to blocked, and every wake rereads status. A watch notification is not readiness evidence.
|
||||
|
||||
Arm exactly one watch, yield, and retire it on final handoff. No competing child watches. Do not retry ambiguous writes, and never use raw API or credential recipes in predicates.
|
||||
@@ -24,14 +24,6 @@
|
||||
# $HOME points at a per-profile directory that has no credentials file.
|
||||
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
|
||||
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
|
||||
#
|
||||
# Gitea has one additional identity-aware path. When the resolved git identity
|
||||
# names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through
|
||||
# tools/git/git-credential-mosaic rather than reading a slot directly. That
|
||||
# production entrypoint enforces the clean-environment and process-ancestry
|
||||
# fence before it reads a seat slot. A seat-slot miss is terminal: this loader
|
||||
# never substitutes the service-store token for it. URLs remain provider
|
||||
# configuration and continue to come from this loader's service store.
|
||||
|
||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
|
||||
@@ -102,85 +94,6 @@ _mosaic_load_woodpecker_legacy() {
|
||||
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
|
||||
}
|
||||
|
||||
_mosaic_resolve_git_identity() {
|
||||
local ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
if [[ -z "$ident" ]]; then
|
||||
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
fi
|
||||
printf '%s' "$ident"
|
||||
}
|
||||
|
||||
_mosaic_git_identity_is_seat() {
|
||||
local ident="$1" brain_home
|
||||
[[ -n "$ident" ]] || return 1
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
[[ -d "$brain_home/fleet/agents/$ident" ]]
|
||||
}
|
||||
|
||||
_mosaic_gitea_seat_token_from_helper() {
|
||||
# Use the production wrapper, not its Bash implementation. The wrapper
|
||||
# removes BASH_ENV/function injection before the implementation evaluates
|
||||
# MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence.
|
||||
local host="$1" ident="$2" script_dir helper response key value
|
||||
local username="" password="" username_seen=0 password_seen=0
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
helper="$script_dir/../git/git-credential-mosaic"
|
||||
|
||||
if [[ ! -x "$helper" ]]; then
|
||||
echo "Error: Gitea seat credential helper is unavailable: $helper" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
[[ -n "$key" ]] || continue
|
||||
case "$key" in
|
||||
username)
|
||||
if (( username_seen )); then
|
||||
echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2
|
||||
return 1
|
||||
fi
|
||||
username="$value"
|
||||
username_seen=1
|
||||
;;
|
||||
password)
|
||||
if (( password_seen )); then
|
||||
echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2
|
||||
return 1
|
||||
fi
|
||||
password="$value"
|
||||
password_seen=1
|
||||
;;
|
||||
*)
|
||||
echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done <<< "$response"
|
||||
|
||||
if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then
|
||||
echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$password"
|
||||
}
|
||||
|
||||
_mosaic_gitea_token() {
|
||||
# $1 is the Gitea host and $2 is the legacy service-store jq path.
|
||||
# Seats are delegated to the fenced helper; all other identities retain the
|
||||
# existing service-store behavior. A failed seat delegation returns nonzero
|
||||
# to the caller and deliberately cannot fall through to _mosaic_read_cred.
|
||||
local host="$1" service_jq_path="$2" ident
|
||||
ident="$(_mosaic_resolve_git_identity)"
|
||||
if _mosaic_git_identity_is_seat "$ident"; then
|
||||
_mosaic_gitea_seat_token_from_helper "$host" "$ident"
|
||||
return
|
||||
fi
|
||||
_mosaic_read_cred "$service_jq_path"
|
||||
}
|
||||
|
||||
load_credentials() {
|
||||
local service="$1"
|
||||
|
||||
@@ -270,31 +183,16 @@ EOF
|
||||
;;
|
||||
gitea-mosaicstack)
|
||||
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
|
||||
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
|
||||
GITEA_URL="${GITEA_URL%/}"
|
||||
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
|
||||
# An explicit caller value retains the loader's established precedence.
|
||||
# Otherwise, a known seat delegates to the ancestry-fenced helper and a
|
||||
# non-seat identity uses the established service-store lookup.
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
local _gitea_token
|
||||
_gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1
|
||||
GITEA_TOKEN="$_gitea_token"
|
||||
fi
|
||||
# Preserve the loader's contract even when the caller supplied an
|
||||
# unexported shell variable before invoking load_credentials.
|
||||
export GITEA_TOKEN
|
||||
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
|
||||
;;
|
||||
gitea-usc)
|
||||
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
|
||||
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
|
||||
GITEA_URL="${GITEA_URL%/}"
|
||||
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
local _gitea_token
|
||||
_gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1
|
||||
GITEA_TOKEN="$_gitea_token"
|
||||
fi
|
||||
export GITEA_TOKEN
|
||||
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
|
||||
;;
|
||||
woodpecker-*)
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic regression for load_credentials Gitea seat-slot resolution.
|
||||
#
|
||||
# It runs against copied framework tools under a fake HOME, fixture credentials,
|
||||
# and fake seat slots only. No real credential path is read.
|
||||
#
|
||||
# Contract pinned here:
|
||||
# G1-G3 an ancestry-owned seat resolves its own host-scoped token through
|
||||
# the production git-credential-mosaic entrypoint, whether identity
|
||||
# comes from env or per-worktree git config.
|
||||
# G4 an owned seat with no slot fails closed and never uses the service
|
||||
# token.
|
||||
# G5 a seat cannot request another seat's slot through load_credentials;
|
||||
# the helper's ancestry fence remains the authorization boundary.
|
||||
# G6/G7 no seat identity and a non-seat identity retain service-store
|
||||
# behavior.
|
||||
# G8-G10 explicitly supplied GITEA_TOKEN values keep their established
|
||||
# precedence and are exported for child tool processes.
|
||||
# G11 non-Gitea services remain unaffected.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/credentials-gitea-seats}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
FRAMEWORK_TOOLS="$FAKE_HOME/.config/mosaic/tools"
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
SPOOL_DIR="$WORK_DIR/spool"
|
||||
LOADER="$FRAMEWORK_TOOLS/_lib/credentials.sh"
|
||||
HELPER="$FRAMEWORK_TOOLS/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
mkdir -p "$FRAMEWORK_TOOLS/_lib" "$FRAMEWORK_TOOLS/git" "$BRAIN_DIR/fleet/agents" \
|
||||
"$REPO_DIR" "$SPOOL_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/credentials.sh" "$LOADER"
|
||||
cp "$SCRIPT_DIR/../git/git-credential-mosaic" "$HELPER"
|
||||
cp "$SCRIPT_DIR/../git/git-credential-mosaic.impl" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
|
||||
chmod +x "$HELPER" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" config user.name 'Credential seat test'
|
||||
git -C "$REPO_DIR" config user.email '[email protected]'
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": "https://git.mosaicstack.dev",
|
||||
"token": "fixture-service-token"
|
||||
},
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "fixture-usc-service-token"
|
||||
}
|
||||
},
|
||||
"woodpecker": {
|
||||
"default": "mosaic",
|
||||
"mosaic": {
|
||||
"url": "https://ci.example.invalid",
|
||||
"token": "fixture-woodpecker-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
for seat in seat-owner seat-config seat-usc seat-victim empty-seat; do
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/$seat/secrets"
|
||||
done
|
||||
printf '%s' 'fixture-owner-slot-token' > "$BRAIN_DIR/fleet/agents/seat-owner/secrets/gitea-mosaicstack-seat-owner.token"
|
||||
printf '%s' 'fixture-config-slot-token' > "$BRAIN_DIR/fleet/agents/seat-config/secrets/gitea-mosaicstack-seat-config.token"
|
||||
printf '%s' 'fixture-usc-slot-token' > "$BRAIN_DIR/fleet/agents/seat-usc/secrets/gitea-usc-seat-usc.token"
|
||||
printf '%s' 'fixture-victim-slot-token' > "$BRAIN_DIR/fleet/agents/seat-victim/secrets/gitea-mosaicstack-seat-victim.token"
|
||||
chmod 600 "$BRAIN_DIR"/fleet/agents/*/secrets/*.token
|
||||
|
||||
# Establishes a seat identity in an exec-frozen ancestor. The empty-name root
|
||||
# carries the lineage fence, preventing the helper from seeing this suite's
|
||||
# real parent process outside its hermetic fixture.
|
||||
cat > "$WORK_DIR/lineage-root.sh" <<'ROOT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
caller="$1"
|
||||
carrier="$2"
|
||||
shift 2
|
||||
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
|
||||
bash "$carrier" "$@"
|
||||
ROOT
|
||||
|
||||
cat > "$WORK_DIR/lineage-carrier.sh" <<'CARRIER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
loader="$1"
|
||||
brain="$2"
|
||||
credentials="$3"
|
||||
repo="$4"
|
||||
spool="$5"
|
||||
identity_source="$6"
|
||||
target="$7"
|
||||
preexisting_token="$8"
|
||||
service="${9:-gitea-mosaicstack}"
|
||||
|
||||
cd "$repo"
|
||||
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
|
||||
git config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
case "$identity_source" in
|
||||
env) export MOSAIC_GIT_IDENTITY="$target" ;;
|
||||
config) git config mosaic.gitIdentity "$target" ;;
|
||||
none) ;;
|
||||
*) echo "unknown identity source: $identity_source" >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "$preexisting_token" != '-' ]]; then
|
||||
export GITEA_TOKEN="$preexisting_token"
|
||||
fi
|
||||
export MOSAIC_BRAIN_HOME="$brain"
|
||||
export MOSAIC_CREDENTIALS_FILE="$credentials"
|
||||
export MOSAIC_CREDENTIAL_SPOOL="$spool"
|
||||
# shellcheck source=/dev/null
|
||||
source "$loader"
|
||||
load_credentials "$service"
|
||||
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
|
||||
CARRIER
|
||||
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
|
||||
|
||||
run_lineage() {
|
||||
local caller="$1" source="$2" target="$3" preset="$4" service="${5:-gitea-mosaicstack}"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
|
||||
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
|
||||
"$LOADER" "$BRAIN_DIR" "$CREDENTIALS_FILE" "$REPO_DIR" "$SPOOL_DIR" \
|
||||
"$source" "$target" "$preset" "$service"
|
||||
}
|
||||
|
||||
run_plain() {
|
||||
local source="$1" target="$2" preset="$3" service="${4:-gitea-mosaicstack}"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
|
||||
LOADER="$LOADER" REPO_DIR="$REPO_DIR" IDENTITY_SOURCE="$source" TARGET="$target" \
|
||||
PRESET="$preset" SERVICE="$service" bash -c '
|
||||
set -euo pipefail
|
||||
cd "$REPO_DIR"
|
||||
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
|
||||
git config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
case "$IDENTITY_SOURCE" in
|
||||
env) export MOSAIC_GIT_IDENTITY="$TARGET" ;;
|
||||
config) git config mosaic.gitIdentity "$TARGET" ;;
|
||||
none) ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
if [[ "$PRESET" != "-" ]]; then export GITEA_TOKEN="$PRESET"; fi
|
||||
source "$LOADER"
|
||||
load_credentials "$SERVICE"
|
||||
printf "url=%s\\ntoken=%s\\n" "$GITEA_URL" "$GITEA_TOKEN"
|
||||
'
|
||||
}
|
||||
|
||||
run_unexported_token() {
|
||||
local service="$1"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" LOADER="$LOADER" SERVICE="$service" \
|
||||
bash -s <<'UNEXPORTED'
|
||||
set -euo pipefail
|
||||
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
|
||||
GITEA_TOKEN='fixture-unexported-token'
|
||||
source "$LOADER"
|
||||
load_credentials "$SERVICE"
|
||||
child_token="$(bash -c 'printf "%s" "${GITEA_TOKEN:-}"')"
|
||||
[[ "$child_token" == "$GITEA_TOKEN" ]] || {
|
||||
echo 'GITEA_TOKEN was not exported to a child process' >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
|
||||
UNEXPORTED
|
||||
}
|
||||
|
||||
fail=0
|
||||
assert_success() {
|
||||
local desc="$1" expected_token="$2" expected_url="$3"
|
||||
shift 3
|
||||
local err="$WORK_DIR/stderr.tmp" out rc token url
|
||||
: > "$err"
|
||||
set +e
|
||||
out=$("$@" 2>"$err")
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL: $desc — expected success, got rc=$rc" >&2
|
||||
cat "$err" >&2
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
token="$(printf '%s\n' "$out" | awk -F= '/^token=/{print substr($0, 7)}')"
|
||||
url="$(printf '%s\n' "$out" | awk -F= '/^url=/{print substr($0, 5)}')"
|
||||
if [[ "$token" != "$expected_token" ]]; then
|
||||
echo "FAIL: $desc — resolved the wrong token source" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$url" != "$expected_url" ]]; then
|
||||
echo "FAIL: $desc — URL did not come from provider configuration" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_refused() {
|
||||
local desc="$1" expected_reason="$2"
|
||||
shift 2
|
||||
local err="$WORK_DIR/stderr.tmp" out rc
|
||||
: > "$err"
|
||||
set +e
|
||||
out=$("$@" 2>"$err")
|
||||
rc=$?
|
||||
set -e
|
||||
local diagnostics
|
||||
diagnostics="$(cat "$err")"
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected refusal, got success" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$out" ]]; then
|
||||
echo "FAIL: $desc — refusal emitted credential output" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$diagnostics" != *"$expected_reason"* ]]; then
|
||||
echo "FAIL: $desc — refusal did not retain helper reason $expected_reason" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$out$diagnostics" == *'fixture-service-token'* || "$out$diagnostics" == *'fixture-victim-slot-token'* ]]; then
|
||||
echo "FAIL: $desc — refusal exposed or fell back to another store" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# G1: env identity, owning seat, populated Mosaic slot.
|
||||
assert_success 'G1 env-owned seat uses its Mosaic slot through the helper' 'fixture-owner-slot-token' 'https://git.mosaicstack.dev' \
|
||||
run_lineage seat-owner env seat-owner -
|
||||
|
||||
# G2: the same contract when the helper and loader resolve git config identity.
|
||||
assert_success 'G2 config-owned seat uses its Mosaic slot through the helper' 'fixture-config-slot-token' 'https://git.mosaicstack.dev' \
|
||||
run_lineage seat-config config seat-config -
|
||||
|
||||
# G3: the USC arm remains host-scoped rather than borrowing Mosaic credentials.
|
||||
assert_success 'G3 USC-owned seat uses its USC slot through the helper' 'fixture-usc-slot-token' 'https://git.uscllc.com' \
|
||||
run_lineage seat-usc env seat-usc - gitea-usc
|
||||
|
||||
# G4: a seat slot miss must be terminal, never service-store fallback.
|
||||
assert_refused 'G4 empty owned seat refuses without service fallback' 'no-token-for-identity' \
|
||||
run_lineage empty-seat env empty-seat -
|
||||
|
||||
# G5: a child cannot select another seat by rewriting MOSAIC_GIT_IDENTITY.
|
||||
assert_refused 'G5 cross-seat identity is refused by ancestry fencing' 'cross-seat-identity-refused' \
|
||||
run_lineage seat-owner env seat-victim -
|
||||
|
||||
# G6/G7: non-seat paths retain the existing shared service-store behavior.
|
||||
assert_success 'G6 no identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
|
||||
run_plain none '' -
|
||||
assert_success 'G7 non-seat identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
|
||||
run_plain env service-automation -
|
||||
|
||||
# G8: caller-provided env values retain the established loader precedence.
|
||||
assert_success 'G8 explicit GITEA_TOKEN remains caller-owned' 'fixture-preexisting-token' 'https://git.mosaicstack.dev' \
|
||||
run_lineage seat-owner env seat-owner fixture-preexisting-token
|
||||
|
||||
# G9/G10: pre-existing shell variables keep the loader's export contract.
|
||||
assert_success 'G9 unexported Mosaic token reaches child processes' 'fixture-unexported-token' 'https://git.mosaicstack.dev' \
|
||||
run_unexported_token gitea-mosaicstack
|
||||
assert_success 'G10 unexported USC token reaches child processes' 'fixture-unexported-token' 'https://git.uscllc.com' \
|
||||
run_unexported_token gitea-usc
|
||||
|
||||
# G11: only Gitea has seat slots; Woodpecker remains service-scoped.
|
||||
wp_out=$(env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_GIT_IDENTITY=seat-owner \
|
||||
LOADER="$LOADER" bash -c '
|
||||
set -euo pipefail
|
||||
unset WOODPECKER_URL WOODPECKER_TOKEN
|
||||
source "$LOADER"
|
||||
load_credentials woodpecker
|
||||
printf "%s|%s" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
|
||||
')
|
||||
if [[ "$wp_out" != 'https://ci.example.invalid|fixture-woodpecker-token' ]]; then
|
||||
echo 'FAIL: G11 Woodpecker changed under a Gitea seat identity' >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo 'credentials Gitea seat-store regression passed'
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -1,2 +0,0 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -21,11 +21,7 @@ A successful provider write command—or a wrapper message based only on that co
|
||||
|
||||
## Credential handling
|
||||
|
||||
Credential resolution and the complete legacy request workflow run in a private
|
||||
subprocess with xtrace disabled. Tokens stay out of caller traces and process
|
||||
arguments. `gitea_curl_with_token` supplies an anonymous config descriptor to curl;
|
||||
request bodies retain stdin. The normalized adapter uses the same anonymous
|
||||
transport contract. No named authentication scratch files are created.
|
||||
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
|
||||
|
||||
## `tea` invocation notes (Gitea)
|
||||
|
||||
@@ -48,8 +44,8 @@ account/token configured through `tools/_lib/credentials.sh`. That means every a
|
||||
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||
separation between an author and a reviewer.
|
||||
|
||||
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
|
||||
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
|
||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||
identity**:
|
||||
|
||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||
@@ -69,12 +65,6 @@ The store is chosen by what the identity **is**, not by which file happens to ex
|
||||
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
|
||||
`packages/mosaic/src/fleet/brain-home.ts` performs.
|
||||
|
||||
The Gitea arms of `load_credentials` obtain a seat token through the production
|
||||
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
|
||||
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
|
||||
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
|
||||
its established environment precedence.
|
||||
|
||||
**There is no precedence between the two stores and no fallback from one to the other.**
|
||||
A seat whose slot is empty is refused even when a same-named token sits in the framework
|
||||
store. One credential lives in exactly one location: a second copy is drift rather than
|
||||
@@ -204,191 +194,3 @@ Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a
|
||||
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
|
||||
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
|
||||
here.
|
||||
|
||||
## Versioned PR contract
|
||||
|
||||
The provider-neutral entry points below accept `--repo OWNER/REPO --host HOST`, or
|
||||
infer an unambiguous origin. PR operations require `-n NUMBER`. Use `--host`, not
|
||||
`-H` (head in older wrappers). `--json` opts existing merge/CI-wait callers into
|
||||
version 1; new commands always emit one JSON envelope. Diagnostics use stderr.
|
||||
|
||||
| Command | Additional arguments |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| forge-capabilities.sh | `--operation status\|review_comments\|review_reply\|review_resolve\|merge\|rebase_merge\|fast_forward_only\|auto_merge\|merge_queue` |
|
||||
| pr-status.sh | `--expect-head FULL_SHA` optional; one bounded snapshot |
|
||||
| pr-watch-check.sh | `--baseline ABSOLUTE_ENVELOPE_JSON`; one snapshot, no internal wait |
|
||||
| pr-review-comments.sh | Complete paginated reviews and inline comments |
|
||||
| pr-review-reply.sh | `--comment-id OPAQUE_ID --body-file PATH --expect-head FULL_SHA` |
|
||||
| pr-review-resolve.sh | `--thread-id OPAQUE_ID --resolve\|--unresolve --expect-head FULL_SHA` |
|
||||
| pr-merge.sh | `--json --expect-head FULL_SHA [-m METHOD]` |
|
||||
| pr-auto-merge.sh | `--action inspect\|enable\|disable [--method METHOD]`; mutations require expected head |
|
||||
| pr-merge-queue.sh | `--action inspect\|enqueue\|dequeue [--method METHOD]`; mutations require expected head |
|
||||
| pr-ci-wait.sh | `[-t SECONDS] [-i SECONDS] [--expect-head FULL_SHA] [--json]` |
|
||||
|
||||
Methods are `squash`, `merge`, `rebase`, `rebase-merge`, `fast-forward-only`.
|
||||
GitHub supports only the first three. Never request force-merge or manually-merged.
|
||||
Normalized merge requires the local origin/declaration context, even with an explicit
|
||||
host/repository, and preserves queue, branch, independent-review and exact-head gates.
|
||||
Legacy squash mode retains its existing flags/output/codes. Explicit non-squash methods
|
||||
use the verified adapter. Message-trailer and branch-deletion flags belong to legacy mode;
|
||||
the normalized adapter refuses unknown flags rather than silently dropping them.
|
||||
|
||||
The envelope schema is [pr-status.schema.json](pr-status.schema.json). Fields are
|
||||
`schema_version`, `operation`, `provider`, `host`, `repo`, `number`, `head_sha`, `data`
|
||||
and nullable `error`. Errors contain `kind`, `message`, nullable
|
||||
`retry_after_seconds`, and `write_outcome` (`none`, `confirmed`, `unknown`). No blind
|
||||
write retries. Reconcile an unknown outcome against the exact returned ID/state.
|
||||
|
||||
| Code | Version 1 meaning |
|
||||
| ---- | ----------------------------------------------------------------------- |
|
||||
| 0 | Successful read or verified mutation; inspect ready/blockers separately |
|
||||
| 1 | Watch predicate unchanged and pending only |
|
||||
| 2 | Usage error |
|
||||
| 3 | Gate failure or POLICY-REFUSED (`error.kind=policy`) |
|
||||
| 4 | Unsupported API/method or forge allow\_\* setting disabled |
|
||||
| 5 | Access/auth, including hidden-resource 404; never capability evidence |
|
||||
| 6 | Malformed, incomplete, unknown capability or failed verification |
|
||||
| 75 | Transient/rate limit, moved head or unknown transport outcome |
|
||||
| 124 | Bounded wait timeout |
|
||||
|
||||
CI wait consumes normalized checks. Default mode maps gate/provider/read failures to
|
||||
legacy exit 1, usage to 2 and timeout to 124. `--json` retains version-1 error codes.
|
||||
Missing checks never become green through repeated empty polls. The CI admission guard
|
||||
and push guard retain their legacy codes; CI admission is separate from a PR merge queue.
|
||||
|
||||
Status includes head/base refs, SHAs and repositories; closure/merge SHA; draft and
|
||||
mergeability; checks with required/observed/complete; independent current-head reviews;
|
||||
thread completeness; integration states; capabilities; ready/blockers; observation time
|
||||
and canonical fingerprint. Head/base/closure are reread after aggregation. Incomplete
|
||||
reads return nonzero with ready=false. Nullable fields remain unknown, never default false.
|
||||
Fingerprint excludes observation time and includes review/comment and integration evidence.
|
||||
The observed snapshot is not an atomic lock: mutations still pin the head at the provider.
|
||||
|
||||
Required branch policy must be readable. Empty check history is not a declared no-CI policy.
|
||||
App-bound GitHub required checks currently remain unknown rather than trusting a same-name
|
||||
check from another app. Code-owner review additionally requires the provider decision.
|
||||
Unsupported branch-pattern policy/readback leaves missing evidence and blocks readiness.
|
||||
|
||||
## Qualified provider profiles and permissions
|
||||
|
||||
GitHub uses documented REST and GraphQL APIs. `GH_HOST` explicitly selects a configured
|
||||
enterprise host. Other hosts must identify Gitea/Forgejo through public swagger metadata;
|
||||
unfamiliar hosts are never guessed to be Gitea. Qualified profiles are Gitea 1.24.7 and
|
||||
28.2.0, and the inspected Forgejo 16.0.0 development schema. Unknown versions expose unknown
|
||||
capabilities until qualified. Legacy dispatch reports `gitea` for the compatible Forgejo API.
|
||||
|
||||
All profiles list every review and review-comment page. GitHub also paginates nested
|
||||
reviewThreads/comments. Authoritative IDs remain provider-qualified. Gitea lists do not
|
||||
invent a thread graph: thread_id/reply_to are null without evidence. For Gitea 28.2 resolution,
|
||||
pass the listed `gitea:comment:ID` as the resolution selector; this names the API's review
|
||||
comment, not a fabricated thread relationship. Resolved and outdated comments remain listed.
|
||||
|
||||
| Operation | GitHub | Gitea 28.2 | Gitea 1.24.7 / inspected Forgejo |
|
||||
| --------------------------------- | ----------- | ---------------------- | -------------------------------- |
|
||||
| Snapshot / inline enumeration | supported | partial state explicit | partial state explicit |
|
||||
| Native reply / resolution | supported | supported | exit 4 before mutation |
|
||||
| Merge/squash/rebase | supported | supported | supported |
|
||||
| Rebase-merge / fast-forward-only | exit 4 | supported | supported |
|
||||
| Verified auto-merge / merge queue | conditional | exit 4 | exit 4 |
|
||||
| Repository policy refuses method | exit 3 | exit 3 | exit 3 |
|
||||
| Forge setting disables method | exit 4 | exit 4 | exit 4 |
|
||||
|
||||
Public schemas inspected: Gitea swagger.v1.json; GitHub
|
||||
[REST pulls](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request) and
|
||||
[GraphQL pull operations](https://docs.github.com/en/graphql/reference/pulls).
|
||||
Gitea/Forgejo expose raw deferred-merge/cancellation endpoints, but the inspected schemas
|
||||
provide no schedule-state readback. These wrappers therefore inspect/schedule/cancel as
|
||||
unsupported without making a mutation. Deferred merge is not a native merge queue.
|
||||
|
||||
Reads require PR/review/check and branch-protection visibility. Replies/resolution require
|
||||
conversation write permission. Merge and integration controls require repository merge
|
||||
permission and applicable product settings. 401/403 is access failure; documented rate-limit
|
||||
headers yield 75 and a retry delay. Neither permissions nor feature settings are modified.
|
||||
|
||||
Enable/enqueue require explicit authority, declared method policy, repository allow\_\* settings,
|
||||
server-enforced checks and independent reviews, stale-approval dismissal and no admin bypass.
|
||||
Queue configuration must agree with the policy method. Inspect/disable/dequeue are separate
|
||||
operations and do not require merge policy. Every mutation requires expected head/readback;
|
||||
scheduling may merge immediately and returns observed merged state rather than queue success.
|
||||
|
||||
## Repository merge policy (schema version 3)
|
||||
|
||||
Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation
|
||||
through the shared declaration validator and repo-decl helper. Consumers apply the existing
|
||||
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 3,
|
||||
"integration_trunk": "integration",
|
||||
"release_branch": "stable",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.example.test/team/project",
|
||||
"canonical_clone": "host:/src/project",
|
||||
"merge": {
|
||||
"allowed_methods": ["squash", "merge"],
|
||||
"default_method": "squash",
|
||||
"shapes": { "task": "squash", "promotion": "merge", "absorb": "merge" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`allowed_methods` is non-empty and unique; default and shape methods must belong to it.
|
||||
Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is
|
||||
release→trunk and requires its explicit shape mapping; other allowed contributions are task.
|
||||
Other targets still fail transition validation. Method requests cannot override a shape mapping.
|
||||
Invalid declarations never fall back to a wider merge policy.
|
||||
|
||||
## Watch transport parity and release
|
||||
|
||||
T3 linking/watching is optional notification transport. Read normalized status after every wake.
|
||||
Fallback watching uses pr-watch-check with one owner-scoped private baseline and a supported
|
||||
notifier, never pr-ci-wait in a predicate. Unknown schedule state blocks full-stop in both paths.
|
||||
Children return pending evidence instead of owning watches. Retire watches at handoff.
|
||||
|
||||
Run `test-pr-watch-parity.sh`, `test-forge-contract.py`, `test-forge-cli.py`,
|
||||
`test-merge-policy.py`, `test-pr-skill-contract.py` and all legacy test-\*.sh suites.
|
||||
Fixtures deny unexpected network calls. Parity tests mock notification transports; they do not
|
||||
claim live notifier delivery, authenticated provider writes or T3 forge support.
|
||||
|
||||
The package manifest includes framework assets. Release/install the reviewed package before
|
||||
operator-owned tool/skill synchronization. Verify deployed files by hash against that release;
|
||||
an upgrade alone does not prove owned copies were synchronized. No local rollout is performed
|
||||
by these tests or by promoting the skill sources.
|
||||
|
||||
Set absolute `TMPDIR` to approved scratch storage for response and policy files.
|
||||
The credential transport contract is described below. Fallback notification requires
|
||||
an operator-provisioned notifier; its absence is an explicit watch-delivery blocker.
|
||||
This change packages the status/predicate and skills and does not install a notifier.
|
||||
|
||||
### Trusted policy and authentication
|
||||
|
||||
The normalized merge and scheduling adapters read `.mosaic/repo.json` from the
|
||||
PR target's pinned base commit, using the matching local origin's object store.
|
||||
Fetch the target revision before invoking a mutation. Missing objects refuse;
|
||||
head commits and uncommitted declarations cannot grant merge authority. Full
|
||||
head and base identities (ref, SHA, repository) must remain unchanged through
|
||||
policy evaluation, readiness aggregation, admission, mutation and readback.
|
||||
|
||||
Authenticated adapter reads and writes require explicit `MOSAIC_GIT_IDENTITY`.
|
||||
A mutation first verifies `/user` matches that identity. Inherited client login
|
||||
sessions and shared token environment variables grant no authority. Existing
|
||||
provider-specific seat/service token stores remain supported. Other hosts,
|
||||
including GitHub, use `<brain>/credentials/forge/<host>/<identity>.token`, where
|
||||
`MOSAIC_BRAIN_HOME` selects `<brain>` (default `~/.mosaic`). Provision the
|
||||
selected actor's token separately. Authorization reaches curl through an
|
||||
anonymous pipe; request JSON uses stdin. Tokens never enter named scratch files.
|
||||
Legacy shell wrappers keep credential resolution and the complete request workflow
|
||||
in a private subprocess with tracing disabled. Caller tracing never receives a
|
||||
secret variable; each request consumes authorization through an anonymous descriptor.
|
||||
|
||||
The skill firewall accepts optional newline-separated forbidden patterns via
|
||||
`MOSAIC_OPERATOR_FORBIDDEN_PATTERNS`; its default is empty. Deployment-specific
|
||||
patterns belong in operator configuration, outside the framework source.
|
||||
|
||||
Public GitHub REST requests use `https://api.github.com`, and GraphQL uses
|
||||
`https://api.github.com/graphql`. Enterprise REST requests use
|
||||
`https://<host>/api/v3`, and GraphQL uses `https://<host>/api/graphql`.
|
||||
Credentials remain bound to the selected forge host, such as `github.com`,
|
||||
while the adapter selects its documented API authority. CLI fixtures match
|
||||
complete request URLs, including scheme, authority, path and query.
|
||||
@@ -7,14 +7,6 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
# All workflow state belongs to this one private subprocess.
|
||||
# shellcheck disable=SC2030,SC2031
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
@@ -351,7 +343,7 @@ gitea_get_branch_head_sha() {
|
||||
# error. A not-yet-pushed feature branch has no in-flight pipeline, so the
|
||||
# pre-push queue guard must treat 404 as "queue clear", not crash.
|
||||
local resp code body
|
||||
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url")
|
||||
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url")
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [[ "$code" == "404" ]]; then
|
||||
@@ -375,7 +367,7 @@ gitea_get_commit_status_json() {
|
||||
local sha="$3"
|
||||
local token="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||
gitea_curl_with_token "$token" -fsSL -H "User-Agent: curl/8" "$url"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
# Repository-admin state for the acting credential, Gitea flavor. The guard's
|
||||
@@ -390,7 +382,7 @@ gitea_repo_admin_state() {
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}"
|
||||
local resp code body
|
||||
resp=$(gitea_curl_with_token "$token" -sS -H "User-Agent: curl/8" -w $'\n%{http_code}' "$url") || return 2
|
||||
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
@@ -481,12 +473,6 @@ if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$NO_CI_EXPECTED" -eq 1 && -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
|
||||
record_assertion_event 'ASSERTION_UNATTRIBUTABLE' 'actor-unattributable' 'unknown' || echo 'Warning: attribution audit unavailable.' >&2
|
||||
echo "Error: ASSERTION_UNATTRIBUTABLE state=unknown purpose=${PURPOSE} asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; explicit MOSAIC_GIT_IDENTITY is required (exit 78)." >&2
|
||||
exit 78
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
@@ -528,7 +514,7 @@ if git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
repo_decl_load
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
|
||||
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
route="feature"
|
||||
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
|
||||
route="trunk (integration head)"
|
||||
@@ -690,6 +676,3 @@ while true; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -15,30 +15,22 @@ detect_platform() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse the authority, rather than matching github.com anywhere in a URL.
|
||||
local host profile rc
|
||||
host=$(get_remote_host) || return 1
|
||||
if [[ "$host" == github.com || ( -n "${GH_HOST:-}" && "$host" == "$GH_HOST" ) ]]; then
|
||||
PLATFORM=github; export PLATFORM; echo github; return 0
|
||||
# Check for GitHub
|
||||
if [[ "$remote_url" == *"github.com"* ]]; then
|
||||
PLATFORM="github"
|
||||
export PLATFORM
|
||||
echo "github"
|
||||
return 0
|
||||
fi
|
||||
profile=$(curl --silent --show-error --fail --max-time 10 --url "https://$host/swagger.v1.json") || {
|
||||
echo "error: forge metadata could not be verified for $host" >&2
|
||||
return 1
|
||||
}
|
||||
rc=$(printf '%s' "$profile" | python3 -I -c '
|
||||
import json,sys
|
||||
try:
|
||||
info=json.load(sys.stdin)["info"]
|
||||
title=str(info["title"]).lower(); version=str(info["version"])
|
||||
print("forgejo" if "forgejo" in title or "gitea" in title and "+gitea-" in version else "gitea" if "gitea" in title else "unknown")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
print("unknown")
|
||||
') || return 1
|
||||
if [[ "$rc" == gitea || "$rc" == forgejo ]]; then
|
||||
# Legacy scripts dispatch both API-compatible providers as gitea.
|
||||
FORGE_PROVIDER="$rc"; PLATFORM=gitea
|
||||
export FORGE_PROVIDER PLATFORM
|
||||
echo gitea
|
||||
|
||||
# Check for common Gitea indicators
|
||||
# Gitea URLs typically don't contain github.com, gitlab.com, bitbucket.org
|
||||
if [[ "$remote_url" != *"gitlab.com"* ]] && \
|
||||
[[ "$remote_url" != *"bitbucket.org"* ]]; then
|
||||
# Assume Gitea for self-hosted repos
|
||||
PLATFORM="gitea"
|
||||
export PLATFORM
|
||||
echo "gitea"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -95,7 +87,7 @@ get_repo_slug() {
|
||||
gitea_url_matches_host() {
|
||||
local url="${1:-}" host="${2:-}"
|
||||
[[ -n "$url" && -n "$host" ]] || return 1
|
||||
python3 -I - "$url" "$host" <<'PY'
|
||||
python3 - "$url" "$host" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -164,7 +156,7 @@ find_tea_login_for_host() {
|
||||
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||
TEA_LOGINS_JSON="$logins_json" python3 -I - "$host" <<'PY'
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -234,7 +226,7 @@ tea_login_exists() {
|
||||
local logins_json
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" <<'PY_INNER'
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
|
||||
import json, os, sys
|
||||
want = sys.argv[1]
|
||||
try:
|
||||
@@ -254,7 +246,7 @@ tea_login_matches_host() {
|
||||
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||
TEA_LOGINS_JSON="$logins_json" python3 -I - "$login_name" "$host" <<'PY'
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -289,7 +281,7 @@ print_gitea_login_diagnostic() {
|
||||
available=$(
|
||||
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
|
||||
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
||||
import json, os
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
@@ -398,7 +390,7 @@ get_gitea_authenticated_user() {
|
||||
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
||||
TEA_AUTHENTICATED_USER_JSON="$response" python3 -I - <<'PY'
|
||||
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -420,7 +412,7 @@ get_default_tea_login() {
|
||||
|
||||
command -v tea >/dev/null 2>&1 || return 1
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||
TEA_LOGINS_JSON="$logins_json" python3 -I - <<'PY'
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -509,7 +501,7 @@ get_host_from_url() {
|
||||
local url="${1:-}"
|
||||
[[ -n "$url" ]] || return 1
|
||||
|
||||
python3 -I - "$url" <<'PY'
|
||||
python3 - "$url" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -539,7 +531,7 @@ get_gitea_repo_slug_for_url() {
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||
|
||||
if [[ "$remote_url" =~ ^https?:// ]]; then
|
||||
python3 -I - "$remote_url" "$configured_url" <<'PY'
|
||||
python3 - "$remote_url" "$configured_url" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -656,55 +648,160 @@ get_gitea_url_for_host() {
|
||||
}
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# Explicit identity and host-bound storage only. Disable tracing before handling
|
||||
# credentials; the subshell preserves the caller's xtrace state.
|
||||
get_gitea_token() (
|
||||
set +x
|
||||
local host="$1" _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
if [[ ! "$_ident" =~ ^[A-Za-z0-9_.-]+$ || ! "$host" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
|
||||
echo 'Error: explicit MOSAIC_GIT_IDENTITY and a valid host are required.' >&2
|
||||
return 1
|
||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||
get_gitea_token() {
|
||||
local host="$1"
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||
|
||||
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||
# stored credential for this host, act AS that agent so API tooling
|
||||
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||
# git credential helper, which this block deliberately mirrors.
|
||||
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
local _ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [[ -z "$_ident" ]]; then
|
||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
_ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
|
||||
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||
esac
|
||||
|
||||
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" _idtok
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
|
||||
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
|
||||
# Credential store selection: an identity is a SEAT or a SERVICE, and which
|
||||
# one it is decides where its credential lives. No precedence between the
|
||||
# two stores and no fallback from one to the other — a seat with an empty
|
||||
# slot fails loud rather than reading a service credential of the same name.
|
||||
# One credential, one location: two copies diverge, and the stale copy fails
|
||||
# in a way that reads as a revoked token rather than as drift.
|
||||
local _idtok _ident_kind
|
||||
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
|
||||
_ident_kind="seat"
|
||||
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
|
||||
else
|
||||
_ident_kind="service identity"
|
||||
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
fi
|
||||
else
|
||||
_idtok="$_brain_home/credentials/forge/$host/$_ident.token"
|
||||
fi
|
||||
if [[ ! -r "$_idtok" ]]; then
|
||||
echo 'Error: selected identity has no readable host-bound credential.' >&2
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||
# but no credential exists for THAT identity. Refuse to fall through to the
|
||||
# shared/default credential loader below — silently borrowing another identity's
|
||||
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
|
||||
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
|
||||
# instead so the caller aborts loudly rather than acting as the wrong identity.
|
||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
|
||||
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
|
||||
return 1
|
||||
fi
|
||||
cat "$_idtok"
|
||||
)
|
||||
|
||||
# Consume a credential inside an untraced shell function. Only an anonymous
|
||||
# config descriptor reaches the curl process; stdin remains the request body.
|
||||
# Callers must keep credential resolution and this call in their private boundary.
|
||||
gitea_curl_with_token() {
|
||||
set +x
|
||||
local token="$1" escaped auth_fd request_rc=0
|
||||
shift
|
||||
if [[ -z "$token" || ${#token} -gt 4096 || "$token" == *[[:cntrl:]]* ]]; then
|
||||
echo 'Error: invalid authentication credential.' >&2
|
||||
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
|
||||
# every record must name the agent that made it, so an unattributable request is
|
||||
# refused rather than handed the shared account. `fleet/agents` existing is the
|
||||
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
|
||||
# keeps the shared path below unchanged: there the shared account is the operator's
|
||||
# own and there is no attribution to lose.
|
||||
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
|
||||
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
|
||||
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
|
||||
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
|
||||
return 1
|
||||
fi
|
||||
escaped="${token//\\/\\\\}"
|
||||
escaped="${escaped//\"/\\\"}"
|
||||
exec {auth_fd}< <(printf 'header = "Authorization: token %s"\n' "$escaped")
|
||||
curl --config "/dev/fd/$auth_fd" "$@" || request_rc=$?
|
||||
exec {auth_fd}<&-
|
||||
return "$request_rc"
|
||||
|
||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
local token
|
||||
token=$(
|
||||
# shellcheck source=/dev/null
|
||||
source "$cred_loader"
|
||||
# Host-specific wrapper resolution must not inherit caller/global GITEA_*.
|
||||
# load_credentials intentionally preserves existing env vars for interactive use,
|
||||
# but metadata/merge wrappers need credentials matching the remote host.
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
case "$host" in
|
||||
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||
*)
|
||||
local matched=false
|
||||
for svc in gitea-mosaicstack gitea-usc; do
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
load_credentials "$svc" 2>/dev/null || continue
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$matched" != true ]]; then
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "${GITEA_TOKEN:-}"
|
||||
)
|
||||
if [[ -n "$token" ]]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||
echo "$GITEA_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. ~/.git-credentials file
|
||||
local creds="$HOME/.git-credentials"
|
||||
if [[ -f "$creds" ]]; then
|
||||
local token
|
||||
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
||||
if [[ -n "$token" ]]; then
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
|
||||
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
|
||||
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
|
||||
# for the lifetime of the request. Instead, write the header into a private
|
||||
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
|
||||
# only the FILE PATH — never the token — appears in argv. Prints the temp file
|
||||
# path on success; the caller OWNS the file and MUST remove it on every exit
|
||||
# path (success and failure). $1 = bearer token. Callers must not log the token.
|
||||
gitea_write_auth_config() {
|
||||
local token="$1" auth_file
|
||||
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
|
||||
# mktemp already creates the file with 0600; be explicit in case of an
|
||||
# unusual umask so the credential is never briefly group/other readable.
|
||||
chmod 600 "$auth_file" 2>/dev/null || true
|
||||
# `header = "..."` is curl's config syntax for an extra request header. Only
|
||||
# this filename reaches curl's argv; the token stays on disk, readable solely
|
||||
# by this user, and is unlinked by the caller's trap after the request.
|
||||
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
|
||||
rm -f "$auth_file"
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$auth_file"
|
||||
}
|
||||
|
||||
# Resolve the API token for a SPECIFIC tea login name from tea's own config
|
||||
@@ -731,7 +828,7 @@ get_gitea_token_for_login() {
|
||||
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
|
||||
[[ -f "$config_file" ]] || return 1
|
||||
|
||||
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 -I - "$config_file" <<'PY'
|
||||
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
@@ -1559,7 +1656,7 @@ get_gitea_basic_auth() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 -I - "$host" "$creds" <<'PY'
|
||||
python3 - "$host" "$creds" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Provision synthetic, actor/host-bound credentials for offline wrapper suites."""
|
||||
import json
|
||||
import re
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
home, brain, actor, host, token = sys.argv[1:]
|
||||
root = pathlib.Path(brain)
|
||||
p = root / 'credentials/forge' / host / (actor + '.token')
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(token)
|
||||
# Existing provider-specific stores derive their prefixes from configured
|
||||
# service keys. The fixture never imports an operator's credential loader.
|
||||
credentials = pathlib.Path(home) / '.config/mosaic/credentials.json'
|
||||
if credentials.is_file():
|
||||
for service, value in json.loads(credentials.read_text()).get('gitea', {}).items():
|
||||
if value.get('url', '').removeprefix('https://').rstrip('/') == host:
|
||||
p = pathlib.Path(home) / '.config/mosaic/secrets/gitea-tokens' / ('gitea-' + service + '-' + actor + '.token')
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(token)
|
||||
|
||||
# Provider store layout is an inherited compatibility contract. Reuse its
|
||||
# declared mapping without embedding deployment names in new fixture code.
|
||||
source=(pathlib.Path(__file__).parent/'detect-platform.sh').read_text()
|
||||
for mapped_host, prefix in re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',source,re.MULTILINE):
|
||||
if mapped_host == host:
|
||||
p=pathlib.Path(home)/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-'+actor+'.token')
|
||||
p.parent.mkdir(parents=True,exist_ok=True)
|
||||
p.write_text(token)
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture; all other network calls fail closed.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
printf 'Unexpected network request in offline suite\n' >&2
|
||||
exit 97
|
||||
@@ -1,860 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Provider adapters for the versioned forge wrapper contract.
|
||||
|
||||
Run with Python -I. No repository modules, provider code or fixture files are imported.
|
||||
All network operations have finite timeouts and pagination bounds. Writes are never retried.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
LIMIT = 100
|
||||
MAX_PAGES = 100
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
def __init__(self, code, kind, message, retry=None, outcome='none'):
|
||||
super().__init__(message)
|
||||
self.code, self.kind, self.retry, self.outcome = code, kind, retry, outcome
|
||||
|
||||
|
||||
def capabilities(provider, version):
|
||||
names = ('status', 'review_comments', 'review_reply', 'review_resolve', 'merge',
|
||||
'rebase_merge', 'fast_forward_only', 'auto_merge', 'merge_queue')
|
||||
result = {n: {'support': 'unknown', 'reason': 'No qualified API profile', 'schema_source': None} for n in names}
|
||||
def mark(names, support, reason, source):
|
||||
for n in names:
|
||||
result[n] = dict(support=support, reason=reason, schema_source=source)
|
||||
if provider == 'github':
|
||||
source = 'https://docs.github.com/en/graphql/reference/objects#pullrequest'
|
||||
mark(names, 'supported', 'Documented API; repository permissions and settings still apply', source)
|
||||
mark(('rebase_merge', 'fast_forward_only'), 'unsupported', 'GitHub merge API has no such method', 'https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request')
|
||||
elif (provider, version) in (('gitea', '28.2.0'), ('gitea', '1.24.7'), ('forgejo', '16.0.0-dev')):
|
||||
source = 'swagger.v1.json:' + provider + ':' + version
|
||||
mark(('status', 'review_comments', 'merge', 'rebase_merge', 'fast_forward_only'), 'supported', 'Qualified versioned schema', source)
|
||||
mark(('review_reply', 'review_resolve'), 'supported' if version == '28.2.0' else 'unsupported', 'Native conversation routes present' if version == '28.2.0' else 'No native conversation mutation routes in qualified schema', source)
|
||||
mark(('auto_merge', 'merge_queue'), 'unsupported', 'No documented schedule readback or native merge queue; raw scheduling/cancellation is not safely verifiable', source)
|
||||
return result
|
||||
|
||||
|
||||
def fingerprint(data):
|
||||
stable = {k: v for k, v in data.items() if k not in ('observed_at', 'fingerprint')}
|
||||
return hashlib.sha256(json.dumps(stable, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def checks(statuses, runs, required, allow_none=False):
|
||||
observed = {}
|
||||
for s in sorted(statuses, key=lambda s: s.get('id', 0)):
|
||||
name = s.get('context')
|
||||
if not name:
|
||||
raise Failure(6, 'malformed', 'Status context missing')
|
||||
observed[name] = s.get('state') if s.get('state') in ('success','failure','error','pending','cancelled') else 'unknown'
|
||||
for r in sorted(runs, key=lambda r: r.get('id', 0)):
|
||||
name = r.get('name')
|
||||
if not name:
|
||||
raise Failure(6, 'malformed', 'Check run name missing')
|
||||
conclusion = r.get('conclusion')
|
||||
observed[name] = ('pending' if r.get('status') != 'completed' else
|
||||
'success' if conclusion in ('success', 'neutral', 'skipped') else
|
||||
'cancelled' if conclusion == 'cancelled' else 'failure' if conclusion in ('failure', 'timed_out', 'action_required', 'startup_failure') else 'unknown')
|
||||
state = 'unknown'
|
||||
if required is not None:
|
||||
selected = [observed.get(n, 'missing') for n in required] if required else list(observed.values())
|
||||
if not selected:
|
||||
state = 'not_required' if allow_none and required == [] else 'missing'
|
||||
else:
|
||||
state = next((s for s in ('failure', 'error', 'cancelled', 'missing', 'unknown', 'pending', 'in_progress', 'queued') if s in selected), 'success')
|
||||
state = {'error': 'failure', 'in_progress': 'pending', 'queued': 'pending'}.get(state, state)
|
||||
return dict(state=state, required=required, observed=observed, complete=True)
|
||||
|
||||
|
||||
def reviews(items, head, author, required_count=1):
|
||||
latest = {}
|
||||
for r in sorted(items, key=lambda r: r.get('id', 0)):
|
||||
login = (r.get('user') or {}).get('login')
|
||||
state = str(r.get('state', '')).upper()
|
||||
if login and state not in ('COMMENTED', 'PENDING'):
|
||||
latest[login] = r
|
||||
rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values())
|
||||
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected
|
||||
return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved)
|
||||
|
||||
|
||||
def run(argv, data=None, cwd=None, env=None, pass_fds=()):
|
||||
try:
|
||||
return subprocess.run(argv, input=data, text=True, capture_output=True, timeout=45, cwd=cwd, env=env, pass_fds=pass_fds)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise Failure(75, 'transient', 'Provider client unavailable or timed out')
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host, repo):
|
||||
self.host, self.repo = host, repo
|
||||
self.provider, self.version, self.token = 'unknown', '', None
|
||||
self.write_attempted = False
|
||||
self.actor_verified = False
|
||||
self.root = '/repos/' + repo
|
||||
|
||||
def detect(self):
|
||||
if self.host == 'github.com' or self.host == os.environ.get('GH_HOST'):
|
||||
self.provider, self.version = 'github', 'documented'
|
||||
return
|
||||
schema = self.http('/swagger.v1.json', public=True)
|
||||
info = schema.get('info', {})
|
||||
title, version = str(info.get('title', '')).lower(), str(info.get('version', ''))
|
||||
if 'forgejo' in title or '+gitea-' in version:
|
||||
self.provider = 'forgejo'
|
||||
# This profile is the inspected 16.0.0 development schema only.
|
||||
self.version = '16.0.0-dev' if version.startswith('16.0.0-dev') else version
|
||||
elif 'gitea' in title:
|
||||
self.provider, self.version = 'gitea', version
|
||||
else:
|
||||
raise Failure(4, 'unsupported', 'Host metadata does not identify a qualified forge')
|
||||
|
||||
def credential(self):
|
||||
if self.token is None:
|
||||
# Fixed script/arguments; no user input becomes shell source.
|
||||
p = run(['bash', '-c', 'source "$1/detect-platform.sh"; get_gitea_token "$2"', 'forge-auth', str(HERE), self.host], cwd='/')
|
||||
if p.returncode or not p.stdout.strip():
|
||||
raise Failure(5, 'access', 'Acting identity has no credential for this host')
|
||||
self.token = p.stdout.strip()
|
||||
return self.token
|
||||
|
||||
def http(self, path, method='GET', body=None, public=False):
|
||||
writing = method != 'GET' and not (path == 'graphql' and body and body.get('query', '').lstrip().startswith('query'))
|
||||
if not public:
|
||||
actor = os.environ.get('MOSAIC_GIT_IDENTITY', '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+', actor):
|
||||
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required')
|
||||
token = self.credential()
|
||||
if writing and not self.actor_verified:
|
||||
user = self.http('/user')
|
||||
if not isinstance(user, dict) or user.get('login') != actor:
|
||||
raise Failure(5, 'access', 'Authenticated actor differs from explicit identity')
|
||||
self.actor_verified = True
|
||||
prefix = '/api/v3' if self.provider == 'github' and self.host != 'github.com' else '' if self.provider == 'github' else '/api/v1'
|
||||
route = ('/graphql' if self.host == 'github.com' else '/api/graphql') if path == 'graphql' else prefix + path
|
||||
authority = 'api.github.com' if self.provider == 'github' and self.host == 'github.com' and not public else self.host
|
||||
url = 'https://' + authority + (path if public else route)
|
||||
args = ['curl', '--silent', '--show-error', '--include', '--max-time', '30', '--request', method, '--header', 'Accept: application/json', '--header', 'User-Agent: mosaic-forge-wrapper']
|
||||
if body is not None:
|
||||
args += ['--header', 'Content-Type: application/json', '--data-binary', '@-']
|
||||
args += ['--url', url]
|
||||
if not public:
|
||||
if len(token) > 4096 or any(ord(c) < 32 or ord(c) == 127 for c in token):
|
||||
raise Failure(5, 'access', 'Credential contains invalid control characters')
|
||||
escaped = token.replace('\\', '\\\\').replace('"', '\\"')
|
||||
# Anonymous pipe: no credential pathname survives termination. The body
|
||||
# retains stdin, while curl reads authentication from the inherited fd.
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
os.write(write_fd, ('header = "Authorization: token ' + escaped + '"\n').encode())
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
self.auth_fd = read_fd
|
||||
p = self.transport(args + ['--config', '/dev/fd/' + str(read_fd)], body, writing)
|
||||
finally:
|
||||
self.auth_fd = None
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
else:
|
||||
p = self.transport(args, body, writing)
|
||||
if p.returncode:
|
||||
raise Failure(75, 'transient', 'Provider transport failed; reconcile before retrying a mutation', outcome='unknown' if writing else 'none')
|
||||
raw = p.stdout.replace('\r\n', '\n')
|
||||
# Proxies can emit an initial CONNECT response. Only consume header blocks.
|
||||
status, headers = None, {}
|
||||
while raw.startswith('HTTP/'):
|
||||
block, sep, raw = raw.partition('\n\n')
|
||||
if not sep:
|
||||
raise Failure(6, 'malformed', 'Provider response headers incomplete')
|
||||
lines = block.splitlines()
|
||||
try:
|
||||
status = int(lines[0].split()[1])
|
||||
except (ValueError, IndexError):
|
||||
raise Failure(6, 'malformed', 'Provider HTTP status malformed')
|
||||
headers = {k.lower().strip(): v.strip() for line in lines[1:] if ':' in line for k, v in [line.split(':', 1)]}
|
||||
if status is None:
|
||||
raise Failure(6, 'malformed', 'Provider did not return HTTP metadata')
|
||||
if status == 429 or status == 403 and (headers.get('x-ratelimit-remaining') == '0' or 'retry-after' in headers):
|
||||
retry = headers.get('retry-after')
|
||||
if retry is None:
|
||||
reset = headers.get('x-ratelimit-reset','')
|
||||
retry = str(max(1,int(reset)-int(time.time()))) if reset.isdigit() else '60'
|
||||
raise Failure(75, 'rate_limit', 'Provider rate limit; use a bounded later wake', int(retry) if retry.isdigit() else 60, 'unknown' if writing else 'none')
|
||||
if status in (401, 403, 404):
|
||||
raise Failure(5, 'access', 'Provider access denied or resource hidden (HTTP %s)' % status, outcome='unknown' if writing else 'none')
|
||||
if status >= 500:
|
||||
raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none')
|
||||
if not 200 <= status < 300:
|
||||
raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status)
|
||||
if not raw.strip() and status == 204:
|
||||
return None
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except ValueError:
|
||||
raise Failure(6, 'malformed', 'Provider returned invalid JSON', outcome='unknown' if writing else 'none')
|
||||
return result
|
||||
|
||||
def transport(self, args, body, writing):
|
||||
if writing:
|
||||
self.write_attempted = True
|
||||
try:
|
||||
return run(args, json.dumps(body) if body is not None else None, pass_fds=(self.auth_fd,) if getattr(self, 'auth_fd', None) is not None else ())
|
||||
except Failure as e:
|
||||
if writing:
|
||||
e.outcome = 'unknown'
|
||||
raise
|
||||
|
||||
def pages(self, path, key=None):
|
||||
result = []
|
||||
expected_total = 0
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
query = ('&' if '?' in path else '?') + ('per_page' if self.provider == 'github' else 'limit') + '=100&page=' + str(page)
|
||||
value = self.http(path + query)
|
||||
if key:
|
||||
total = value.get('total_count')
|
||||
if not isinstance(total,int) or isinstance(total,bool) or total < 0:
|
||||
raise Failure(6,'incomplete','Check-run total_count missing or invalid')
|
||||
expected_total = max(expected_total,total)
|
||||
value = value.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise Failure(6, 'malformed', 'Provider collection is not a list')
|
||||
result.extend(value)
|
||||
if len(value) < LIMIT:
|
||||
if len(result) < expected_total:
|
||||
raise Failure(6,'incomplete','Provider returned fewer records than total_count')
|
||||
return result
|
||||
raise Failure(6, 'incomplete', 'Pagination bound reached; collection incomplete')
|
||||
|
||||
def gql(self, query, variables):
|
||||
result = self.http('graphql', 'POST', {'query': query, 'variables': variables})
|
||||
if not isinstance(result, dict) or result.get('errors') or not isinstance(result.get('data'), dict):
|
||||
raise Failure(6, 'incomplete', 'GraphQL returned errors or incomplete data', outcome='unknown' if query.lstrip().startswith('mutation') else 'none')
|
||||
return result['data']
|
||||
|
||||
def pr(self, number):
|
||||
value = self.http(self.root + '/pulls/' + str(number))
|
||||
if not isinstance(value, dict) or value.get('number') != number or not re.fullmatch('[0-9a-fA-F]{40}', (value.get('head') or {}).get('sha', '')):
|
||||
raise Failure(6, 'malformed', 'PR identity or head SHA missing')
|
||||
return value
|
||||
|
||||
|
||||
def require(client, operation):
|
||||
cap = capabilities(client.provider, client.version)[operation]
|
||||
if cap['support'] != 'supported':
|
||||
raise Failure(4 if cap['support'] == 'unsupported' else 6, 'unsupported' if cap['support'] == 'unsupported' else 'incomplete', cap['reason'])
|
||||
|
||||
|
||||
def base_identity(p):
|
||||
b = p['base']
|
||||
h = p['head']
|
||||
return (b.get('ref'), b.get('sha'), (b.get('repo') or {}).get('full_name'), h.get('ref'), h.get('sha'), (h.get('repo') or {}).get('full_name'))
|
||||
|
||||
|
||||
def same_base(p, original):
|
||||
if base_identity(p) != base_identity(original):
|
||||
raise Failure(75, 'base_moved', 'PR target changed; discard policy and readiness')
|
||||
return p
|
||||
|
||||
|
||||
def same_head(client, number, expected):
|
||||
p = client.pr(number)
|
||||
if p['head']['sha'] != expected:
|
||||
raise Failure(75, 'head_moved', 'PR head changed; discard readiness and refresh review')
|
||||
return p
|
||||
|
||||
|
||||
def github_conversations(client, number):
|
||||
owner, repo = client.repo.split('/')
|
||||
cursor, seen, threads, integration = None, set(), [], None
|
||||
fields = '''id isResolved isOutdated comments(first:100) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } }'''
|
||||
for _ in range(MAX_PAGES):
|
||||
q = '''query($owner:String!,$repo:String!,$number:Int!,$cursor:String) {
|
||||
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
||||
id reviewDecision autoMergeRequest { mergeMethod } mergeQueueEntry { id }
|
||||
reviewThreads(first:100,after:$cursor) { nodes { ''' + fields + ''' } pageInfo { hasNextPage endCursor } }
|
||||
} } }'''
|
||||
pr = client.gql(q, dict(owner=owner, repo=repo, number=number, cursor=cursor))['repository']['pullRequest']
|
||||
if not isinstance(pr, dict) or not pr.get('id') or 'autoMergeRequest' not in pr or 'mergeQueueEntry' not in pr:
|
||||
raise Failure(6, 'incomplete', 'Missing integration state')
|
||||
auto, queue = pr['autoMergeRequest'], pr['mergeQueueEntry']
|
||||
if auto is not None and (not isinstance(auto, dict) or auto.get('mergeMethod') not in ('SQUASH', 'MERGE', 'REBASE')):
|
||||
raise Failure(6, 'malformed', 'Unknown auto-merge state')
|
||||
if queue is not None and (not isinstance(queue, dict) or not isinstance(queue.get('id'), str) or not queue['id']):
|
||||
raise Failure(6, 'malformed', 'Unknown merge-queue state')
|
||||
if integration is None:
|
||||
integration = dict(id=pr['id'], review_decision=pr.get('reviewDecision'), auto_merge={'state':'enabled' if pr['autoMergeRequest'] else 'disabled', 'method':((pr['autoMergeRequest'] or {}).get('mergeMethod') or '').lower() or None}, queue={'state':'queued' if pr['mergeQueueEntry'] else 'absent', 'entry_id':(pr['mergeQueueEntry'] or {}).get('id')})
|
||||
connection = pr['reviewThreads']
|
||||
for thread in connection['nodes']:
|
||||
if type(thread.get('isResolved')) is not bool:
|
||||
raise Failure(6, 'malformed', 'Review thread resolution must be boolean')
|
||||
comments = thread['comments']
|
||||
nodes = list(comments['nodes'])
|
||||
nested_seen = set()
|
||||
for _ in range(MAX_PAGES):
|
||||
if comments['pageInfo']['hasNextPage'] is False:
|
||||
break
|
||||
nested_cursor = comments['pageInfo']['endCursor']
|
||||
if not nested_cursor or nested_cursor in nested_seen:
|
||||
raise Failure(6, 'incomplete', 'Review comment cursor missing or repeated')
|
||||
nested_seen.add(nested_cursor)
|
||||
comments = client.gql('''query($id:ID!,$cursor:String!) { node(id:$id) { ... on PullRequestReviewThread { comments(first:100,after:$cursor) { nodes { databaseId id replyTo { databaseId } } pageInfo { hasNextPage endCursor } } } } }''', {'id':thread['id'], 'cursor':nested_cursor})['node']['comments']
|
||||
nodes.extend(comments['nodes'])
|
||||
else:
|
||||
raise Failure(6, 'incomplete', 'Nested comment pagination bound reached')
|
||||
thread['comments'] = nodes
|
||||
threads.append(thread)
|
||||
if connection['pageInfo']['hasNextPage'] is False:
|
||||
return threads, integration
|
||||
cursor = connection['pageInfo']['endCursor']
|
||||
if not cursor or cursor in seen:
|
||||
raise Failure(6, 'incomplete', 'Review thread cursor missing or repeated')
|
||||
seen.add(cursor)
|
||||
raise Failure(6, 'incomplete', 'Review thread pagination bound reached')
|
||||
|
||||
|
||||
def review_comments(client, number):
|
||||
require(client, 'review_comments')
|
||||
root = client.root + '/pulls/' + str(number)
|
||||
revs = client.pages(root + '/reviews')
|
||||
comments, threads, integration = [], [], None
|
||||
if client.provider == 'github':
|
||||
raw = client.pages(root + '/comments')
|
||||
threads, integration = github_conversations(client, number)
|
||||
mapping = {}
|
||||
for t in threads:
|
||||
for c in t['comments']:
|
||||
cid = c.get('databaseId')
|
||||
if cid is None or cid in mapping:
|
||||
raise Failure(6, 'incomplete', 'Thread comment identity missing or duplicated')
|
||||
mapping[cid] = t
|
||||
for c in raw:
|
||||
if c.get('id') not in mapping:
|
||||
raise Failure(6, 'incomplete', 'REST comment absent from authoritative thread graph')
|
||||
if set(mapping) != {c.get('id') for c in raw}:
|
||||
raise Failure(6, 'incomplete', 'Comment collections disagree')
|
||||
else:
|
||||
raw = []
|
||||
mapping = {}
|
||||
for r in revs:
|
||||
if r.get('id') is None:
|
||||
raise Failure(6, 'malformed', 'Review ID missing')
|
||||
page = client.pages(root + '/reviews/' + str(r['id']) + '/comments')
|
||||
for c in page:
|
||||
c = dict(c, pull_request_review_id=r['id'])
|
||||
raw.append(c)
|
||||
seen = set()
|
||||
for c in raw:
|
||||
cid = c.get('id')
|
||||
if cid is None or cid in seen:
|
||||
raise Failure(6, 'incomplete', 'Comment ID missing or duplicated')
|
||||
seen.add(cid)
|
||||
t = mapping.get(cid)
|
||||
parent = c.get('in_reply_to_id')
|
||||
# A resolver proves a resolved comment; it does not prove a thread graph.
|
||||
resolved = t['isResolved'] if t else bool(c.get('resolver')) if 'resolver' in c else None
|
||||
comments.append(dict(id=f'{client.provider}:comment:{cid}', review_id=c.get('pull_request_review_id'), thread_id=f"github:thread:{t['id']}" if t else None, reply_to=f'{client.provider}:comment:{parent}' if parent else None, author=(c.get('user') or {}).get('login'), body=c.get('body'), path=c.get('path'), line=c.get('line') or c.get('position') or c.get('new_position'), commit_sha=c.get('commit_id'), outdated=t['isOutdated'] if t else c.get('outdated'), resolved=resolved, url=c.get('html_url') or c.get('url')))
|
||||
return dict(complete=True, reviews=revs, comments=comments), threads, integration
|
||||
|
||||
|
||||
def protection(client, base):
|
||||
if client.provider == 'github':
|
||||
p = client.http(client.root + '/branches/' + urllib.parse.quote(base, safe='') + '/protection')
|
||||
status = p.get('required_status_checks')
|
||||
required = sorted(set((status or {}).get('contexts', []) + [c['context'] for c in (status or {}).get('checks', [])])) if status is not None else []
|
||||
review = p.get('required_pull_request_reviews') or {}
|
||||
client.review_count = review.get('required_approving_review_count', 1)
|
||||
client.codeowners_required = review.get('require_code_owner_reviews') is True
|
||||
enforced = bool(required and review.get('required_approving_review_count', 0) > 0 and review.get('dismiss_stale_reviews') is True and (p.get('enforce_admins') or {}).get('enabled') is True)
|
||||
if status is not None and any(c.get('app_id') not in (None,-1) for c in status.get('checks',[])):
|
||||
# App-bound contexts need source-specific matching; unknown never clears a gate.
|
||||
required = None
|
||||
return required, enforced
|
||||
rules = client.pages(client.root + '/branch_protections')
|
||||
matching = [r for r in rules if r.get('branch_name') == base]
|
||||
if len(matching) != 1:
|
||||
return None, False
|
||||
r = matching[0]
|
||||
client.review_count = r.get('required_approvals',1)
|
||||
return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False
|
||||
|
||||
|
||||
def snapshot(client, number, expected):
|
||||
require(client, 'status')
|
||||
p = client.pr(number)
|
||||
head = p['head']['sha']
|
||||
if expected and head != expected:
|
||||
raise Failure(3, 'gate', 'Expected head differs from PR head')
|
||||
base = p['base']
|
||||
required, enforced = protection(client, base['ref'])
|
||||
root = client.root
|
||||
statuses = client.pages(root + '/commits/' + head + '/statuses')
|
||||
runs = client.pages(root + '/commits/' + head + '/check-runs', 'check_runs') if client.provider == 'github' else []
|
||||
check = checks(statuses, runs, required)
|
||||
comments, threads, integration = review_comments(client, number)
|
||||
issue_comments = client.pages(root + '/issues/' + str(number) + '/comments')
|
||||
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1))
|
||||
if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED':
|
||||
review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None)
|
||||
unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None
|
||||
thread_state = dict(unresolved=unresolved, complete=comments['complete'] and unresolved is not None)
|
||||
state = 'merged' if p.get('merged') is True else p['state']
|
||||
if state not in ('open','closed','merged'):
|
||||
raise Failure(6,'malformed','Provider PR state is unknown')
|
||||
mergeability = 'conflict' if p.get('mergeable') is False else 'clean' if p.get('mergeable') is True else 'unknown'
|
||||
blockers = []
|
||||
if not re.fullmatch('[0-9a-fA-F]{40}',base.get('sha','')):
|
||||
blockers.append('base_sha_unknown')
|
||||
if not (p['head'].get('repo') or {}).get('full_name') or not (base.get('repo') or {}).get('full_name'):
|
||||
blockers.append('repository_unknown')
|
||||
if state != 'open':
|
||||
blockers.append(state)
|
||||
if p.get('draft') is not False:
|
||||
blockers.append('draft_or_unknown')
|
||||
if check['state'] not in ('success', 'not_required'):
|
||||
blockers.append('checks_' + check['state'])
|
||||
if not review['approved']:
|
||||
blockers.append('review_' + review['decision'])
|
||||
if unresolved != 0:
|
||||
blockers.append('review_threads_unresolved_or_unknown')
|
||||
if mergeability != 'clean':
|
||||
blockers.append('mergeability_' + mergeability)
|
||||
data = dict(url=p.get('html_url'), state=state, draft=p.get('draft'), head_ref=p['head'].get('ref'), head_sha=head, base_ref=base.get('ref'), base_sha=base.get('sha'), head_repository=(p['head'].get('repo') or {}).get('full_name'), base_repository=(base.get('repo') or {}).get('full_name'), merge_sha=p.get('merge_commit_sha') if state == 'merged' else None, mergeability=mergeability, checks=check, reviews=review, threads=thread_state, auto_merge=integration['auto_merge'] if integration else {'state':'unsupported'}, queue=integration['queue'] if integration else {'state':'unsupported'}, capabilities=capabilities(client.provider, client.version), ready=not blockers, blockers=blockers, comments=comments['comments'], review_records=comments['reviews'], issue_comments=issue_comments, protection_enforced=enforced, observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
||||
after = same_head(client, number, head)
|
||||
same_base(after, p)
|
||||
if after.get('state') != p.get('state') or after.get('merged') != p.get('merged'):
|
||||
raise Failure(75, 'snapshot_changed', 'PR base or closure changed during aggregation')
|
||||
data['fingerprint'] = fingerprint(data)
|
||||
return data
|
||||
|
||||
|
||||
def owned_comment(client, number, opaque):
|
||||
data, threads, integration = review_comments(client, number)
|
||||
matches = [c for c in data['comments'] if c['id'] == opaque]
|
||||
if len(matches) != 1:
|
||||
raise Failure(3, 'gate', 'Comment is not uniquely owned by the requested PR')
|
||||
return matches[0]
|
||||
|
||||
|
||||
def reply(client, number, opaque, body, expected):
|
||||
require(client, 'review_reply')
|
||||
same_head(client, number, expected)
|
||||
target = owned_comment(client, number, opaque)
|
||||
if not re.fullmatch(re.escape(client.provider) + r':comment:[1-9][0-9]*', opaque):
|
||||
raise Failure(2, 'usage', 'Use the provider-qualified comment ID from pr-review-comments')
|
||||
actor = client.http('/user').get('login')
|
||||
if not actor:
|
||||
raise Failure(5, 'access', 'Acting user could not be verified')
|
||||
same_head(client, number, expected)
|
||||
cid = opaque.rsplit(':', 1)[1]
|
||||
path = client.root + '/pulls/' + str(number) + '/comments/' + cid + '/replies'
|
||||
created = client.http(path, 'POST', {'body':body})
|
||||
if not isinstance(created, dict) or not isinstance(created.get('id'), int):
|
||||
raise Failure(6, 'verification', 'Reply response has no created ID; reconcile before retry', outcome='unknown')
|
||||
new_id = f"{client.provider}:comment:{created['id']}"
|
||||
try:
|
||||
if client.provider == 'github':
|
||||
item = client.http(client.root + '/pulls/comments/' + str(created['id']))
|
||||
expected_url = '/repos/' + client.repo + '/pulls/' + str(number)
|
||||
parsed = urllib.parse.urlparse(item.get('pull_request_url', ''))
|
||||
authority = urllib.parse.urlparse('https://' + ('api.github.com' if client.host == 'github.com' else client.host))
|
||||
actual_path = parsed.path.removeprefix('/api/v3') if client.host != 'github.com' else parsed.path
|
||||
if actual_path != expected_url or parsed.scheme != 'https' or parsed.hostname != authority.hostname or (parsed.port or 443) != (authority.port or 443) or parsed.username or parsed.password or item.get('in_reply_to_id') != int(cid):
|
||||
raise Failure(6, 'verification', 'Reply repository or parent readback differs', outcome='unknown')
|
||||
actual = {'id':f"github:comment:{item.get('id')}", 'body':item.get('body'), 'author':(item.get('user') or {}).get('login'), 'commit_sha':item.get('commit_id')}
|
||||
else:
|
||||
actual = owned_comment(client, number, new_id)
|
||||
if actual.get('id') != new_id or actual.get('body') != body or actual.get('author') != actor or target.get('commit_sha') and actual.get('commit_sha') != target['commit_sha']:
|
||||
raise Failure(6, 'verification', 'Created reply identity, author, body or commit readback differs', outcome='unknown')
|
||||
same_head(client, number, expected)
|
||||
except Failure as e:
|
||||
e.outcome = 'unknown'
|
||||
raise
|
||||
return dict(id=new_id, reply_to=opaque, thread_id=target.get('thread_id'), verified=True, head_sha=expected)
|
||||
|
||||
|
||||
def resolve(client, number, opaque, desired, expected):
|
||||
require(client, 'review_resolve')
|
||||
same_head(client, number, expected)
|
||||
data, threads, integration = review_comments(client, number)
|
||||
if client.provider == 'github':
|
||||
matches = [t for t in threads if 'github:thread:' + t['id'] == opaque]
|
||||
if len(matches) != 1:
|
||||
raise Failure(3, 'gate', 'Thread is not uniquely owned by the requested PR')
|
||||
target = matches[0]
|
||||
if target.get('isResolved') is desired:
|
||||
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
|
||||
same_head(client, number, expected)
|
||||
mutation = 'resolveReviewThread' if desired else 'unresolveReviewThread'
|
||||
client.gql('mutation($id:ID!) { ' + mutation + '(input:{threadId:$id}) { thread { id isResolved } } }', {'id':target['id']})
|
||||
else:
|
||||
# This API resolves a review comment. Its ID is a provider selector,
|
||||
# never an invented path/line-based thread graph.
|
||||
matches = [c for c in data['comments'] if c['id'] == opaque]
|
||||
if len(matches) != 1 or not re.fullmatch('gitea:comment:[1-9][0-9]*', opaque):
|
||||
raise Failure(3, 'gate', 'Resolution selector is not owned by this PR')
|
||||
if matches[0]['resolved'] is desired:
|
||||
return dict(thread_id=opaque, resolved=desired, verified=True, changed=False)
|
||||
same_head(client, number, expected)
|
||||
client.http(client.root + '/pulls/comments/' + opaque.rsplit(':',1)[1] + ('/resolve' if desired else '/unresolve'), 'POST')
|
||||
try:
|
||||
new_data, new_threads, _ = review_comments(client, number)
|
||||
states = [t['isResolved'] for t in new_threads if 'github:thread:' + t['id'] == opaque] if client.provider == 'github' else [c['resolved'] for c in new_data['comments'] if c['id'] == opaque]
|
||||
if states != [desired]:
|
||||
raise Failure(6, 'verification', 'Resolution readback does not prove requested state', outcome='unknown')
|
||||
same_head(client, number, expected)
|
||||
except Failure as e:
|
||||
e.outcome = 'unknown'
|
||||
raise
|
||||
return dict(thread_id=opaque, resolved=desired, verified=True, changed=True)
|
||||
|
||||
|
||||
def forge_method(client, method):
|
||||
if method not in ('squash','merge','rebase','rebase-merge','fast-forward-only'):
|
||||
raise Failure(2, 'usage', 'Unknown merge method')
|
||||
if client.provider == 'github' and method in ('rebase-merge','fast-forward-only'):
|
||||
raise Failure(4, 'unsupported', 'GitHub has no ' + method + ' merge method')
|
||||
settings = client.http(client.root)
|
||||
key = ({'squash':'allow_squash_merge','merge':'allow_merge_commit','rebase':'allow_rebase_merge'} if client.provider == 'github' else {'squash':'allow_squash_merge','merge':'allow_merge_commits','rebase':'allow_rebase','rebase-merge':'allow_rebase_explicit','fast-forward-only':'allow_fast_forward_only_merge'})[method]
|
||||
if settings.get(key) is False:
|
||||
raise Failure(4, 'unsupported', 'Forge repository setting ' + key + ' disables method')
|
||||
if settings.get(key) is not True:
|
||||
raise Failure(6, 'incomplete', 'Forge repository setting ' + key + ' could not be verified')
|
||||
return settings
|
||||
|
||||
|
||||
def declared_method(client, p, requested):
|
||||
root = run(['git','rev-parse','--show-toplevel'])
|
||||
if root.returncode:
|
||||
raise Failure(3,'policy','POLICY-REFUSED: mutation requires a local repository declaration context')
|
||||
origin = run(['git','remote','get-url','origin']).stdout.strip()
|
||||
parsed = urllib.parse.urlparse(origin if not origin.startswith('git@') else 'ssh://' + origin.replace(':','/',1))
|
||||
if parsed.hostname != client.host.split(':')[0] or parsed.path.strip('/').removesuffix('.git') != client.repo:
|
||||
raise Failure(3,'policy','POLICY-REFUSED: explicit target differs from local origin')
|
||||
base = p['base']
|
||||
if (base.get('repo') or {}).get('full_name') != client.repo or not re.fullmatch('[0-9a-fA-F]{40}', base.get('sha', '')):
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: trusted target revision unavailable')
|
||||
revision = base['sha']
|
||||
if run(['git', 'cat-file', '-e', revision + '^{commit}']).returncode:
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: fetch the pinned target revision before merging')
|
||||
listing = run(['git', 'ls-tree', revision, '--', '.mosaic/repo.json'])
|
||||
if listing.returncode:
|
||||
raise Failure(6, 'verification', 'Cannot inspect pinned target declaration')
|
||||
if not listing.stdout.strip():
|
||||
if requested not in (None, 'squash'):
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: absent merge policy permits squash only')
|
||||
if base['ref'] != client.http(client.root).get('default_branch'):
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: absent declaration requires the repository default base')
|
||||
return 'squash'
|
||||
content = run(['git', 'show', revision + ':.mosaic/repo.json'])
|
||||
if content.returncode:
|
||||
raise Failure(6, 'verification', 'Cannot read pinned target declaration')
|
||||
# This temporary file contains public policy, never authentication material.
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
declaration = pathlib.Path(directory) / 'repo.json'
|
||||
declaration.write_text(content.stdout)
|
||||
validator = run([str(HERE.parent/'structure/validate-repo-json.sh'), str(declaration), '--mode', 'display'])
|
||||
if validator.returncode:
|
||||
raise Failure(6, 'malformed', 'Invalid target repository declaration: ' + validator.stdout.strip())
|
||||
doc = json.loads(content.stdout)
|
||||
canonical = doc.get('canonical_remote', '').removesuffix('.git').rstrip('/')
|
||||
normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', canonical])
|
||||
origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin])
|
||||
if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout:
|
||||
raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin')
|
||||
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']]
|
||||
if requested:
|
||||
args += ['--method', requested]
|
||||
result = run(args)
|
||||
value = json.loads(result.stdout)
|
||||
if result.returncode:
|
||||
raise Failure(result.returncode, 'policy' if result.returncode == 3 else 'malformed', value.get('error', 'Merge policy refused'))
|
||||
return value['method']
|
||||
|
||||
|
||||
def queue_guard(client, p):
|
||||
# This is the CI push/merge guard, never GitHub's PR merge queue.
|
||||
git_dir = run(['git','rev-parse','--absolute-git-dir'])
|
||||
work_tree = run(['git','rev-parse','--show-toplevel'])
|
||||
if git_dir.returncode or work_tree.returncode:
|
||||
raise Failure(3,'gate','CI admission needs the verified local repository context')
|
||||
guard_env = dict(os.environ,GIT_DIR=git_dir.stdout.strip(),GIT_WORK_TREE=work_tree.stdout.strip())
|
||||
result = run([str(HERE/'ci-queue-wait.sh'),'--purpose','merge','--repo',client.repo,'--sha',p['head']['sha'],'-B',p['head']['ref'],'-t','1','-i','1'],cwd='/',env=guard_env)
|
||||
if result.returncode:
|
||||
mapping = {75:75, 78:3, 124:124}
|
||||
raise Failure(mapping.get(result.returncode,3),'gate','CI queue guard refused (legacy exit %s)' % result.returncode)
|
||||
|
||||
|
||||
def merge(client, number, requested, expected):
|
||||
require(client, 'merge')
|
||||
p = same_head(client,number,expected)
|
||||
method = declared_method(client,p,requested)
|
||||
forge_method(client,method)
|
||||
status = snapshot(client,number,expected)
|
||||
if (status['base_ref'], status['base_sha'], status['base_repository'], status['head_ref'], status['head_sha'], status['head_repository']) != base_identity(p):
|
||||
raise Failure(75, 'base_moved', 'PR identity changed after policy evaluation')
|
||||
if not status['ready']:
|
||||
raise Failure(3,'gate','Merge readiness failed: ' + ', '.join(status['blockers']))
|
||||
queue_guard(client,p)
|
||||
same_base(same_head(client,number,expected), p)
|
||||
if client.provider == 'github':
|
||||
client.http(client.root+'/pulls/'+str(number)+'/merge','PUT',{'merge_method':method,'sha':expected})
|
||||
else:
|
||||
key = 'do' if client.version == '28.2.0' else 'Do'
|
||||
client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected})
|
||||
try:
|
||||
after = same_base(same_head(client,number,expected), p)
|
||||
if after.get('merged') is not True or not after.get('merge_commit_sha'):
|
||||
raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown')
|
||||
except Failure as e:
|
||||
e.outcome='unknown'
|
||||
raise
|
||||
return dict(requested_method=requested, effective_method=method, merged=True, merge_sha=after['merge_commit_sha'],head_sha=expected,base_ref=after['base']['ref'])
|
||||
|
||||
|
||||
def integration(client, number, operation, action, requested, expected):
|
||||
require(client,operation)
|
||||
if action not in (('inspect','enable','disable') if operation == 'auto_merge' else ('inspect','enqueue','dequeue')):
|
||||
raise Failure(2,'usage','Invalid action for integration operation')
|
||||
if action != 'inspect' and not expected:
|
||||
raise Failure(2,'usage','Mutation requires --expect-head')
|
||||
p = same_head(client,number,expected) if expected else client.pr(number)
|
||||
_, state = github_conversations(client,number)
|
||||
key = 'auto_merge' if operation == 'auto_merge' else 'queue'
|
||||
before = state[key]
|
||||
if action == 'inspect':
|
||||
same_base(same_head(client,number,p['head']['sha']), p)
|
||||
return dict(supported=True,state=before['state'],method=before.get('method'),entry_id=before.get('entry_id'),expected_head=expected,head_sha=p['head']['sha'],verified=True)
|
||||
enabling = action in ('enable','enqueue')
|
||||
method = before.get('method')
|
||||
if enabling:
|
||||
method = declared_method(client,p,requested)
|
||||
settings = forge_method(client,method)
|
||||
_, enforced = protection(client,p['base']['ref'])
|
||||
if not enforced:
|
||||
raise Failure(3,'gate','Scheduling requires enforced checks, independent reviews, stale approval dismissal and no administrator bypass')
|
||||
if p.get('state') != 'open' or p.get('draft') is not False:
|
||||
raise Failure(3,'gate','Scheduling requires an open non-draft PR')
|
||||
owner, repo = client.repo.split('/')
|
||||
queue = client.gql('query($owner:String!,$repo:String!,$branch:String!) { repository(owner:$owner,name:$repo) { mergeQueue(branch:$branch) { configuration { mergeMethod } } } }',{'owner':owner,'repo':repo,'branch':p['base']['ref']})['repository']['mergeQueue']
|
||||
queue_method = ((queue or {}).get('configuration') or {}).get('mergeMethod')
|
||||
if action == 'enqueue' and not queue_method:
|
||||
raise Failure(4,'unsupported','No configured merge queue method available')
|
||||
if queue_method and queue_method.lower() != method:
|
||||
raise Failure(3,'policy','POLICY-REFUSED: queue effective method conflicts with requested/declaration method')
|
||||
if action == 'enable' and settings.get('allow_auto_merge') is not True:
|
||||
raise Failure(4,'unsupported','Repository allow_auto_merge is disabled or unknown')
|
||||
queue_guard(client,p)
|
||||
same_base(same_head(client,number,expected), p)
|
||||
variables = {'id':state['id']}
|
||||
if action == 'enable':
|
||||
query = 'mutation($id:ID!,$head:GitObjectID!,$method:PullRequestMergeMethod!) { enablePullRequestAutoMerge(input:{pullRequestId:$id,expectedHeadOid:$head,mergeMethod:$method}) { pullRequest { id } } }'
|
||||
variables.update(head=expected,method=method.upper())
|
||||
elif action == 'disable':
|
||||
query = 'mutation($id:ID!) { disablePullRequestAutoMerge(input:{pullRequestId:$id}) { pullRequest { id } } }'
|
||||
elif action == 'enqueue':
|
||||
query = 'mutation($id:ID!,$head:GitObjectID!) { enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head}) { mergeQueueEntry { id } } }'
|
||||
variables['head']=expected
|
||||
else:
|
||||
query = 'mutation($id:ID!) { dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }'
|
||||
client.gql(query,variables)
|
||||
try:
|
||||
after = same_base(same_head(client,number,expected), p)
|
||||
if after.get('merged') is True:
|
||||
if not after.get('merge_commit_sha'):
|
||||
raise Failure(6,'verification','Merged PR lacks merge SHA',outcome='unknown')
|
||||
return dict(supported=True,state='merged',method=method,entry_id=None,expected_head=expected,verified=True,merge_sha=after['merge_commit_sha'])
|
||||
_, readback = github_conversations(client,number)
|
||||
desired = ('enabled' if enabling else 'disabled') if operation == 'auto_merge' else ('queued' if enabling else 'absent')
|
||||
actual = readback[key]
|
||||
if actual['state'] != desired or enabling and operation == 'auto_merge' and actual.get('method','').lower() != method or desired == 'queued' and not actual.get('entry_id'):
|
||||
raise Failure(6,'verification','Integration readback does not prove requested state',outcome='unknown')
|
||||
same_base(same_head(client,number,expected), p)
|
||||
except Failure as e:
|
||||
e.outcome='unknown'
|
||||
raise
|
||||
return dict(supported=True,state=desired,method=method,entry_id=actual.get('entry_id'),expected_head=expected,verified=True)
|
||||
|
||||
|
||||
def workflow_verdict(data, mode):
|
||||
if data.get('state') == 'merged':
|
||||
return 'merged'
|
||||
if data.get('state') == 'closed':
|
||||
return 'closed_unmerged'
|
||||
if mode == 'full-stop' and (data.get('auto_merge',{}).get('state') != 'disabled' or data.get('queue',{}).get('state') != 'absent'):
|
||||
return 'blocked'
|
||||
if data.get('reviews',{}).get('decision') in ('changes_requested','codeowner_approval_unknown'):
|
||||
return 'blocked'
|
||||
if data.get('checks',{}).get('state') in ('failure','cancelled','unknown','missing'):
|
||||
return 'blocked'
|
||||
if data.get('ready') is True:
|
||||
return 'ready_to_merge'
|
||||
if data.get('mergeability') in ('conflict','unknown') or any(b.startswith('review_threads') or 'unknown' in b for b in data.get('blockers',[])):
|
||||
return 'blocked'
|
||||
return 'waiting'
|
||||
|
||||
|
||||
def ci_wait(client, number, expected, timeout, interval):
|
||||
if timeout <= 0 or interval <= 0:
|
||||
raise Failure(2,'usage','Timeout and interval must be positive')
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
data = snapshot(client,number,expected)
|
||||
state = data['checks']['state']
|
||||
if state in ('success','not_required'):
|
||||
return data
|
||||
if state in ('failure','cancelled'):
|
||||
raise Failure(3,'gate','CI reached terminal ' + state)
|
||||
if time.monotonic() >= deadline:
|
||||
raise Failure(124,'timeout','CI wait timed out')
|
||||
time.sleep(min(interval,max(0,deadline-time.monotonic())))
|
||||
|
||||
|
||||
def main():
|
||||
class Parser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
print(message, file=sys.stderr)
|
||||
print(json.dumps(dict(schema_version=1,operation=sys.argv[1] if len(sys.argv)>1 else 'unknown',provider='unknown',host=None,repo=None,number=None,head_sha=None,data=None,error=dict(kind='usage',message=message,retry_after_seconds=None,write_outcome='none'))))
|
||||
raise SystemExit(2)
|
||||
parser = Parser(description='Versioned forge adapter. See README for provider limitations.')
|
||||
parser.add_argument('operation', choices=['capabilities', 'status', 'watch-check', 'review-comments', 'review-reply', 'review-resolve', 'merge', 'auto-merge', 'merge-queue', 'ci-wait'])
|
||||
parser.add_argument('-n', '--number', type=int)
|
||||
parser.add_argument('-r', '--repo')
|
||||
parser.add_argument('--host')
|
||||
parser.add_argument('--json', action='store_true')
|
||||
parser.add_argument('--operation', dest='requested_operation')
|
||||
parser.add_argument('--expect-head')
|
||||
parser.add_argument('--baseline')
|
||||
parser.add_argument('-m', '--method')
|
||||
parser.add_argument('--action')
|
||||
parser.add_argument('-t','--timeout',type=int,default=1800)
|
||||
parser.add_argument('-i','--interval',type=int,default=15)
|
||||
parser.add_argument('--comment-id')
|
||||
parser.add_argument('--thread-id')
|
||||
parser.add_argument('--body-file')
|
||||
resolution = parser.add_mutually_exclusive_group()
|
||||
resolution.add_argument('--resolve', action='store_true')
|
||||
resolution.add_argument('--unresolve', action='store_true')
|
||||
args = parser.parse_args()
|
||||
client, data, head, error, code = None, None, None, None, 0
|
||||
try:
|
||||
if args.number is not None and args.number < 1:
|
||||
raise Failure(2, 'usage', 'PR number must be positive')
|
||||
if args.operation != 'capabilities' and not args.number:
|
||||
raise Failure(2, 'usage', '-n NUMBER is required')
|
||||
if args.expect_head and not re.fullmatch('[0-9a-fA-F]{40}', args.expect_head):
|
||||
raise Failure(2, 'usage', '--expect-head requires a full SHA')
|
||||
if args.operation in ('review-reply','review-resolve','merge') and not args.expect_head:
|
||||
raise Failure(2,'usage','Mutation requires --expect-head')
|
||||
if args.operation == 'review-reply' and (not args.comment_id or not args.body_file or not pathlib.Path(args.body_file).is_file()):
|
||||
raise Failure(2,'usage','Reply requires --comment-id and a readable --body-file')
|
||||
if args.operation == 'review-resolve' and (not args.thread_id or not (args.resolve or args.unresolve)):
|
||||
raise Failure(2,'usage','Resolution requires --thread-id and --resolve or --unresolve')
|
||||
if args.operation in ('auto-merge','merge-queue') and args.action not in (('inspect','enable','disable') if args.operation == 'auto-merge' else ('inspect','enqueue','dequeue')):
|
||||
raise Failure(2,'usage','Explicit --action is required')
|
||||
if args.operation in ('auto-merge','merge-queue') and args.action != 'inspect' and not args.expect_head:
|
||||
raise Failure(2,'usage','Mutation requires --expect-head')
|
||||
if args.operation == 'watch-check' and (not args.baseline or not os.path.isabs(args.baseline)):
|
||||
raise Failure(2,'usage','--baseline requires an absolute path')
|
||||
if not args.host or not args.repo:
|
||||
p = run(['git', 'remote', 'get-url', 'origin'])
|
||||
remote = p.stdout.strip()
|
||||
if remote.startswith('git@'):
|
||||
remote = 'ssh://' + remote.replace(':', '/', 1)
|
||||
parsed = urllib.parse.urlparse(remote)
|
||||
args.host = args.host or parsed.hostname
|
||||
args.repo = args.repo or parsed.path.strip('/').removesuffix('.git')
|
||||
if args.host:
|
||||
args.host = args.host.lower()
|
||||
if not args.host or not re.fullmatch(r'[A-Za-z0-9.-]+(?::[0-9]+)?', args.host) or not args.repo or not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repo) or any(part in ('.','..') for part in args.repo.split('/')):
|
||||
raise Failure(2, 'usage', 'Use an unambiguous origin or --host HOST --repo OWNER/REPO')
|
||||
if args.operation != 'capabilities' and not re.fullmatch(r'[A-Za-z0-9_.-]+', os.environ.get('MOSAIC_GIT_IDENTITY', '')):
|
||||
raise Failure(5, 'access', 'Explicit MOSAIC_GIT_IDENTITY is required before provider requests')
|
||||
client = Client(args.host, args.repo)
|
||||
client.detect()
|
||||
if args.operation == 'capabilities':
|
||||
data = {'version': client.version, 'operations': capabilities(client.provider, client.version)}
|
||||
if args.requested_operation:
|
||||
if args.requested_operation not in data['operations']:
|
||||
raise Failure(2, 'usage', 'Unknown capability operation')
|
||||
require(client, args.requested_operation)
|
||||
elif args.operation == 'ci-wait':
|
||||
data = ci_wait(client,args.number,args.expect_head,args.timeout,args.interval)
|
||||
head = data['head_sha']
|
||||
elif args.operation in ('merge','auto-merge','merge-queue'):
|
||||
if args.operation == 'merge':
|
||||
if not args.expect_head:
|
||||
raise Failure(2,'usage','Merge requires --expect-head')
|
||||
data = merge(client,args.number,args.method,args.expect_head)
|
||||
else:
|
||||
data = integration(client,args.number,'auto_merge' if args.operation == 'auto-merge' else 'merge_queue',args.action,args.method,args.expect_head)
|
||||
head = data.get('head_sha') or args.expect_head
|
||||
elif args.operation == 'review-comments':
|
||||
before = client.pr(args.number)['head']['sha']
|
||||
data, _, _ = review_comments(client, args.number)
|
||||
same_head(client, args.number, before)
|
||||
head = before
|
||||
elif args.operation in ('review-reply', 'review-resolve'):
|
||||
if not args.expect_head:
|
||||
raise Failure(2, 'usage', 'Mutation requires --expect-head')
|
||||
head = args.expect_head
|
||||
if args.operation == 'review-reply':
|
||||
if not args.comment_id or not args.body_file:
|
||||
raise Failure(2, 'usage', 'Reply requires --comment-id and --body-file')
|
||||
try:
|
||||
body = pathlib.Path(args.body_file).read_text()
|
||||
except OSError:
|
||||
raise Failure(2, 'usage', 'Body file unreadable')
|
||||
if not body.strip():
|
||||
raise Failure(2, 'usage', 'Reply body must not be empty')
|
||||
data = reply(client, args.number, args.comment_id, body, args.expect_head)
|
||||
else:
|
||||
if not args.thread_id or not (args.resolve or args.unresolve):
|
||||
raise Failure(2, 'usage', 'Resolution requires --thread-id and --resolve or --unresolve')
|
||||
data = resolve(client, args.number, args.thread_id, args.resolve, args.expect_head)
|
||||
else:
|
||||
data = snapshot(client, args.number, args.expect_head)
|
||||
head = data['head_sha']
|
||||
if args.operation == 'watch-check':
|
||||
if not args.baseline or not os.path.isabs(args.baseline):
|
||||
raise Failure(2, 'usage', '--baseline must be an absolute JSON path')
|
||||
try:
|
||||
baseline = json.loads(pathlib.Path(args.baseline).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise Failure(6, 'malformed', 'Baseline cannot be read as JSON')
|
||||
if baseline.get('schema_version') != 1 or baseline.get('error') is not None or not re.fullmatch('[0-9a-f]{64}',(baseline.get('data') or {}).get('fingerprint','')):
|
||||
raise Failure(6,'malformed','Baseline is not a successful version-1 snapshot')
|
||||
if any(baseline.get(k) != v for k, v in [('provider', client.provider), ('host', args.host), ('repo', args.repo), ('number', args.number)]):
|
||||
raise Failure(6, 'malformed', 'Baseline belongs to a different PR/watch owner')
|
||||
code = 0 if data['state'] != 'open' or data['ready'] or workflow_verdict(data,'full-send') == 'blocked' or data['fingerprint'] != (baseline.get('data') or {}).get('fingerprint') else 1
|
||||
except Failure as e:
|
||||
if data is None and args.operation in ('status','watch-check','ci-wait'):
|
||||
data = dict(ready=False,checks={'state':'unknown','complete':False},reviews={'decision':'unknown','complete':False},threads={'unresolved':None,'complete':False})
|
||||
code = e.code
|
||||
if client and client.write_attempted and e.outcome == 'none' and code in (6,75,124):
|
||||
e.outcome = 'unknown'
|
||||
error = dict(kind=e.kind, message=str(e), retry_after_seconds=e.retry, write_outcome=e.outcome)
|
||||
print(str(e), file=sys.stderr)
|
||||
except (ValueError, TypeError, KeyError, AttributeError) as e:
|
||||
code = 6
|
||||
error = dict(kind='malformed', message='Provider evidence malformed: ' + str(e), retry_after_seconds=None, write_outcome='unknown' if client and client.write_attempted else 'none')
|
||||
print(json.dumps(dict(schema_version=1, operation=args.operation, provider=client.provider if client else 'unknown', host=args.host, repo=args.repo, number=args.number, head_sha=head, data=data, error=error), sort_keys=True))
|
||||
if args.operation == 'ci-wait' and not args.json and code not in (0,2,124):
|
||||
return 1
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" capabilities "$@"
|
||||
@@ -1,64 +1,220 @@
|
||||
#!/usr/bin/python3
|
||||
# git-credential-mosaic — production entrypoint (P0-SEC R4, rev-code-02 B1).
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# WHY THIS IS NOT BASH: three review rounds falsified every in-bash startup
|
||||
# guard. A non-interactive bash sources $BASH_ENV and imports exported
|
||||
# functions BEFORE the first script line, so read(), unset(), exit(),
|
||||
# declare(), printf() — every callable — can be shadows that fake the
|
||||
# ancestry, defeat the scrub, or forge diagnostics (rev-code-02 probes 1 and
|
||||
# 2, artifacts fc49e9d9 lineage). No in-language dispatch survives that.
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
#
|
||||
# This entrypoint is unshapable at the bash level: python does not read
|
||||
# BASH_ENV and imports no bash functions, and the interpreter is pinned by
|
||||
# absolute shebang (no PATH resolution). It builds the child environment BY
|
||||
# ALLOWLIST and execve's the bash implementation directly — the child bash
|
||||
# starts with no BASH_ENV, no BASH_FUNC_*, no SHELLOPTS/BASHOPTS, and exactly
|
||||
# the variables the credential protocol needs. stdin/stdout/stderr and argv
|
||||
# pass through untouched.
|
||||
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# The implementation file (git-credential-mosaic.impl) refuses to run without
|
||||
# the clean-mode marker, so it cannot be invoked directly as a shaped-entry
|
||||
# bypass of this wrapper.
|
||||
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||
# This helper used to end by emitting the shared account's token for any request
|
||||
# it could not resolve to an identity. A seat with no identity, or with an
|
||||
# identity whose token was never provisioned, therefore received the most
|
||||
# privileged credential configured on the host — silently, and indistinguishably
|
||||
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||
# was attributed to that shared account, so author != reviewer separation was
|
||||
# unenforceable and the true actor was unrecoverable after the fact.
|
||||
#
|
||||
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||
# principal is not.
|
||||
#
|
||||
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||
# username git supplies on stdin
|
||||
# store : chosen by what the identity IS, with no precedence and no
|
||||
# cross-store fallback (see "Credential store selection" below)
|
||||
# hit : emit username + password, exit 0
|
||||
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||
# remotes handled by another helper)
|
||||
#
|
||||
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||
# and no identity requested still gets the shared account, because on such a host
|
||||
# the shared account is the operator's own and there is no attribution to lose.
|
||||
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||
# the shared fallback is refused there.
|
||||
#
|
||||
# A token is never written to stderr, to the escalation record, or to any log.
|
||||
|
||||
import os
|
||||
import sys
|
||||
[ "$1" = "get" ] || exit 0
|
||||
|
||||
IMPL = os.path.join(os.path.dirname(os.path.realpath(__file__)), "git-credential-mosaic.impl")
|
||||
# Absolute-path candidates ONLY — never PATH resolution (an attacker-shaped
|
||||
# PATH must not choose the interpreter). /usr/bin/bash is the fleet-host
|
||||
# layout; /bin/bash is alpine and other FHS variants (found by the T125
|
||||
# gateway-image verification: the hardcoded /usr/bin/bash made every call
|
||||
# exit 127 inside node:22-alpine).
|
||||
BASH_CANDIDATES = ("/usr/bin/bash", "/bin/bash")
|
||||
BASH = next((p for p in BASH_CANDIDATES if os.access(p, os.X_OK)), None)
|
||||
host=""; username_in=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
|
||||
# Allowlist: everything else in the environment dies at this boundary. Adding
|
||||
# a variable here is a security decision — it crosses into a shell that no
|
||||
# longer has any startup shaping, but it also becomes the only context the
|
||||
# implementation can see.
|
||||
KEEP = (
|
||||
"HOME",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"MOSAIC_GIT_IDENTITY",
|
||||
"MOSAIC_AGENT_NAME",
|
||||
"MOSAIC_BRAIN_HOME",
|
||||
"MOSAIC_CREDENTIAL_SPOOL",
|
||||
"MOSAIC_CREDENTIAL_LINEAGE_FENCE",
|
||||
)
|
||||
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||
# declined quietly — another helper owns it, and refusing would break it.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
|
||||
env = {"_MOSAIC_HELPER_CLEAN": "1"}
|
||||
for name in KEEP:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
env[name] = value
|
||||
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [ -z "$ident" ]; then
|
||||
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [ -z "$ident" ]; then
|
||||
ident="$username_in"
|
||||
ident_src="the username git supplied"
|
||||
fi
|
||||
|
||||
argv = [BASH, IMPL] + sys.argv[1:]
|
||||
if BASH is None:
|
||||
sys.stderr.write("git-credential-mosaic: no executable bash at " + " or ".join(BASH_CANDIDATES) + "\n")
|
||||
sys.exit(127)
|
||||
try:
|
||||
os.execve(BASH, argv, env)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"git-credential-mosaic: entrypoint exec failed: {exc}\n")
|
||||
sys.exit(127)
|
||||
# ── Credential store selection ────────────────────────────────────────────────
|
||||
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||
# its credential lives. There is no precedence rule between the two stores and no
|
||||
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||
# than reading a service credential that happens to share its name.
|
||||
#
|
||||
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||
# service — it does not
|
||||
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||
#
|
||||
# One credential, one location. Two copies of one credential diverge, and the
|
||||
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||
#
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
|
||||
idtok=""; ident_kind=""
|
||||
if [ -n "$ident" ]; then
|
||||
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||
ident_kind="seat"
|
||||
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||
else
|
||||
ident_kind="service identity"
|
||||
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||
# is active. Where there are seats, records must be attributable, so an
|
||||
# unresolvable request is refused instead of borrowing the shared account.
|
||||
fleet_present=0
|
||||
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||
|
||||
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||
# the password field, not from the username string, so any non-empty
|
||||
# placeholder works — deliberately NOT a real account name, since framework
|
||||
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||
if [ -z "$ident" ]; then
|
||||
reason="no-identity"
|
||||
else
|
||||
reason="no-token-for-identity"
|
||||
fi
|
||||
|
||||
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# The escalation RECORD is durable and unconditional; any notification built on
|
||||
# top of it is best-effort. Record and alert are deduplicated separately — a cap
|
||||
# on the alert alone lets the spool grow without bound exactly while the operator
|
||||
# is being told nothing, so the louder the failure the quieter it gets.
|
||||
#
|
||||
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||
# and a spool that silently stops parsing is worse than no spool, because the
|
||||
# operator only discovers it while reading the record that explains an outage.
|
||||
json_escape() {
|
||||
local s=$1
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\t'/\\t}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\n'/\\n}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||
spool_record=""
|
||||
if mkdir -p "$spool" 2>/dev/null; then
|
||||
chmod 700 "$spool" 2>/dev/null
|
||||
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||
if [ ! -e "$dedupe" ]; then
|
||||
: > "$dedupe" 2>/dev/null
|
||||
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||
>> "$spoolfile" 2>/dev/null
|
||||
chmod 600 "$spoolfile" 2>/dev/null
|
||||
fi
|
||||
# Name the record only if one is actually on disk. Printing the path
|
||||
# unconditionally sends the operator to a file that does not exist on exactly
|
||||
# the hosts where the spool could not be created.
|
||||
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
git-credential-mosaic: REFUSED (fail-closed).
|
||||
host : ${host}
|
||||
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||
reason : ${reason}
|
||||
EOF
|
||||
|
||||
if [ -n "$ident" ]; then
|
||||
cat >&2 <<EOF
|
||||
expected : ${idtok}
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||
account: that fallback makes every record it creates attributable to one
|
||||
principal, which is unrecoverable once a pull request has merged under it.
|
||||
|
||||
Fix (pick one):
|
||||
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||
Then provision that identity's credential at the path named above. An identity
|
||||
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||
|
||||
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||
to provision one.
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$spool_record" ]; then
|
||||
echo " record: ${spool_record}" >&2
|
||||
else
|
||||
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||
fi
|
||||
exit 1
|
||||
@@ -1,480 +0,0 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
#
|
||||
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||
# This helper used to end by emitting the shared account's token for any request
|
||||
# it could not resolve to an identity. A seat with no identity, or with an
|
||||
# identity whose token was never provisioned, therefore received the most
|
||||
# privileged credential configured on the host — silently, and indistinguishably
|
||||
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||
# was attributed to that shared account, so author != reviewer separation was
|
||||
# unenforceable and the true actor was unrecoverable after the fact.
|
||||
#
|
||||
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||
# principal is not.
|
||||
#
|
||||
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||
# username git supplies on stdin
|
||||
# ownership: a FLEET SEAT caller may resolve ONLY its own identity, where
|
||||
# the CALLER is established by process ANCESTRY, not by the
|
||||
# current environment: every ancestor's /proc/<pid>/environ is
|
||||
# frozen at exec, so a child can rewrite its own MOSAIC_AGENT_NAME
|
||||
# but can never make an ancestor disagree with what the launcher
|
||||
# gave it (P5-RM-006; the dual-variable override was measured by
|
||||
# rev-code-02 F1). An anonymous caller (no lineage, no consensus)
|
||||
# may resolve NOTHING on a fleet host — seat or service
|
||||
# (rev-code-02 F2). Non-fleet hosts keep the documented legacy
|
||||
# paths below.
|
||||
# perms : a slot whose mode lets group or other read it (anything but
|
||||
# ?00) is refused — a loose slot is provisioning drift, and
|
||||
# serving from it silently widens every seat's exposure on a
|
||||
# single-account host.
|
||||
# store : chosen by what the identity IS, with no precedence and no
|
||||
# cross-store fallback (see "Credential store selection" below)
|
||||
# hit : emit username + password, exit 0
|
||||
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||
# remotes handled by another helper)
|
||||
#
|
||||
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||
# and no identity requested still gets the shared account, because on such a host
|
||||
# the shared account is the operator's own and there is no attribution to lose.
|
||||
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||
# the shared fallback is refused there.
|
||||
#
|
||||
# A token is never written to stderr, to the escalation record, or to any log.
|
||||
|
||||
[ "$1" = "get" ] || exit 0
|
||||
|
||||
|
||||
# ── The shared refusal path ──────────────────────────────────────────────────
|
||||
# Every fail-closed exit funnels through refuse(): a durable escalation record
|
||||
# (deduped, JSON-escaped), a stderr diagnostic naming host/identity/reason,
|
||||
# caller-supplied guidance when the refusing site has specific advice, exit 1.
|
||||
# Defined here because the ownership gate below must be able to reach it.
|
||||
refuse() {
|
||||
local guidance="${1:-}"
|
||||
local seat ts spool spool_record spoolfile dedupe
|
||||
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||
# and a spool that silently stops parsing is worse than no spool, because the
|
||||
# operator only discovers it while reading the record that explains an outage.
|
||||
json_escape() {
|
||||
local s=$1
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\t'/\\t}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\n'/\\n}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||
spool_record=""
|
||||
if mkdir -p "$spool" 2>/dev/null; then
|
||||
chmod 700 "$spool" 2>/dev/null
|
||||
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||
if [ ! -e "$dedupe" ]; then
|
||||
: > "$dedupe" 2>/dev/null
|
||||
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||
>> "$spoolfile" 2>/dev/null
|
||||
chmod 600 "$spoolfile" 2>/dev/null
|
||||
fi
|
||||
# Name the record only if one is actually on disk. Printing the path
|
||||
# unconditionally sends the operator to a file that does not exist on exactly
|
||||
# the hosts where the spool could not be created.
|
||||
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
git-credential-mosaic: REFUSED (fail-closed).
|
||||
host : ${host}
|
||||
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||
reason : ${reason}
|
||||
EOF
|
||||
|
||||
if [ -n "$ident" ]; then
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
expected : ${idtok}
|
||||
EOF
|
||||
fi
|
||||
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
|
||||
${guidance}
|
||||
EOF
|
||||
|
||||
if [ -n "$spool_record" ]; then
|
||||
echo " record: ${spool_record}" >&2
|
||||
else
|
||||
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Bash environment injection guard (rev-code-02 R3, B1) ───────────────────
|
||||
# Non-interactive bash sources $BASH_ENV at startup and imports exported
|
||||
# functions from BASH_FUNC_* environment entries; either can define a read()
|
||||
# or printf() that shadows the builtin the ancestry walker and diagnostics
|
||||
# rely on — measured live by the reviewer's fixture (BASH_ENV read() rewrote
|
||||
# every ancestry entry). A legitimate fleet seat environment carries neither
|
||||
# (verified: zero BASH_FUNC_* in seat envs), so their presence in a helper
|
||||
# request is an injection attempt: scrub the shadows first (so even the
|
||||
# refusal machinery cannot be subverted), then refuse fail-closed.
|
||||
# Imported functions are detected by ENUMERATION, not env-var names: bash
|
||||
# consumes BASH_FUNC_* variables while importing the functions, so the
|
||||
# environment no longer shows them (measured). At this point the script has
|
||||
# defined exactly one function of its own (refuse); anything else in the
|
||||
# function table arrived from the caller's environment. BASH_ENV is checked
|
||||
# directly (it remains visible after sourcing).
|
||||
_injected=0
|
||||
_inj_names=""
|
||||
while IFS=' ' builtin read -r _decl _kind _fn; do
|
||||
[ -n "$_fn" ] || continue
|
||||
case "$_fn" in
|
||||
refuse) ;;
|
||||
*) _injected=1; _inj_names="$_inj_names $_fn";;
|
||||
esac
|
||||
done < <(declare -F)
|
||||
_inj_vars="${!BASH_FUNC_@}"
|
||||
if [ -n "$_inj_vars" ]; then
|
||||
_injected=1
|
||||
for _iv in $_inj_vars; do
|
||||
case "$_iv" in
|
||||
BASH_FUNC_*%%) _ifn="${_iv#BASH_FUNC_}"; _ifn="${_ifn%%%}";;
|
||||
BASH_FUNC_*) _ifn="${_iv#BASH_FUNC_}";;
|
||||
*) _ifn="";;
|
||||
esac
|
||||
[ -n "$_ifn" ] && { unset -f "$_ifn" 2>/dev/null; _inj_names="$_inj_names $_ifn"; }
|
||||
done
|
||||
fi
|
||||
if [ "$_injected" = 1 ] || [ -n "${BASH_ENV:-}" ]; then
|
||||
while IFS=' ' builtin read -r _decl _kind _fn; do
|
||||
[ "$_fn" = refuse ] || unset -f "$_fn" 2>/dev/null
|
||||
done < <(declare -F)
|
||||
unset BASH_ENV 2>/dev/null
|
||||
reason="bash-environment-injection-refused"
|
||||
refuse "The helper's bash startup state was externally shaped: BASH_ENV is
|
||||
set and/or exported BASH_FUNC_* functions are present in the request
|
||||
environment. Non-interactive bash sources BASH_ENV and imports those
|
||||
functions BEFORE any script line runs, so builtins this helper's security
|
||||
decisions rely on could be shadowed. Nothing resolves from a shaped request
|
||||
environment. If this surprised a legitimate workflow, the caller environment
|
||||
must be cleaned (no BASH_ENV, no exported functions) before invoking git."
|
||||
fi
|
||||
|
||||
|
||||
host=""; username_in=""
|
||||
while IFS= builtin read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
|
||||
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||
# declined quietly — another helper owns it, and refusing would break it.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
|
||||
# ── Clean-entrypoint assert (P0-SEC R4) ─────────────────────────────────────
|
||||
# This implementation only runs behind the python entrypoint
|
||||
# (git-credential-mosaic), which execve's it with an allowlist environment:
|
||||
# no BASH_ENV, no imported functions, nothing shapable at bash startup. A
|
||||
# direct invocation without the marker is a bypass attempt on that boundary
|
||||
# and refuses. Placed after refuse() and the host parse so the refusal path
|
||||
# exists when it fires (an earlier placement died on 'refuse: command not
|
||||
# found' — the failure mode is real, keep this after every definition it
|
||||
# calls).
|
||||
if [ "${_MOSAIC_HELPER_CLEAN:-}" != "1" ]; then
|
||||
reason="direct-entrypoint-refused"
|
||||
refuse "This implementation refuses to run outside the production
|
||||
entrypoint. git-credential-mosaic (the python wrapper in this directory)
|
||||
execve's it with a hand-built, unshapable environment; invoking the .impl
|
||||
directly bypasses that boundary. Credential requests go through git, which
|
||||
invokes the wrapper named in gitconfig."
|
||||
fi
|
||||
|
||||
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [ -z "$ident" ]; then
|
||||
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [ -z "$ident" ]; then
|
||||
ident="$username_in"
|
||||
ident_src="the username git supplied"
|
||||
fi
|
||||
|
||||
# ── Credential store selection ────────────────────────────────────────────────
|
||||
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||
# its credential lives. There is no precedence rule between the two stores and no
|
||||
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||
# than reading a service credential that happens to share its name.
|
||||
#
|
||||
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||
# service — it does not
|
||||
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||
#
|
||||
# One credential, one location. Two copies of one credential diverge, and the
|
||||
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||
#
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
|
||||
# ── Caller-identity ownership (P5-RM-006) ──────────────────────────────────────
|
||||
# A credential request is honourable only when the CALLER owns the identity it
|
||||
# asks for. On a fleet host every seat shares one unix account, so the
|
||||
# launcher-established MOSAIC_AGENT_NAME is the only attribution signal the
|
||||
# helper has. Two measured paths made the old contract unsafe:
|
||||
#
|
||||
# - a seat exporting MOSAIC_GIT_IDENTITY=<another-seat> resolved that seat's
|
||||
# token through the normal precedence chain (T97 G2, jarvis V2 probe), and
|
||||
# - an anonymous caller (no seat name) inherited the host gitconfig's
|
||||
# username=jarvis line and resolved jarvis's slot (T94: five watcher units
|
||||
# flapping on exactly this class).
|
||||
#
|
||||
# Ownership rules, fail-closed on fleet hosts only; a host with no fleet keeps
|
||||
# the legacy contract unchanged:
|
||||
# 1. a SEAT caller may resolve only its own identity;
|
||||
# 2. an anonymous caller may not resolve any SEAT identity (service
|
||||
# identities remain available to non-seat automation such as CI).
|
||||
# [P5-RM-006r1 ancestry binding begin]
|
||||
# ── Caller identity from exec-frozen ancestry (rev-code-02 F1/F2) ───────────
|
||||
# Walk /proc self->root collecting MOSAIC_AGENT_NAME from each ancestor's
|
||||
# frozen environ. Rules:
|
||||
# - any DISAGREEMENT (an ancestor value != the current value, or ancestors
|
||||
# disagreeing among themselves) is a rewrite -> spoof-refused, nothing
|
||||
# resolves. A child can inject variables downward but cannot alter an
|
||||
# ancestor's exec-frozen environ, so the launcher-established value always
|
||||
# participates in the comparison.
|
||||
# - consensus (all ancestors that carry the var agree with the current env,
|
||||
# or with each other when the current env is empty) -> caller = that value.
|
||||
# - no ancestor carries it -> the current claim is unlineaged: caller is
|
||||
# anonymous regardless of what the environment says. A name with no
|
||||
# lineage is a claim, not an identity.
|
||||
# The walk stops at PID 1, at a missing /proc entry, or INCLUSIVE at an
|
||||
# ancestor that carries MOSAIC_CREDENTIAL_LINEAGE_FENCE with an EMPTY agent
|
||||
# name — the test-suite lineage root. A fence beside a non-empty name is
|
||||
# IGNORED and the walk continues, so an attacker cannot fence off the true
|
||||
# ancestry by planting the marker next to a victim name.
|
||||
trusted_caller() {
|
||||
# PATH-HARDENED (rev-code-02 R1 F1): every /proc read below uses ONLY bash
|
||||
# builtins (read/case/parameter expansion). The first implementation piped
|
||||
# through PATH-resolved tr/sed/head/grep, and a caller that prepends hostile
|
||||
# utilities to PATH in the same invocation that overrides the identity
|
||||
# variables could forge the ancestry itself. Builtins cannot be shadowed.
|
||||
local pid ppid v entry line fence
|
||||
local -a vals=()
|
||||
pid=$$
|
||||
while :; do
|
||||
v=""
|
||||
fence=0
|
||||
if [ -r "/proc/$pid/environ" ]; then
|
||||
# Read inside a captured subshell whose stderr is closed: opening
|
||||
# /proc/<pid>/environ can fail with EACCES on ancestors that are
|
||||
# readable-by-mode but not openable (session managers), and that open
|
||||
# failure prints from the SHELL, immune to loop-level 2>/dev/null
|
||||
# (measured). The subshell makes the skip silent; NUL separators are
|
||||
# converted to newlines for the parent's builtin parse.
|
||||
_env_text=$( { while IFS= builtin read -r -d '' _e; do builtin printf '%s\n' "$_e"; done < "/proc/$pid/environ"; } 2>/dev/null )
|
||||
while IFS= builtin read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
case "$entry" in
|
||||
MOSAIC_AGENT_NAME=*) v="${entry#MOSAIC_AGENT_NAME=}";;
|
||||
MOSAIC_CREDENTIAL_LINEAGE_FENCE=*) fence=1;;
|
||||
esac
|
||||
done <<EOF_ENV
|
||||
$_env_text
|
||||
EOF_ENV
|
||||
fi
|
||||
if [ "$pid" != "$$" ]; then
|
||||
[ -n "$v" ] && vals+=("$v")
|
||||
if [ "$fence" = 1 ] && [ -z "$v" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
ppid=""
|
||||
if [ -r "/proc/$pid/status" ]; then
|
||||
while IFS= builtin read -r line; do
|
||||
case "$line" in
|
||||
PPid:*) ppid="${line#PPid:}"; ppid="${ppid//[[:space:]]/}";;
|
||||
esac
|
||||
done < "/proc/$pid/status"
|
||||
fi
|
||||
case "$ppid" in ''|0|1) break;; esac
|
||||
pid=$ppid
|
||||
done
|
||||
local self="${MOSAIC_AGENT_NAME:-}" i consensus=""
|
||||
if [ "${#vals[@]}" -gt 0 ]; then
|
||||
consensus="${vals[0]}"
|
||||
for i in "${vals[@]}"; do
|
||||
if [ "$i" != "$consensus" ]; then
|
||||
printf 'SPOOF'
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [ -n "$self" ] && [ "$self" != "$consensus" ]; then
|
||||
printf 'SPOOF'
|
||||
return
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$consensus"
|
||||
}
|
||||
|
||||
if [ -d "$brain_home/fleet/agents" ]; then
|
||||
caller="$(trusted_caller)"
|
||||
if [ "$caller" = "SPOOF" ]; then
|
||||
reason="caller-identity-spoof-refused"
|
||||
refuse "The MOSAIC_AGENT_NAME lineage disagrees within this process tree:
|
||||
an ancestor established by exec carries a different value than the request.
|
||||
A child process can rewrite its own environment but never an ancestor's
|
||||
frozen environ, so disagreement is a rewrite, not a race. Nothing resolves
|
||||
under a rewritten caller identity. If this surprised a legitimate workflow,
|
||||
run git from the seat's own session, not from a rewritten environment."
|
||||
fi
|
||||
if [ -n "$caller" ] && [ -d "$brain_home/fleet/agents/$caller" ]; then
|
||||
if [ -n "$ident" ] && [ "$ident" != "$caller" ]; then
|
||||
reason="cross-seat-identity-refused"
|
||||
refuse "A seat may resolve only its own credential slot. Caller seat is
|
||||
'$caller' (ancestry-established); the request names '$ident'. Overriding
|
||||
MOSAIC_GIT_IDENTITY (or a git config / URL username) to another seat's name is
|
||||
exactly the path this refusal exists to close. If '$ident' auth is genuinely
|
||||
required, that seat runs the operation itself or the orchestrator provisions
|
||||
an explicit grant."
|
||||
fi
|
||||
else
|
||||
# Anonymous caller on a fleet host (no lineage, or the lineage root is not
|
||||
# a seat): NOTHING resolves — seat slots (T94 jarvis@ class) or legacy
|
||||
# service credentials (rev-code-02 F2: credentialed services are seats;
|
||||
# the legacy store is vestigial and not anonymously reachable).
|
||||
if [ -n "$ident" ]; then
|
||||
ident_kind="${ident_kind:-}"
|
||||
[ -d "$brain_home/fleet/agents/$ident" ] && ident_kind="seat" || ident_kind="service identity"
|
||||
reason="anonymous-credential-refused"
|
||||
refuse "This caller has no seat lineage on a fleet host and asked for
|
||||
'$ident' (a ${ident_kind}). Anonymous callers resolve nothing on fleet hosts:
|
||||
seat credentials must never serve an unattributable caller, and credentialed
|
||||
services are seats with their own sessions (the legacy service store is
|
||||
vestigial). Run from the owning seat's session."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# [P5-RM-006r1 ancestry binding end]
|
||||
|
||||
idtok=""; ident_kind=""
|
||||
if [ -n "$ident" ]; then
|
||||
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||
ident_kind="seat"
|
||||
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||
else
|
||||
ident_kind="service identity"
|
||||
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
# P5-RM-006 seat permissions: a SEAT slot readable by group or other is
|
||||
# provisioning drift, and on a single-account fleet host it widens every
|
||||
# seat's exposure at once. Refuse rather than serve from a loose slot; the
|
||||
# record names the path so the provisioning fix is one chmod away.
|
||||
# Scoped to seat slots: the framework service store is operator-managed
|
||||
# and outside this work unit's permission surface.
|
||||
if [ "${ident_kind:-}" = "seat" ]; then
|
||||
# command -p resolves stat from the POSIX default PATH (system
|
||||
# directories), never the caller's PATH (rev-code-02 R3 B2: a shadowed
|
||||
# stat reported a 0644 slot as 600 and the helper served it). Output is
|
||||
# shape-validated: anything that is not 3-4 octal digits refuses.
|
||||
slot_mode="$(command -p stat -c '%a' "$idtok" 2>/dev/null || true)"
|
||||
case "$slot_mode" in
|
||||
[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;;
|
||||
*) slot_mode="unverifiable";;
|
||||
esac
|
||||
if [ "${slot_mode:1:2}" != "00" ]; then
|
||||
reason="slot-permission-violation"
|
||||
refuse "Slot $idtok has mode ${slot_mode:-unknown}; expected owner-only
|
||||
(0600 or stricter). Tighten it: chmod 600 '$idtok'. This refusal is the seat
|
||||
permissions half of P5-RM-006: a loose slot on a shared-account host is every
|
||||
seat's exposure, so the helper declines to serve from it. Mode inspection uses
|
||||
command -p (trusted PATH) and fails closed on unverifiable output."
|
||||
fi
|
||||
fi
|
||||
echo "username=${ident}"
|
||||
echo "password=$(<"$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||
# is active. Where there are seats, records must be attributable, so an
|
||||
# unresolvable request is refused instead of borrowing the shared account.
|
||||
fleet_present=0
|
||||
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||
|
||||
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||
# the password field, not from the username string, so any non-empty
|
||||
# placeholder works — deliberately NOT a real account name, since framework
|
||||
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||
# The escalation RECORD is durable and unconditional; any notification built on
|
||||
# top of it is best-effort (see refuse()). Record and alert are deduplicated
|
||||
# separately — a cap on the alert alone lets the spool grow without bound
|
||||
# exactly while the operator is being told nothing, so the louder the failure
|
||||
# the quieter it gets.
|
||||
if [ -z "$ident" ]; then
|
||||
reason="no-identity"
|
||||
else
|
||||
reason="no-token-for-identity"
|
||||
fi
|
||||
refuse "No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||
account: that fallback makes every record it creates attributable to one
|
||||
principal, which is unrecoverable once a pull request has merged under it.
|
||||
|
||||
Fix (pick one):
|
||||
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||
Then provision that identity's credential at the path named above. An identity
|
||||
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||
|
||||
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||
to provision one."
|
||||
|
||||
@@ -48,12 +48,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
usage() {
|
||||
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
|
||||
echo ""
|
||||
@@ -176,7 +170,12 @@ ORG="${REPO_SLUG%%/*}"
|
||||
REPO_NAME="${REPO_SLUG#*/}"
|
||||
|
||||
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
|
||||
trap 'rm -f "$RESPONSE_FILE"' EXIT
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo "Error: could not stage Gitea credential for reviewer grant" >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
|
||||
|
||||
# gitea_api <step> <method> <path> [json-payload]
|
||||
# Runs one API call with the staged credential (token never in argv). Sets
|
||||
@@ -190,8 +189,9 @@ gitea_api() {
|
||||
if [[ -n "$payload" ]]; then
|
||||
payload_args=(-H 'Content-Type: application/json' -d "$payload")
|
||||
fi
|
||||
if ! GITEA_API_STATUS=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
||||
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
||||
-X "$method" \
|
||||
--config "$AUTH_CONFIG" \
|
||||
"${payload_args[@]}" \
|
||||
"$GITEA_API_ROOT$path"); then
|
||||
echo "Error: Gitea transport failed during $step" >&2
|
||||
@@ -341,6 +341,3 @@ fi
|
||||
|
||||
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
|
||||
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -9,12 +9,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
@@ -102,8 +96,9 @@ import os
|
||||
print(json.dumps({"body": os.environ["COMMENT"]}))
|
||||
PY
|
||||
)
|
||||
gitea_curl_with_token "$token" -fsS -X POST \
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$url" >/dev/null
|
||||
@@ -114,8 +109,9 @@ gitea_issue_close_api() {
|
||||
host=$(get_remote_host) || return 1
|
||||
token=$(get_gitea_token "$host") || return 1
|
||||
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
|
||||
gitea_curl_with_token "$token" -fsS -X PATCH \
|
||||
curl -fsS -X PATCH \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"$url" >/dev/null
|
||||
@@ -179,6 +175,3 @@ else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -28,12 +28,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
@@ -161,15 +155,8 @@ gitea_resolve_api_for_login() {
|
||||
}
|
||||
fi
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
# No monolith-configured Gitea URL for this host (#1450): seat-token-only
|
||||
# hosts carry no gitea-mosaicstack/gitea-usc credentials.sh entry and no
|
||||
# bare GITEA_URL, so get_gitea_url_for_host has nothing to match against.
|
||||
# Synthesize the API base directly from the git remote's own host --
|
||||
# exactly the trust model issue-create.sh's REST fallback already uses
|
||||
# successfully on these hosts. This is NOT a cross-host guess: $host came
|
||||
# from get_remote_host() reading THIS repo's own origin remote, so the
|
||||
# resolved base always matches the repo actually being acted on.
|
||||
configured_url="https://${host}"
|
||||
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
|
||||
return 1
|
||||
}
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
@@ -190,12 +177,18 @@ gitea_resolve_api_for_login() {
|
||||
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
||||
# Prints the login on success.
|
||||
gitea_authenticated_login() {
|
||||
local response_file status
|
||||
local response_file auth_config status
|
||||
|
||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
||||
trap 'rm -f "$response_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$response_file"
|
||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
||||
|
||||
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \
|
||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
"$GITEA_API_ROOT/user"); then
|
||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||
return 1
|
||||
@@ -237,7 +230,7 @@ PY
|
||||
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
|
||||
gitea_create_comment_verified() {
|
||||
local issue_number="$1" comment_body="$2" acting_login="$3"
|
||||
local payload write_file readback_file write_status readback_status created_id
|
||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
||||
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
@@ -247,10 +240,16 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
|
||||
trap 'rm -f "$write_file" "$readback_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file"
|
||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
||||
@@ -279,7 +278,8 @@ print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
return 1
|
||||
@@ -414,6 +414,3 @@ else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -7,12 +7,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
@@ -55,8 +49,9 @@ PY
|
||||
)
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/issues"
|
||||
gitea_curl_with_token "$token" -fsS -X POST \
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$url"
|
||||
@@ -205,6 +200,3 @@ case "$PLATFORM" in
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -8,12 +8,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
@@ -100,8 +94,9 @@ import os
|
||||
print(json.dumps({"body": os.environ["COMMENT"]}))
|
||||
PY
|
||||
)
|
||||
gitea_curl_with_token "$token" -fsS -X POST \
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$url" >/dev/null
|
||||
@@ -112,8 +107,9 @@ gitea_issue_reopen_api() {
|
||||
host=$(get_remote_host) || return 1
|
||||
token=$(get_gitea_token "$host") || return 1
|
||||
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
|
||||
gitea_curl_with_token "$token" -fsS -X PATCH \
|
||||
curl -fsS -X PATCH \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"open"}' \
|
||||
"$url" >/dev/null
|
||||
@@ -160,6 +156,3 @@ else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -7,12 +7,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
@@ -42,12 +36,12 @@ gitea_issue_view_api() {
|
||||
}
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
||||
local -a curl_args=(-fsS -H "User-Agent: curl/8")
|
||||
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
# No renderer: raw JSON is all this path can give. Comments are a
|
||||
# second resource, so fetch them too rather than only the count.
|
||||
gitea_curl_with_token "$token" "${curl_args[@]}" "$url"
|
||||
gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments"
|
||||
curl "${curl_args[@]}" "$url"
|
||||
curl "${curl_args[@]}" "${url}/comments"
|
||||
return
|
||||
fi
|
||||
# Render issue + comments as text (#1357 F2). The old fallback dumped the
|
||||
@@ -55,10 +49,10 @@ gitea_issue_view_api() {
|
||||
# invisible on this path and the wrapper could never show what
|
||||
# `tea issues --comments` shows.
|
||||
{
|
||||
gitea_curl_with_token "$token" "${curl_args[@]}" "$url"
|
||||
curl "${curl_args[@]}" "$url"
|
||||
echo
|
||||
echo "__MOSAIC_COMMENTS__"
|
||||
gitea_curl_with_token "$token" "${curl_args[@]}" "${url}/comments"
|
||||
curl "${curl_args[@]}" "${url}/comments"
|
||||
} | python3 -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read()
|
||||
@@ -151,6 +145,3 @@ else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and resolve the optional v3 merge declaration. No provider writes."""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
METHODS = {'squash', 'merge', 'rebase', 'rebase-merge', 'fast-forward-only'}
|
||||
SHAPES = {'task', 'promotion', 'absorb'}
|
||||
|
||||
|
||||
def policy(doc):
|
||||
if 'merge' not in doc:
|
||||
return {'allowed_methods':['squash'], 'default_method':'squash', 'shapes':{}}
|
||||
if doc.get('schema_version') != 3:
|
||||
raise ValueError('merge requires schema_version 3')
|
||||
p = doc['merge']
|
||||
if not isinstance(p, dict) or set(p) - {'allowed_methods','default_method','shapes'}:
|
||||
raise ValueError('merge must be an object containing only allowed_methods, default_method and shapes')
|
||||
allowed = p.get('allowed_methods')
|
||||
if not isinstance(allowed, list) or not allowed or any(not isinstance(m,str) or m not in METHODS for m in allowed) or len(allowed) != len(set(allowed)):
|
||||
raise ValueError('merge.allowed_methods must be a non-empty unique subset of supported methods')
|
||||
if not isinstance(p.get('default_method'), str) or p['default_method'] not in allowed:
|
||||
raise ValueError('merge.default_method must be in allowed_methods')
|
||||
shapes = p.get('shapes', {})
|
||||
if not isinstance(shapes, dict) or any(k not in SHAPES or not isinstance(v,str) or v not in allowed for k,v in shapes.items()):
|
||||
raise ValueError('merge.shapes accepts task, promotion and absorb, with methods in allowed_methods')
|
||||
return dict(p, shapes=shapes)
|
||||
|
||||
|
||||
def resolve(doc, head, base, request):
|
||||
p = policy(doc)
|
||||
if not head or not base or head == base:
|
||||
return 3, {'error':'POLICY-REFUSED: head/base must identify a non-self transition'}
|
||||
trunk, release = doc.get('integration_trunk'), doc.get('release_branch')
|
||||
shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task'
|
||||
mapped = p['shapes'].get(shape)
|
||||
selected = request or mapped or p['default_method']
|
||||
if selected not in p['allowed_methods'] or mapped and selected != mapped:
|
||||
return 3, {'error':'POLICY-REFUSED: requested method conflicts with repository merge policy', 'shape':shape}
|
||||
if shape == 'absorb' and not mapped:
|
||||
return 3, {'error':'POLICY-REFUSED: absorb transition requires an explicit shape mapping', 'shape':shape}
|
||||
if base != trunk and not (shape == 'promotion' and doc.get('flow') == 'trunk-release'):
|
||||
return 3, {'error':'POLICY-REFUSED: undeclared target transition', 'shape':shape}
|
||||
return 0, dict(method=selected, shape=shape, allowed_methods=p['allowed_methods'])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('file')
|
||||
parser.add_argument('--validate', action='store_true')
|
||||
parser.add_argument('--head')
|
||||
parser.add_argument('--base')
|
||||
parser.add_argument('--method')
|
||||
a = parser.parse_args()
|
||||
try:
|
||||
doc = json.loads(pathlib.Path(a.file).read_text())
|
||||
if not isinstance(doc,dict):
|
||||
raise ValueError('declaration must be an object')
|
||||
p = policy(doc)
|
||||
code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method)
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)}
|
||||
print(json.dumps(value, sort_keys=True))
|
||||
return code
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -221,7 +221,7 @@ fi
|
||||
repo_decl_warn_absent_irreversible "worktree branch-creation"
|
||||
;;
|
||||
valid)
|
||||
if [ "$DECL_SCHEMA" -ge 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
|
||||
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
|
||||
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
|
||||
fi
|
||||
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" auto-merge "$@"
|
||||
@@ -1,5 +1,372 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI wait consumes normalized checks; default exits preserve 0/1/2/124.
|
||||
#!/bin/bash
|
||||
# pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea)
|
||||
# Usage: pr-ci-wait.sh -n <pr_number> [-r owner/repo] [-t timeout_sec] [-i interval_sec]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" ci-wait "$@"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
PR_NUMBER=""
|
||||
TIMEOUT_SEC=1800
|
||||
INTERVAL_SEC=15
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER PR number (required)
|
||||
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
||||
--host HOST Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 643
|
||||
$(basename "$0") -n 643 --repo ddk/ai-bma
|
||||
$(basename "$0") -n 643 -t 900 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
extract_state_from_status_json() {
|
||||
# Capture piped JSON BEFORE invoking `python3 - <<PY`. The heredoc binds
|
||||
# stdin to the Python program text — so json.load(sys.stdin) inside would
|
||||
# try to re-read stdin after `-` already consumed it for the program,
|
||||
# yielding EOF and returning "unknown" every time. Pass payload via env.
|
||||
local payload
|
||||
payload=$(cat)
|
||||
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
|
||||
except Exception:
|
||||
print("unknown")
|
||||
raise SystemExit(0)
|
||||
|
||||
state = (payload.get("state") or "").lower()
|
||||
if state in {"success", "pending", "failure", "error"}:
|
||||
print(state)
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
|
||||
if any(v in {"failure", "error"} for v in values):
|
||||
print("failure")
|
||||
elif values and all(v == "success" for v in values):
|
||||
print("success")
|
||||
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
|
||||
print("pending")
|
||||
elif not values and not state:
|
||||
# No pipeline/status of any kind reported for this commit. Distinct from
|
||||
# "unknown" (an ambiguous/unrecognized status that should keep polling):
|
||||
# this signals a repo/commit that simply has no CI configured.
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
}
|
||||
|
||||
print_status_summary() {
|
||||
# Same stdin-collision fix as extract_state_from_status_json above.
|
||||
local payload
|
||||
payload=$(cat)
|
||||
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
|
||||
except Exception:
|
||||
print("[pr-ci-wait] status payload unavailable")
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
if not statuses:
|
||||
print("[pr-ci-wait] no status contexts reported yet")
|
||||
raise SystemExit(0)
|
||||
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
state = item.get("status") or item.get("state") or "unknown-state"
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if target:
|
||||
print(f"[pr-ci-wait] {name}: {state} ({target})")
|
||||
else:
|
||||
print(f"[pr-ci-wait] {name}: {state}")
|
||||
PY
|
||||
}
|
||||
|
||||
github_get_pr_head_sha() {
|
||||
gh pr view "$PR_NUMBER" --repo "$OWNER/$REPO" --json headRefOid --jq '.headRefOid'
|
||||
}
|
||||
|
||||
github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
}
|
||||
|
||||
gitea_get_pr_head_sha() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}/pulls/${PR_NUMBER}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
print((data.get("head") or {}).get("sha", ""))
|
||||
'
|
||||
}
|
||||
|
||||
gitea_get_commit_status_json() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local sha="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
gitea_get_default_branch() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
import json, sys
|
||||
print((json.load(sys.stdin) or {}).get("default_branch", ""))
|
||||
'
|
||||
}
|
||||
|
||||
github_get_default_branch() {
|
||||
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number)
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--interval)
|
||||
INTERVAL_SEC="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
echo "Error: PR number is required (-n)." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
REPO_INFO="$REPO_OVERRIDE"
|
||||
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||
else
|
||||
detect_platform > /dev/null
|
||||
REPO_INFO=$(get_repo_info)
|
||||
fi
|
||||
|
||||
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* || "$REPO_INFO" != */* ]]; then
|
||||
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo owner/repo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER=${REPO_INFO%%/*}
|
||||
REPO=${REPO_INFO##*/}
|
||||
START_TS=$(date +%s)
|
||||
DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required for GitHub CI status polling." >&2
|
||||
exit 1
|
||||
fi
|
||||
HEAD_SHA=$(github_get_pr_head_sha)
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if [[ -n "$HOST_OVERRIDE" ]]; then
|
||||
HOST="$HOST_OVERRIDE"
|
||||
elif [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
HOST=$(get_gitea_api_host_for_repo_override) || {
|
||||
echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine Gitea host from git origin." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_pr_head_sha "$HOST" "$OWNER/$REPO" "$TOKEN")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[pr-ci-wait] Platform=gitea host=${HOST} repo=${OWNER}/${REPO} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||
else
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
|
||||
#
|
||||
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
|
||||
# BRANCH's commit status. A repo whose default branch carries CI statuses
|
||||
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
|
||||
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
|
||||
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
|
||||
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
|
||||
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
|
||||
#
|
||||
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
|
||||
# (default branch also has no CI history, e.g. device-imaging class), where burning
|
||||
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
|
||||
#
|
||||
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
|
||||
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
|
||||
REPO_HAS_CI=1
|
||||
detect_repo_ci() {
|
||||
local def_branch def_status
|
||||
# Every early exit returns 0: a probe miss must leave the conservative
|
||||
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
def_branch=$(github_get_default_branch 2>/dev/null) || {
|
||||
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||
[[ -n "$def_branch" ]] || return 0
|
||||
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||
else
|
||||
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
|
||||
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||
[[ -n "$def_branch" ]] || return 0
|
||||
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||
fi
|
||||
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
|
||||
REPO_HAS_CI=0
|
||||
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
|
||||
else
|
||||
REPO_HAS_CI=1
|
||||
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
|
||||
fi
|
||||
}
|
||||
detect_repo_ci || true
|
||||
|
||||
NO_CI_STREAK=0
|
||||
NO_CI_MAX=3
|
||||
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: Timed out waiting for CI status on PR #$PR_NUMBER after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
else
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$HEAD_SHA")
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | extract_state_from_status_json)
|
||||
echo "[pr-ci-wait] state=${STATE} pr=#${PR_NUMBER} sha=${HEAD_SHA}"
|
||||
|
||||
case "$STATE" in
|
||||
success)
|
||||
printf '%s' "$STATUS_JSON" | print_status_summary
|
||||
echo "[pr-ci-wait] CI is green for PR #$PR_NUMBER."
|
||||
exit 0
|
||||
;;
|
||||
failure|error)
|
||||
printf '%s' "$STATUS_JSON" | print_status_summary
|
||||
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
|
||||
exit 1
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REPO_HAS_CI" == "1" ]]; then
|
||||
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
|
||||
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
|
||||
# polling until it registers or the timeout fires. Reset the streak so
|
||||
# a later genuine CI-less misread can't accumulate across this state.
|
||||
NO_CI_STREAK=0
|
||||
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
|
||||
else
|
||||
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
|
||||
# history either). Empty polls => fast-exit green after NO_CI_MAX.
|
||||
NO_CI_STREAK=$((NO_CI_STREAK + 1))
|
||||
if (( NO_CI_STREAK >= NO_CI_MAX )); then
|
||||
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
pending|unknown)
|
||||
# A pipeline exists but hasn't reached a terminal state (or is
|
||||
# transiently ambiguous) — keep waiting, and reset the no-CI streak
|
||||
# since this commit is not in the "no CI at all" condition.
|
||||
NO_CI_STREAK=0
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
*)
|
||||
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
|
||||
NO_CI_STREAK=0
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -7,12 +7,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
@@ -39,8 +33,9 @@ gitea_default_branch() {
|
||||
# `curl | python` reports only python's status, so an HTTP failure that
|
||||
# still emits parseable JSON would masquerade as success. curl's own
|
||||
# exit status is authoritative here.
|
||||
if ! body=$(gitea_curl_with_token "$token" -fsS \
|
||||
if ! body=$(curl -fsS \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
"$url" 2>/dev/null); then
|
||||
return 1
|
||||
fi
|
||||
@@ -110,8 +105,9 @@ PY
|
||||
)
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/pulls"
|
||||
gitea_curl_with_token "$token" -fsS -X POST \
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$url"
|
||||
@@ -247,13 +243,9 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
# Compatibility callbacks are invoked by declaration checks below.
|
||||
# shellcheck disable=SC2317
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
# shellcheck disable=SC2317
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
# shellcheck disable=SC2317
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
@@ -262,10 +254,10 @@ EFFECTIVE_BASE="$BASE_BRANCH"
|
||||
case "$DECL_STATE" in
|
||||
invalid) repo_decl_report_invalid ;;
|
||||
esac
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -lt 2 ]]; then
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
|
||||
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
# Write path: a normalized-remote mismatch refuses (spec 5.3).
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
@@ -366,6 +358,3 @@ case "$PLATFORM" in
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -7,12 +7,6 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
OUTPUT_FILE=""
|
||||
@@ -112,7 +106,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
||||
|
||||
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
||||
DIFF_CONTENT=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -H "User-Agent: curl/8" "$DIFF_URL")
|
||||
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
|
||||
else
|
||||
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" "$DIFF_URL")
|
||||
fi
|
||||
@@ -126,6 +120,3 @@ else
|
||||
echo "Error: Unknown platform" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -8,12 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
@@ -23,16 +17,16 @@ DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
AUTH_CONFIG=""
|
||||
|
||||
# Signal and EXIT traps invoke these callbacks.
|
||||
# shellcheck disable=SC2317
|
||||
cleanup() { :; }
|
||||
# shellcheck disable=SC2317
|
||||
cleanup() {
|
||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
||||
}
|
||||
terminate() {
|
||||
local signal="$1"
|
||||
trap - "$signal"
|
||||
cleanup
|
||||
kill -s "$signal" "$BASHPID"
|
||||
kill -s "$signal" "$$"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'terminate HUP' HUP
|
||||
@@ -172,13 +166,18 @@ case "$PLATFORM" in
|
||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
||||
echo "Error: could not stage private Gitea authentication" >&2
|
||||
exit 1
|
||||
}
|
||||
unset TOKEN
|
||||
|
||||
API_BASE="https://${HOST}/api/v1"
|
||||
# Resolve identity through the SAME private curl config used for the
|
||||
# mutation. Tea login names are globally scoped and can be duplicated
|
||||
# across hosts; a separate `tea api --login NAME` could validate another
|
||||
# credential than this host-bound token.
|
||||
AUTHENTICATED_USER=$(gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
||||
exit 1
|
||||
@@ -189,7 +188,7 @@ case "$PLATFORM" in
|
||||
}
|
||||
|
||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
||||
gitea_curl_with_token "$TOKEN" -fsS -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -205,13 +204,10 @@ if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "dra
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
gitea_curl_with_token "$TOKEN" -fsS -X PATCH \
|
||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
||||
;;
|
||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge-queue "$@"
|
||||
@@ -8,28 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Versioned JSON mode and explicit non-squash methods use the normalized adapter.
|
||||
for ((arg_i=1; arg_i<=$#; arg_i++)); do
|
||||
if [[ "${!arg_i}" == --json ]]; then
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
|
||||
fi
|
||||
done
|
||||
# A declared merge policy also applies to callers that have not adopted --json.
|
||||
declaration_root=$(git rev-parse --show-toplevel 2>/dev/null) || declaration_root=""
|
||||
if [[ -n "$declaration_root" && -f "$declaration_root/.mosaic/repo.json" ]] && python3 -I -c 'import json,sys
|
||||
try:
|
||||
d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1)
|
||||
except (ValueError,OSError):
|
||||
sys.exit(0)' "$declaration_root/.mosaic/repo.json"; then
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
|
||||
fi
|
||||
|
||||
# Default values
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
@@ -48,7 +26,7 @@ Merge a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method (default: declared policy, legacy squash); --json uses verified v1 contract
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
@@ -143,8 +121,8 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "POLICY-REFUSED: absent merge policy permits squash only." >&2
|
||||
exit 3
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
@@ -181,11 +159,8 @@ if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
# Compatibility callbacks are invoked by declaration checks below.
|
||||
# shellcheck disable=SC2317
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
# shellcheck disable=SC2317
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
@@ -194,7 +169,7 @@ fi
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" -ge 2 ]]; then
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
@@ -323,8 +298,6 @@ print(ascii(message))
|
||||
PY
|
||||
}
|
||||
|
||||
# EXIT invokes this cleanup callback.
|
||||
# shellcheck disable=SC2317
|
||||
cleanup_merge_temp_dirs() {
|
||||
local path
|
||||
for path in "${MERGE_TEMP_DIRS[@]}"; do
|
||||
@@ -743,6 +716,3 @@ case "$PLATFORM" in
|
||||
esac
|
||||
|
||||
echo "PR #$PR_NUMBER merged successfully"
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -8,12 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
OUTPUT_FILE=""
|
||||
@@ -70,7 +64,7 @@ write_metadata() {
|
||||
|
||||
curl_gitea_pull() {
|
||||
local api_url="$1"
|
||||
local token raw_code body_file http_code
|
||||
local token basic_auth raw_code body_file http_code
|
||||
body_file=$(mktemp)
|
||||
|
||||
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
|
||||
@@ -82,16 +76,30 @@ curl_gitea_pull() {
|
||||
}
|
||||
trap cleanup_gitea_pull_body RETURN
|
||||
|
||||
token=$(get_gitea_token "$HOST") || {
|
||||
echo 'Error: identity-bound metadata credential unavailable.' >&2
|
||||
return 1
|
||||
}
|
||||
raw_code=$(gitea_curl_with_token "$token" -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url") || return $?
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
cat "$body_file" || return $?
|
||||
return 0
|
||||
token=$(get_gitea_token "$HOST" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
cat "$body_file" || return $?
|
||||
return 0
|
||||
fi
|
||||
http_code="$raw_code"
|
||||
fi
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
cat "$body_file" || return $?
|
||||
return 0
|
||||
fi
|
||||
http_code="$raw_code"
|
||||
fi
|
||||
|
||||
if [[ -z "${http_code:-}" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url" || true)
|
||||
http_code="$raw_code"
|
||||
fi
|
||||
http_code="$raw_code"
|
||||
|
||||
python3 - "$http_code" "$body_file" <<'PY' >&2
|
||||
import json
|
||||
@@ -254,6 +262,3 @@ else
|
||||
echo "Error: Unknown platform" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-comments "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-reply "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" review-resolve "$@"
|
||||
@@ -35,12 +35,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Credential resolution and all consumers execute inside this private boundary.
|
||||
# Never restore xtrace while credentials remain in scope.
|
||||
mosaic_private_forge_main() (
|
||||
set +x
|
||||
|
||||
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
ACTION=""
|
||||
@@ -229,7 +223,7 @@ PY
|
||||
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
|
||||
gitea_create_comment_verified() {
|
||||
local pr_number="$1" comment_body="$2" acting_login="$3"
|
||||
local payload write_file readback_file write_status readback_status created_id
|
||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
||||
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
@@ -239,10 +233,16 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||
trap 'rm -f "$write_file" "$readback_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file"
|
||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
@@ -272,7 +272,8 @@ print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
@@ -410,7 +411,7 @@ PY
|
||||
# residue.
|
||||
gitea_resolve_api_for_login() {
|
||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||
local preflight_status
|
||||
local preflight_auth_config preflight_status
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" ]]; then
|
||||
host="$HOST_OVERRIDE"
|
||||
@@ -458,9 +459,15 @@ gitea_resolve_api_for_login() {
|
||||
GITEA_WEB_BASE="${configured_url%/}"
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
preflight_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o /dev/null -w '%{http_code}' \
|
||||
preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
echo "Error: could not stage Gitea credential for --repo preflight" >&2
|
||||
return 1
|
||||
}
|
||||
preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--config "$preflight_auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE") || preflight_status="000"
|
||||
rm -f "$preflight_auth_config"
|
||||
if [[ "$preflight_status" != "200" ]]; then
|
||||
echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host <gitea-host> or cd into the target checkout" >&2
|
||||
return 1
|
||||
@@ -474,12 +481,18 @@ gitea_resolve_api_for_login() {
|
||||
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
||||
# Prints the login on success.
|
||||
gitea_authenticated_login() {
|
||||
local response_file status
|
||||
local response_file auth_config status
|
||||
|
||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
|
||||
trap 'rm -f "$response_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$response_file"
|
||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
||||
|
||||
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$response_file" -w '%{http_code}' \
|
||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_ROOT/user"); then
|
||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||
@@ -508,14 +521,15 @@ PY
|
||||
}
|
||||
|
||||
# GET /pulls/{n} into a caller-owned response file and print its head commit
|
||||
# SHA. This core sets NO RETURN trap and reuses a caller-provided
|
||||
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
|
||||
# response file, so it is safe to call from INSIDE another trapped function
|
||||
# (the post-verify re-read below) without clobbering that function's cleanup
|
||||
# trap. $1 = PR number, $2 = response file.
|
||||
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
|
||||
gitea_read_pr_head_into() {
|
||||
local pr_number="$1" pr_file="$2" status
|
||||
local pr_number="$1" pr_file="$2" auth_config="$3" status
|
||||
|
||||
if ! status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$pr_file" -w '%{http_code}' \
|
||||
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/pulls/$pr_number"); then
|
||||
echo "Error: Gitea PR head read transport failed" >&2
|
||||
@@ -547,12 +561,17 @@ PY
|
||||
# stale review left over from an earlier push cannot be mistaken for this one.
|
||||
# Prints the head SHA on success.
|
||||
gitea_pr_head_sha() {
|
||||
local pr_number="$1" pr_file
|
||||
local pr_number="$1" pr_file auth_config
|
||||
|
||||
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
||||
trap 'rm -f "$pr_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$pr_file"
|
||||
echo "Error: could not stage Gitea credential for PR head read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$pr_file" "$auth_config"' RETURN
|
||||
|
||||
gitea_read_pr_head_into "$pr_number" "$pr_file"
|
||||
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
|
||||
}
|
||||
|
||||
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
||||
@@ -572,7 +591,7 @@ gitea_pr_head_sha() {
|
||||
# $5 = PR head sha.
|
||||
gitea_submit_review_verified() {
|
||||
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
|
||||
local payload write_file readback_file recheck_file
|
||||
local payload write_file readback_file recheck_file auth_config
|
||||
local write_status readback_status created_id live_head
|
||||
|
||||
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
|
||||
@@ -588,10 +607,16 @@ print(json.dumps({
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
|
||||
trap 'rm -f "$write_file" "$readback_file" "$recheck_file"' RETURN
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file" "$recheck_file"
|
||||
echo "Error: could not stage Gitea credential for review submit" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$write_file" -w '%{http_code}' \
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
@@ -622,7 +647,8 @@ print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(gitea_curl_with_token "$GITEA_API_TOKEN" -sS -o "$readback_file" -w '%{http_code}' \
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
|
||||
echo "Error: Gitea review read-back transport failed" >&2
|
||||
@@ -687,9 +713,9 @@ PY
|
||||
# live tip carries unreviewed code — yet the wrapper would still report
|
||||
# success. Re-read the LIVE PR head and require it STILL equals the submitted
|
||||
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
|
||||
# success line). This reuses the submit-scoped recheck file so
|
||||
# success line). This reuses the submit-scoped auth config + recheck file so
|
||||
# it neither leaks the token to argv nor clobbers this function's cleanup.
|
||||
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file") || {
|
||||
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
|
||||
echo "Error: could not re-read Gitea PR head after review verification" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -830,6 +856,3 @@ else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
)
|
||||
mosaic_private_forge_main "$@"
|
||||
@@ -1,251 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Forge wrapper envelope v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"operation",
|
||||
"provider",
|
||||
"host",
|
||||
"repo",
|
||||
"number",
|
||||
"head_sha",
|
||||
"data",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"operation": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"enum": ["github", "gitea", "forgejo", "unknown"]
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"repo": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"number": {
|
||||
"type": ["integer", "null"]
|
||||
},
|
||||
"head_sha": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"data": {
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"error": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["kind", "message", "retry_after_seconds", "write_outcome"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"retry_after_seconds": {
|
||||
"type": ["integer", "null"]
|
||||
},
|
||||
"write_outcome": {
|
||||
"enum": ["none", "confirmed", "unknown"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"snapshot": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"head_ref",
|
||||
"base_ref",
|
||||
"head_repository",
|
||||
"base_repository",
|
||||
"head_sha",
|
||||
"base_sha",
|
||||
"merge_sha",
|
||||
"state",
|
||||
"draft",
|
||||
"mergeability",
|
||||
"ready",
|
||||
"blockers",
|
||||
"observed_at",
|
||||
"fingerprint",
|
||||
"capabilities",
|
||||
"checks",
|
||||
"reviews",
|
||||
"threads",
|
||||
"auto_merge",
|
||||
"queue"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"head_ref": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"base_ref": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"head_repository": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"base_repository": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"head_sha": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[0-9a-fA-F]{40}$"
|
||||
},
|
||||
"base_sha": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[0-9a-fA-F]{40}$"
|
||||
},
|
||||
"merge_sha": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[0-9a-fA-F]{40}$"
|
||||
},
|
||||
"state": {
|
||||
"enum": ["open", "closed", "merged"]
|
||||
},
|
||||
"draft": {
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"mergeability": {
|
||||
"enum": ["clean", "conflict", "unknown"]
|
||||
},
|
||||
"ready": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"blockers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"observed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object"
|
||||
},
|
||||
"checks": {
|
||||
"type": "object",
|
||||
"required": ["state", "required", "observed", "complete"],
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": [
|
||||
"pending",
|
||||
"success",
|
||||
"failure",
|
||||
"cancelled",
|
||||
"missing",
|
||||
"not_required",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"required": {
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"observed": {
|
||||
"type": "object"
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
"type": "object",
|
||||
"required": ["decision", "reviewed_sha", "complete"],
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string"
|
||||
},
|
||||
"reviewed_sha": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[0-9a-fA-F]{40}$"
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"threads": {
|
||||
"type": "object",
|
||||
"required": ["unresolved", "complete"],
|
||||
"properties": {
|
||||
"unresolved": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_merge": {
|
||||
"type": "object",
|
||||
"required": ["state"],
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": ["enabled", "disabled", "unsupported", "unknown"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"type": "object",
|
||||
"required": ["state"],
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": ["queued", "absent", "unsupported", "unknown"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"operation": {
|
||||
"enum": ["status", "watch-check", "ci-wait"]
|
||||
},
|
||||
"error": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/$defs/snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" status "$@"
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/forge-api.py" watch-check "$@"
|
||||
@@ -73,21 +73,21 @@ repo_decl_load() {
|
||||
|
||||
# Valid: extract the consumed fields via the same python stdlib the
|
||||
# ecosystem already uses. Field-level grammar was the validator's job.
|
||||
eval "$(python3 -I - "$DECL_FILE" <<'PY'
|
||||
import json, sys, shlex
|
||||
eval "$(python3 - "$DECL_FILE" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
def q(k):
|
||||
v = d.get(k, "")
|
||||
return v if isinstance(v, str) else ""
|
||||
sv = d.get("schema_version", 1)
|
||||
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
|
||||
print(f"DECL_TRUNK={shlex.quote(q('integration_trunk'))}")
|
||||
print(f"DECL_RELEASE={shlex.quote(q('release_branch'))}")
|
||||
print(f"DECL_FLOW={shlex.quote(q('flow'))}")
|
||||
print(f"DECL_REMOTE={shlex.quote(q('canonical_remote'))}")
|
||||
print(f"DECL_POLICY={shlex.quote(q('worktree_policy'))}")
|
||||
print(f"DECL_WT_ROOT={shlex.quote(q('worktree_root'))}")
|
||||
print(f"DECL_CLONE={shlex.quote(q('canonical_clone'))}")
|
||||
print(f"DECL_TRUNK={q('integration_trunk')!r}")
|
||||
print(f"DECL_RELEASE={q('release_branch')!r}")
|
||||
print(f"DECL_FLOW={q('flow')!r}")
|
||||
print(f"DECL_REMOTE={q('canonical_remote')!r}")
|
||||
print(f"DECL_POLICY={q('worktree_policy')!r}")
|
||||
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
|
||||
print(f"DECL_CLONE={q('canonical_clone')!r}")
|
||||
PY
|
||||
)" || {
|
||||
DECL_STATE=invalid
|
||||
@@ -177,14 +177,6 @@ repo_decl_check_transition() { # $1 head, $2 base
|
||||
return 1
|
||||
;;
|
||||
trunk-release)
|
||||
if [ "$head" = "$DECL_RELEASE" ] && [ "$base" = "$DECL_TRUNK" ]; then
|
||||
# Absorb is opt-in through a declared shape mapping, never a CLI exception.
|
||||
if python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$head" --base "$base" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
repo_decl_warn "absorb transition requires explicit merge policy"
|
||||
return 1
|
||||
fi
|
||||
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
|
||||
return 0 # feature -> trunk
|
||||
fi
|
||||
@@ -204,9 +196,3 @@ repo_decl_check_transition() { # $1 head, $2 base
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Optional merge policy. Caller must first require DECL_STATE=valid and matching origin.
|
||||
repo_decl_merge_method() {
|
||||
[ "$DECL_STATE" = valid ] || { repo_decl_warn "merge policy requires a valid declaration"; return 3; }
|
||||
python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$1" --base "$2" "${@:3}"
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-branch-absent}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
@@ -38,12 +37,6 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
# post-fix branch-lookup code paths faithfully.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
@@ -96,9 +89,6 @@ run_ci_queue_wait() {
|
||||
cd "$REPO_DIR"
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
|
||||
export MOSAIC_GIT_IDENTITY=test-identity
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031
|
||||
# Each fixture subshell intentionally isolates its credential environment.
|
||||
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
|
||||
# the sanctioned merge path for a repository with no CI configured at all.
|
||||
#
|
||||
@@ -27,7 +25,6 @@
|
||||
|
||||
set -u
|
||||
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
@@ -44,12 +41,6 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
@@ -114,9 +105,6 @@ run_guard() {
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
|
||||
export MOSAIC_GIT_IDENTITY=test-identity
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
@@ -135,9 +123,6 @@ run_guard_no_identity() {
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
|
||||
export MOSAIC_GIT_IDENTITY=test-identity
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
|
||||
export MOSAIC_STUB_URL_LOG="$URL_LOG"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
@@ -318,7 +303,7 @@ out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard
|
||||
rc_h=$?
|
||||
set -u
|
||||
if expect_rc h 78 "$rc_h"; then
|
||||
expect_text h "ASSERTION_UNATTRIBUTABLE state=unknown purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
|
||||
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
|
||||
expect_text h "queue-clear" "$out_h" absent
|
||||
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
|
||||
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
|
||||
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"
|
||||
@@ -49,12 +48,6 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
# is selected by MOSAIC_STUB_STATUS_MODE.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
@@ -110,9 +103,6 @@ run_ci_queue_wait() {
|
||||
cd "$REPO_DIR"
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
|
||||
export MOSAIC_GIT_IDENTITY=test-identity
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
set -u
|
||||
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
@@ -61,12 +60,6 @@ git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
@@ -150,9 +143,6 @@ run_guard() {
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_BRAIN_HOME="$WORK_DIR/fixture-brain"
|
||||
export MOSAIC_GIT_IDENTITY=test-identity
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$WORK_DIR/home" "$MOSAIC_BRAIN_HOME" "$MOSAIC_GIT_IDENTITY" git.example.test stub-token
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
@@ -20,10 +20,6 @@ fail=0
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
mkdir -p "$TMP/bin"
|
||||
cp "$HERE/fixture-schema-client.sh" "$TMP/bin/curl"
|
||||
export PATH="$TMP/bin:$PATH"
|
||||
|
||||
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=$?
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Synthetic-only credential, xtrace, and termination controls."""
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
HERE=pathlib.Path(__file__).resolve().parent
|
||||
|
||||
|
||||
LEGACY_CURL = '''#!/usr/bin/env -S python3 -I
|
||||
import json,os,pathlib,sys,urllib.parse
|
||||
a=sys.argv[1:]; secret=os.environ['TEST_AUTH_SECRET']; url=next((x for x in reversed(a) if x.startswith(('https://','http://'))),'')
|
||||
u=urllib.parse.urlsplit(url)
|
||||
if u.scheme!='https' or u.netloc!='forge.example.test' or not (u.path.startswith('/api/v1/') or u.path=='/swagger.v1.json'): sys.exit(98)
|
||||
method='GET'
|
||||
for flag in ['-X','--request']:
|
||||
if flag in a: method=a[a.index(flag)+1]
|
||||
config=''; anonymous=False
|
||||
for flag in ['--config','-K']:
|
||||
if flag in a:
|
||||
value=a[a.index(flag)+1]; anonymous=value=='-' or value.startswith('/dev/fd/')
|
||||
config=sys.stdin.read() if value=='-' else pathlib.Path(value).read_text()
|
||||
observed={'path':u.path,'method':method,'argv_leak':any(secret in x for x in a),'authenticated':config=='header = '+json.dumps('Authorization: token '+secret)+chr(10),'anonymous':anonymous}
|
||||
with open(os.environ['TEST_AUTH_OBSERVATIONS'],'a') as f: f.write(json.dumps(observed)+chr(10))
|
||||
if observed['argv_leak']: sys.exit(97)
|
||||
head='a'*40; base='b'*40
|
||||
pull=dict(number=1,state='open',draft=False,merged=False,mergeable=True,title='Title',head={'sha':head,'ref':'task','repo':{'full_name':'owner/repo'}},base={'sha':base,'ref':'main','repo':{'full_name':'owner/repo'}},user={'login':'author'})
|
||||
if u.path=='/swagger.v1.json': body={'info':{'title':'Gitea API','version':'1.24.7'}}
|
||||
elif u.path.endswith('/user'): body={'login':'fixture-user'}
|
||||
elif '/branches/' in u.path: body={'commit':{'id':head}}
|
||||
elif u.path.endswith('/status'): body={'state':'success','statuses':[{'context':'build','status':'success'}]}
|
||||
elif u.path.endswith('/pulls/1'): body=pull
|
||||
elif u.path.endswith('/pulls/1.diff'): body='diff --git a/example b/example'
|
||||
elif u.path.endswith('/issues/1'): body=dict(number=1,title='Title',state='open',user={'login':'author'},created_at='',body='Body',comments=0)
|
||||
elif u.path.endswith('/comments'): body=[] if method=='GET' else {'id':7,'body':'body','user':{'login':'fixture-user'},'issue_url':'https://forge.example.test/owner/repo/issues/1'}
|
||||
elif u.path.endswith('/repos/owner/repo'): body={'default_branch':'main','permissions':{'admin':True}}
|
||||
else: body={'id':7,'number':1,'login':'fixture-user','name':'reviewers','merged':True}
|
||||
text=body if isinstance(body,str) else json.dumps(body)
|
||||
if '-o' in a: pathlib.Path(a[a.index('-o')+1]).write_text(text)
|
||||
else: print(text,end='')
|
||||
if '-w' in a:
|
||||
fmt=a[a.index('-w')+1].replace('%{http_code}','200').replace(chr(92)+'n',chr(10))
|
||||
print(fmt,end='')
|
||||
'''
|
||||
|
||||
LEGACY_CASES = {
|
||||
'ci-queue-wait.sh': (['--purpose','push','-B','task','-t','1','-i','1'], 'GET', '/commits/'+'a'*40+'/status'),
|
||||
'grant-reviewer.sh': (['-u','reviewer'], 'GET', '/orgs/owner'),
|
||||
'issue-close.sh': (['-i','1'], 'PATCH', '/issues/1'),
|
||||
'issue-comment.sh': (['-i','1','-b','body'], 'POST', '/issues/1/comments'),
|
||||
'issue-create.sh': (['-t','Title','-b','body'], 'POST', '/issues'),
|
||||
'issue-reopen.sh': (['-i','1'], 'PATCH', '/issues/1'),
|
||||
'issue-view.sh': (['-i','1'], 'GET', '/issues/1'),
|
||||
'pr-create.sh': (['-t','Title','-H','task','-B','main'], 'POST', '/pulls'),
|
||||
'pr-diff.sh': (['-n','1'], 'GET', '/pulls/1.diff'),
|
||||
'pr-edit.sh': (['-n','1','--login','fixture-user','--title','Title'], 'PATCH', '/pulls/1'),
|
||||
'pr-merge.sh': (['-n','1','--expect-head','a'*40], 'POST', '/pulls/1/merge'),
|
||||
'pr-metadata.sh': (['-n','1'], 'GET', '/pulls/1'),
|
||||
'pr-review.sh': (['-n','1','-a','comment','-c','body'], 'POST', '/issues/1/comments'),
|
||||
}
|
||||
|
||||
|
||||
class Auth(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
|
||||
self.root=pathlib.Path(self.tmp.name)
|
||||
self.env=dict(os.environ,HOME=str(self.root),MOSAIC_BRAIN_HOME=str(self.root/'brain'),GIT_CONFIG_GLOBAL='/dev/null',GITEA_TOKEN='shared-synthetic',GITEA_URL='https://forge.example.test',TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root))
|
||||
self.env.pop('MOSAIC_GIT_IDENTITY',None)
|
||||
self.token=self.root/'brain/credentials/forge/forge.example.test/fixture-user.token'
|
||||
self.token.parent.mkdir(parents=True); self.token.write_text('private-synthetic-token')
|
||||
def credential(self,trace=False):
|
||||
return subprocess.run(['bash',*(['-x'] if trace else []),'-c','source "$1/detect-platform.sh"; get_gitea_token forge.example.test','fixture',str(HERE)],env=self.env,cwd=self.root,capture_output=True,text=True)
|
||||
def test_no_identity_cannot_borrow_shared_or_git_config(self):
|
||||
subprocess.run(['git','init','-q',str(self.root)],check=True,env=self.env)
|
||||
subprocess.run(['git','-C',str(self.root),'config','mosaic.gitIdentity','fixture-user'],check=True,env=self.env)
|
||||
p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'')
|
||||
def test_host_bound_credential_and_xtrace(self):
|
||||
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
|
||||
p=self.credential(True); self.assertEqual(p.returncode,0,p.stderr)
|
||||
self.assertEqual(p.stdout.strip(),'private-synthetic-token')
|
||||
self.assertNotIn('private-synthetic-token',p.stderr)
|
||||
self.token.unlink(); p=self.credential(); self.assertNotEqual(p.returncode,0); self.assertEqual(p.stdout,'')
|
||||
def test_provider_store_separates_seat_and_service(self):
|
||||
import re
|
||||
mappings=re.findall(r'^\s*([A-Za-z0-9.-]+)\)\s+_idpfx=([A-Za-z0-9-]+)',(HERE/'detect-platform.sh').read_text(),re.MULTILINE)
|
||||
self.assertTrue(mappings)
|
||||
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
|
||||
for host,prefix in mappings:
|
||||
service=self.root/'.config/mosaic/secrets/gitea-tokens'/(prefix+'-fixture-user.token')
|
||||
service.parent.mkdir(parents=True,exist_ok=True); service.write_text('service-synthetic')
|
||||
seat=self.root/'brain/fleet/agents/fixture-user/secrets'
|
||||
seat.mkdir(parents=True,exist_ok=True)
|
||||
command=['bash','-c','source "$1/detect-platform.sh"; get_gitea_token "$2"','fixture',str(HERE),host]
|
||||
p=subprocess.run(command,env=self.env,capture_output=True,text=True)
|
||||
self.assertNotEqual(p.returncode,0,'seat borrowed service token')
|
||||
token=seat/(prefix+'-fixture-user.token'); token.write_text('seat-synthetic')
|
||||
p=subprocess.run(command,env=self.env,capture_output=True,text=True)
|
||||
self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(p.stdout,'seat-synthetic')
|
||||
|
||||
def legacy_fixture(self):
|
||||
import shutil
|
||||
tools=self.root/'tools'
|
||||
shutil.copytree(HERE,tools,ignore=shutil.ignore_patterns('__pycache__','.work'))
|
||||
repo=self.root/'repo'; repo.mkdir()
|
||||
subprocess.run(['git','init','-q','-b','task',str(repo)],check=True,env=self.env)
|
||||
subprocess.run(['git','-C',str(repo),'remote','add','origin','https://forge.example.test/owner/repo'],check=True,env=self.env)
|
||||
bin_dir=self.root/'bin';bin_dir.mkdir()
|
||||
(bin_dir/'curl').write_text(LEGACY_CURL);(bin_dir/'curl').chmod(0o755)
|
||||
(bin_dir/'tea').write_text('#!/bin/sh\nexit 1\n');(bin_dir/'tea').chmod(0o755)
|
||||
for name in ['gh','glab','wget','ssh']:
|
||||
(bin_dir/name).write_text('#!/bin/sh\nexit 99\n');(bin_dir/name).chmod(0o755)
|
||||
config=self.root/'.config/tea';config.mkdir(parents=True)
|
||||
(config/'config.yml').write_text('logins:\n - name: fixture-user\n url: https://forge.example.test\n token: private-synthetic-token\n')
|
||||
credentials=self.root/'credentials.json';credentials.write_text('{}')
|
||||
self.env.update(PATH=str(bin_dir)+os.pathsep+os.environ['PATH'],MOSAIC_GIT_IDENTITY='fixture-user',XDG_CONFIG_HOME=str(self.root/'.config'),MOSAIC_CREDENTIALS_FILE=str(credentials),GITEA_LOGIN='fixture-user',GITEA_URL='https://forge.example.test',TEST_AUTH_SECRET='private-synthetic-token',TEST_AUTH_OBSERVATIONS=str(self.root/'observations.jsonl'),GIT_CONFIG_NOSYSTEM='1')
|
||||
for key in ['GIT_DIR','GIT_WORK_TREE','BASH_ENV']: self.env.pop(key,None)
|
||||
return tools,repo
|
||||
|
||||
def legacy_call(self,tools,repo,name):
|
||||
import json
|
||||
log=self.root/'observations.jsonl'
|
||||
if log.exists(): log.unlink()
|
||||
args,method,suffix=LEGACY_CASES[name]
|
||||
p=subprocess.run(['bash','-x',str(tools/name),*args],cwd=repo,env=self.env,capture_output=True,text=True,timeout=30)
|
||||
observations=[json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
|
||||
# Never put captured traces, argv or credential values in assertion output.
|
||||
leaked=self.env['TEST_AUTH_SECRET'] in p.stdout+p.stderr
|
||||
authenticated=any(o['authenticated'] and o['method']==method and o['path'].endswith(suffix) for o in observations)
|
||||
return dict(exit_code=p.returncode,trace_leak=leaked,argv_leak=any(o['argv_leak'] for o in observations),authenticated=authenticated,anonymous=all(o['anonymous'] for o in observations if o['authenticated']))
|
||||
|
||||
def test_all_legacy_callers_keep_credentials_private_under_xtrace(self):
|
||||
tools,repo=self.legacy_fixture()
|
||||
for name in LEGACY_CASES:
|
||||
with self.subTest(wrapper=name):
|
||||
observed=self.legacy_call(tools,repo,name)
|
||||
self.assertTrue(observed['authenticated'],name+' did not reach its credential-consuming request; exit '+str(observed['exit_code']))
|
||||
self.assertFalse(observed['trace_leak'],name+' exposed credential in captured trace')
|
||||
self.assertFalse(observed['argv_leak'],name+' exposed credential in argv')
|
||||
self.assertTrue(observed['anonymous'],name+' did not use anonymous authentication')
|
||||
|
||||
def test_legacy_anonymous_config_escapes_credential(self):
|
||||
tools,repo=self.legacy_fixture()
|
||||
secret='quoted"'+chr(92)+'credential'
|
||||
self.token.write_text(secret)
|
||||
self.env['TEST_AUTH_SECRET']=secret
|
||||
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
|
||||
self.assertTrue(observed['authenticated'],'credential config escaping failed')
|
||||
self.assertTrue(observed['anonymous'])
|
||||
self.assertFalse(observed['trace_leak'] or observed['argv_leak'])
|
||||
|
||||
def test_metadata_xtrace_mutant_is_detected(self):
|
||||
tools,repo=self.legacy_fixture()
|
||||
metadata=tools/'pr-metadata.sh'
|
||||
source=metadata.read_text()
|
||||
marker='mosaic_private_forge_main() (\n set +x'
|
||||
self.assertIn(marker,source)
|
||||
metadata.write_text(source.replace(marker,'mosaic_private_forge_main() (\n :',1))
|
||||
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
|
||||
self.assertTrue(observed['authenticated'])
|
||||
self.assertTrue(observed['trace_leak'],'removing caller privacy was not detected')
|
||||
|
||||
def test_legacy_argv_mutant_is_detected(self):
|
||||
tools,repo=self.legacy_fixture()
|
||||
helper=tools/'detect-platform.sh'
|
||||
source=helper.read_text()
|
||||
marker='curl --config "/dev/fd/$auth_fd" "$@"'
|
||||
self.assertIn(marker,source)
|
||||
helper.write_text(source.replace(marker,'curl -H "Authorization: token $token" "$@"',1))
|
||||
observed=self.legacy_call(tools,repo,'pr-metadata.sh')
|
||||
self.assertTrue(observed['argv_leak'],'raw-header mutation was not detected')
|
||||
self.assertFalse(observed['trace_leak'])
|
||||
self.assertNotEqual(observed['exit_code'],0)
|
||||
|
||||
def test_legacy_consumer_inventory_has_wrapper_controls(self):
|
||||
import re
|
||||
consumers={p.name for p in HERE.glob('*.sh') if not p.name.startswith(('test-','fixture-')) and p.name!='detect-platform.sh' and re.search(r'\$\(get_gitea_token(?:_for_login)?\b',p.read_text())}
|
||||
self.assertEqual(consumers,set(LEGACY_CASES))
|
||||
|
||||
def test_sigterm_leaves_no_credential_files(self):
|
||||
# Child blocks inside curl's transport; only anonymous fds hold auth.
|
||||
self.env['MOSAIC_GIT_IDENTITY']='fixture-user'
|
||||
script='''import importlib.util,os,pathlib,signal,sys,time
|
||||
s=importlib.util.spec_from_file_location('a',sys.argv[1]); a=importlib.util.module_from_spec(s); s.loader.exec_module(a)
|
||||
c=a.Client('forge.example.test','owner/repo'); c.provider='gitea'
|
||||
def transport(args,body,writing):
|
||||
assert all('private-synthetic-token' not in x for x in args)
|
||||
config=args[args.index('--config')+1]
|
||||
assert config.startswith('/dev/fd/')
|
||||
assert 'private-synthetic-token' in pathlib.Path(config).read_text()
|
||||
pathlib.Path(sys.argv[2]).write_text('ready')
|
||||
while True: time.sleep(1)
|
||||
c.transport=transport
|
||||
c.http('/repos/owner/repo')
|
||||
'''
|
||||
ready=self.root/'ready'
|
||||
before=set(self.root.rglob('*'))
|
||||
child=subprocess.Popen(['python3','-I','-c',script,str(HERE/'forge-api.py'),str(ready)],env=self.env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
|
||||
try:
|
||||
deadline=time.monotonic()+10
|
||||
while not ready.exists() and child.poll() is None and time.monotonic()<deadline: time.sleep(.02)
|
||||
self.assertTrue(ready.exists(),'transport did not receive auth fd')
|
||||
child.terminate(); child.communicate(timeout=5)
|
||||
self.assertEqual(child.returncode,-signal.SIGTERM)
|
||||
self.assertEqual(set(self.root.rglob('*'))-before,{ready})
|
||||
finally:
|
||||
if child.poll() is None: child.kill(); child.communicate()
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/test-forge-contract.py"
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Shell entry points with network-denying fake provider executables and isolated HOME."""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE=pathlib.Path(__file__).resolve().parent
|
||||
FAKE='''#!/usr/bin/env -S python3 -I
|
||||
import json,os,pathlib,sys,urllib.parse
|
||||
args=sys.argv[1:]
|
||||
if any('fixture-token' in a for a in args):
|
||||
print('credential exposed in argv',file=sys.stderr); sys.exit(97)
|
||||
if '--config' in args:
|
||||
config=pathlib.Path(args[args.index('--config')+1])
|
||||
if config.read_text() != 'header = "Authorization: token fixture-token"\\n':
|
||||
print('invalid protected auth config',file=sys.stderr); sys.exit(96)
|
||||
method='GET'; body=None
|
||||
if '--method' in args: method=args[args.index('--method')+1]
|
||||
if '--request' in args: method=args[args.index('--request')+1]
|
||||
if '--url' not in args:
|
||||
print('fake provider requires a full URL',file=sys.stderr); sys.exit(98)
|
||||
url=args[args.index('--url')+1]
|
||||
fixtures=json.loads(pathlib.Path(os.environ['FAKE_RESPONSES']).read_text())
|
||||
entry=fixtures.get(method+' '+url)
|
||||
if entry is None:
|
||||
print('unexpected provider URL '+url,file=sys.stderr); sys.exit(98)
|
||||
response=entry['response']; path=entry['path']
|
||||
query=urllib.parse.urlsplit(url).query
|
||||
if '--input' in args or '--data-binary' in args:
|
||||
body=json.load(sys.stdin)
|
||||
with open(os.environ['FAKE_LOG'],'a') as f: f.write(json.dumps({'method':method,'url':url,'path':path,'query':query,'body':body})+'\\n')
|
||||
status=response.get('status',200)
|
||||
print('HTTP/2 '+str(status))
|
||||
for k,v in response.get('headers',{}).items(): print(k+': '+str(v))
|
||||
print()
|
||||
print(json.dumps(response.get('data')))
|
||||
sys.exit(1 if pathlib.Path(sys.argv[0]).name=='gh' and status>=400 else 0)
|
||||
'''
|
||||
|
||||
class CLI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory()
|
||||
self.root=pathlib.Path(self.tmp.name)
|
||||
(self.root/'home').mkdir()
|
||||
for client in ['curl','gh']:
|
||||
p=self.root/client; p.write_text(FAKE); p.chmod(0o755)
|
||||
self.env=dict(os.environ,HOME=str(self.root/'home'),TMPDIR=str(self.root),TMP=str(self.root),TEMP=str(self.root),PATH=str(self.root)+os.pathsep+os.environ['PATH'],GIT_CONFIG_GLOBAL='/dev/null',MOSAIC_GIT_IDENTITY='',GITEA_TOKEN='fixture-token',GITEA_URL='https://git.example.test',FAKE_RESPONSES=str(self.root/'responses.json'),FAKE_LOG=str(self.root/'calls.jsonl'))
|
||||
for key in ['BASH_ENV','GH_TOKEN','GITHUB_TOKEN','GH_ENTERPRISE_TOKEN','GH_HOST','GITEA_LOGIN','GIT_DIR','GIT_WORK_TREE']:
|
||||
self.env.pop(key,None)
|
||||
self.env.update(MOSAIC_GIT_IDENTITY='fixture-user',MOSAIC_BRAIN_HOME=str(self.root/'home/.mosaic'),XDG_CONFIG_HOME=str(self.root/'home/.config'),MOSAIC_CREDENTIALS_FILE=str(self.root/'fixture-credentials.json'))
|
||||
(self.root/'fixture-credentials.json').write_text('{}')
|
||||
for host in ['github.com', 'git.example.test']:
|
||||
token_dir=self.root/'home/.mosaic/credentials/forge'/host
|
||||
token_dir.mkdir(parents=True)
|
||||
(token_dir/'fixture-user.token').write_text('fixture-token')
|
||||
self.fixtures={}
|
||||
def tearDown(self): self.tmp.cleanup()
|
||||
def write_responses(self, host='github.com'):
|
||||
# Test-owned endpoint table. Never derive expected URLs from the adapter.
|
||||
github=host=='github.com' or host==self.env.get('GH_HOST')
|
||||
rest=('https://api.github.com' if host=='github.com' else 'https://'+host+'/api/v3') if github else 'https://'+host+'/api/v1'
|
||||
graphql='https://api.github.com/graphql' if host=='github.com' else 'https://'+host+'/api/graphql'
|
||||
expected={}
|
||||
for key,response in self.fixtures.items():
|
||||
method,path=key.split(' ',1)
|
||||
url=graphql if path=='graphql' else 'https://'+host+path if path.startswith('/swagger.v1.json') else rest+path
|
||||
entry={'path':path.split('?',1)[0],'response':response}
|
||||
expected[method+' '+url]=entry
|
||||
if '?' not in path and path!='graphql' and not path.startswith('/swagger.v1.json'):
|
||||
pagination='per_page' if github else 'limit'
|
||||
expected[method+' '+url+'?'+pagination+'=100&page=1']=entry
|
||||
(self.root/'responses.json').write_text(json.dumps(expected))
|
||||
|
||||
def run_wrapper(self,name,*args,host='github.com'):
|
||||
self.write_responses(host)
|
||||
p=subprocess.run(['bash',str(HERE/(name+'.sh')),'--host',host,'--repo','owner/repo','--json',*args],cwd=self.root,env=self.env,capture_output=True,text=True)
|
||||
self.assertNotIn('fixture-token',p.stdout+p.stderr)
|
||||
self.assertEqual(list(self.root.glob('mosaic-forge-auth-*')),[], 'credential config leaked')
|
||||
return p,json.loads(p.stdout) if p.stdout.strip() else {}
|
||||
def calls(self):
|
||||
p=self.root/'calls.jsonl'
|
||||
return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else []
|
||||
def gitea(self,version='1.24.7',title='Gitea API'):
|
||||
self.fixtures['GET /swagger.v1.json']={'data':{'info':{'title':title,'version':version}}}
|
||||
def test_missing_identity_refuses_before_requests(self):
|
||||
self.env.pop('MOSAIC_GIT_IDENTITY')
|
||||
p,v=self.run_wrapper('pr-auto-merge','-n','1','--action','disable','--expect-head','a'*40)
|
||||
self.assertEqual(p.returncode,5)
|
||||
self.assertEqual(self.calls(),[])
|
||||
|
||||
def test_mutation_verifies_actor(self):
|
||||
self.fixtures['GET /user']={'data':{'login':'other-actor'}}
|
||||
# Trigger transport directly so a malformed PR cannot hide the actor gate.
|
||||
script = "import importlib.util; s=importlib.util.spec_from_file_location('a', %r); a=importlib.util.module_from_spec(s); s.loader.exec_module(a); c=a.Client('github.com','owner/repo'); c.provider='github'; c.http('/mutation','PUT',{})" % str(HERE/'forge-api.py')
|
||||
self.write_responses()
|
||||
r=subprocess.run(['python3','-I','-c',script],env=self.env,capture_output=True,text=True)
|
||||
self.assertNotEqual(r.returncode,0)
|
||||
self.assertEqual([(c['method'],c['path']) for c in self.calls()],[('GET','/user')])
|
||||
|
||||
def test_enterprise_auth_uses_explicit_host_store(self):
|
||||
host='enterprise.example.test'
|
||||
self.env['GH_HOST']=host
|
||||
directory=self.root/'home/.mosaic/credentials/forge'/host
|
||||
directory.mkdir(parents=True); (directory/'fixture-user.token').write_text('fixture-token')
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1']={'status':403,'data':{'message':'denied'}}
|
||||
p,_=self.run_wrapper('pr-status','-n','1',host=host)
|
||||
self.assertEqual(p.returncode,5)
|
||||
self.assertEqual(self.calls()[0]['path'],'/repos/owner/repo/pulls/1')
|
||||
|
||||
def test_public_and_enterprise_snapshot_use_exact_api_urls(self):
|
||||
for host, rest, graphql in [
|
||||
('github.com','https://api.github.com','https://api.github.com/graphql'),
|
||||
('enterprise.example.test','https://enterprise.example.test/api/v3','https://enterprise.example.test/api/graphql'),
|
||||
]:
|
||||
with self.subTest(host=host):
|
||||
if host!='github.com':
|
||||
self.env['GH_HOST']=host
|
||||
directory=self.root/'home/.mosaic/credentials/forge'/host
|
||||
directory.mkdir(parents=True,exist_ok=True)
|
||||
(directory/'fixture-user.token').write_text('fixture-token')
|
||||
self.fixtures={
|
||||
'GET /repos/owner/repo/pulls/1':{'data':dict(number=1,state='open',draft=False,merged=False,mergeable=True,head={'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}},base={'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},user={'login':'author'})},
|
||||
'GET /repos/owner/repo/branches/integration/protection':{'data':{'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}},
|
||||
'GET /repos/owner/repo/commits/'+'a'*40+'/statuses':{'data':[{'context':'build','state':'success','id':1}]},
|
||||
'GET /repos/owner/repo/commits/'+'a'*40+'/check-runs':{'data':{'total_count':0,'check_runs':[]}},
|
||||
'GET /repos/owner/repo/pulls/1/reviews':{'data':[{'id':1,'state':'APPROVED','commit_id':'a'*40,'user':{'login':'reviewer'}}]},
|
||||
'GET /repos/owner/repo/pulls/1/comments':{'data':[]},
|
||||
'GET /repos/owner/repo/issues/1/comments':{'data':[]},
|
||||
'POST graphql':{'data':{'data':{'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}}},
|
||||
}
|
||||
log=self.root/'calls.jsonl'
|
||||
if log.exists(): log.unlink()
|
||||
p,value=self.run_wrapper('pr-status','-n','1',host=host)
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
self.assertTrue(value['data']['ready'])
|
||||
self.assertEqual(self.calls()[0]['url'],rest+'/repos/owner/repo/pulls/1')
|
||||
self.assertIn(graphql,[c['url'] for c in self.calls()])
|
||||
self.assertTrue(all(c['url'].startswith(rest+'/repos/') or c['url']==graphql for c in self.calls()))
|
||||
|
||||
def test_fake_rejects_unexpected_full_urls(self):
|
||||
self.fixtures={'GET /repos/owner/repo':{'data':{}}}
|
||||
self.write_responses()
|
||||
for url in ['https://github.com/repos/owner/repo','http://api.github.com/repos/owner/repo','https://other.example.test/repos/owner/repo','https://api.github.com/wrong','https://api.github.com/repos/owner/repo?unexpected=1']:
|
||||
p=subprocess.run([str(self.root/'curl'),'--request','GET','--url',url],env=self.env,capture_output=True,text=True)
|
||||
self.assertEqual(p.returncode,98,url)
|
||||
|
||||
def test_profiles_and_unknown_host_fail_closed(self):
|
||||
for version,title,provider in [('28.2.0','Gitea API','gitea'),('1.24.7','Gitea API','gitea'),('16.0.0-dev','Forgejo API','forgejo')]:
|
||||
self.gitea(version,title)
|
||||
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr); self.assertEqual(v['provider'],provider)
|
||||
self.gitea('1','Unrelated application')
|
||||
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
|
||||
self.assertEqual(p.returncode,4); self.assertEqual(v['error']['kind'],'unsupported')
|
||||
def test_unsupported_reply_has_no_mutation(self):
|
||||
self.gitea()
|
||||
body=self.root/'body'; body.write_text('reply')
|
||||
p,v=self.run_wrapper('pr-review-reply','-n','1','--comment-id','gitea:comment:2','--body-file',str(body),'--expect-head','a'*40,host='git.example.test')
|
||||
self.assertEqual(p.returncode,4,p.stderr)
|
||||
self.assertTrue(all(c['method']=='GET' for c in self.calls()))
|
||||
def test_auth_denial_and_rate_limit_codes(self):
|
||||
for status,headers,expected in [(401,{},5),(403,{},5),(404,{},5),(429,{'Retry-After':23},75)]:
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1']={'status':status,'headers':headers,'data':{'message':'refused'}}
|
||||
p,v=self.run_wrapper('pr-status','-n','1')
|
||||
self.assertEqual(p.returncode,expected,p.stderr)
|
||||
self.assertFalse(v['data']['ready'])
|
||||
if status==429: self.assertEqual(v['error']['retry_after_seconds'],23)
|
||||
def test_hostile_python_modules_are_not_imported(self):
|
||||
(self.root/'json.py').write_text("raise RuntimeError('repository module executed')")
|
||||
self.gitea()
|
||||
p,v=self.run_wrapper('forge-capabilities',host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
def test_gitea_review_pages_preserve_resolved_outdated(self):
|
||||
self.gitea('28.2.0')
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'head':{'sha':'a'*40}}}
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1},{'id':2}]}
|
||||
for rid in [1,2]:
|
||||
self.fixtures[f'GET /repos/owner/repo/pulls/1/reviews/{rid}/comments']={'data':[{'id':rid,'body':'same body','resolver':{'login':'reviewer'} if rid==1 else None,'outdated':rid==1,'user':{'login':'reviewer'}}]}
|
||||
p,v=self.run_wrapper('pr-review-comments','-n','1',host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
self.assertEqual(len(v['data']['comments']),2)
|
||||
self.assertEqual(v['data']['comments'][0]['resolved'],True)
|
||||
self.assertIsNone(v['data']['comments'][0]['thread_id'])
|
||||
self.assertEqual(v['data']['comments'][1]['resolved'],False)
|
||||
|
||||
|
||||
|
||||
def test_gitea_status_and_ci_wait_use_exact_head(self):
|
||||
self.gitea()
|
||||
head='a'*40
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1']={'data':{'number':1,'state':'open','draft':False,'merged':False,'mergeable':True,'head':{'sha':head,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'b'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'}}}
|
||||
self.fixtures['GET /repos/owner/repo/branch_protections']={'data':[{'branch_name':'integration','enable_status_check':True,'status_check_contexts':['build'],'required_approvals':1}]}
|
||||
self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[{'id':1,'context':'build','state':'success'}]}
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1/reviews']={'data':[{'id':1,'state':'APPROVED','commit_id':head,'user':{'login':'reviewer'}}]}
|
||||
self.fixtures['GET /repos/owner/repo/pulls/1/reviews/1/comments']={'data':[]}
|
||||
self.fixtures['GET /repos/owner/repo/issues/1/comments']={'data':[]}
|
||||
p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1','--expect-head',head,host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
self.assertEqual(v['data']['checks']['state'],'success')
|
||||
self.assertEqual(v['data']['head_repository'],'fork/repo')
|
||||
self.assertTrue(any(c['path'].endswith('/commits/'+head+'/statuses') for c in self.calls()))
|
||||
self.fixtures['GET /repos/owner/repo/commits/'+head+'/statuses']={'data':[]}
|
||||
p,v=self.run_wrapper('pr-ci-wait','-n','1','-t','1','-i','1',host='git.example.test')
|
||||
self.assertEqual(p.returncode,124,p.stderr)
|
||||
self.assertFalse(v['data']['ready'])
|
||||
|
||||
|
||||
|
||||
def test_watch_predicate_preserves_baseline_and_read_errors(self):
|
||||
self.test_gitea_status_and_ci_wait_use_exact_head()
|
||||
key='GET /repos/owner/repo/commits/'+'a'*40+'/statuses'
|
||||
self.fixtures[key]={'data':[{'id':1,'context':'build','state':'pending'}]}
|
||||
p,baseline=self.run_wrapper('pr-status','-n','1',host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
path=self.root/'baseline.json'; path.write_text(json.dumps(baseline))
|
||||
before=path.read_text()
|
||||
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
|
||||
self.assertEqual(p.returncode,1,p.stderr)
|
||||
self.assertEqual(path.read_text(),before)
|
||||
self.fixtures[key]={'data':[{'id':1,'context':'build','state':'failure'}]}
|
||||
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
|
||||
self.assertEqual(p.returncode,0,p.stderr)
|
||||
self.assertEqual(value['data']['checks']['state'],'failure')
|
||||
self.fixtures[key]={'status':403,'data':{'message':'denied'}}
|
||||
p,value=self.run_wrapper('pr-watch-check','-n','1','--baseline',str(path),host='git.example.test')
|
||||
self.assertEqual(p.returncode,5,p.stderr)
|
||||
self.assertFalse(value['data']['checks']['complete'])
|
||||
self.assertEqual(path.read_text(),before)
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -1,446 +0,0 @@
|
||||
"""Offline contracts. Fake responses replace the provider HTTP boundary."""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
PATH = pathlib.Path(__file__).with_name('forge-api.py')
|
||||
|
||||
class Contract(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if PATH.is_file():
|
||||
spec = importlib.util.spec_from_file_location('forge_api', PATH)
|
||||
cls.api = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.api)
|
||||
|
||||
def setUp(self):
|
||||
self.assertTrue(PATH.is_file(), 'normalized adapter is missing')
|
||||
|
||||
def test_profiles_require_schema_evidence(self):
|
||||
for provider, version, reply in [('gitea', '28.2.0', 'supported'), ('gitea', '1.24.7', 'unsupported'), ('forgejo', '16.0.0-dev', 'unsupported')]:
|
||||
p = self.api.capabilities(provider, version)
|
||||
self.assertEqual(p['review_reply']['support'], reply)
|
||||
self.assertEqual(p['auto_merge']['support'], 'unsupported')
|
||||
self.assertEqual(self.api.capabilities('gitea', '99.0.0')['review_reply']['support'], 'unknown')
|
||||
self.assertEqual(self.api.capabilities('unknown', '')['status']['support'], 'unknown')
|
||||
|
||||
def test_fingerprint_ignores_clock_but_tracks_evidence(self):
|
||||
a = {'head_sha': 'a', 'observed_at': 'one', 'checks': {'state': 'pending'}}
|
||||
b = dict(a, observed_at='two')
|
||||
self.assertEqual(self.api.fingerprint(a), self.api.fingerprint(b))
|
||||
b['checks'] = {'state': 'failure'}
|
||||
self.assertNotEqual(self.api.fingerprint(a), self.api.fingerprint(b))
|
||||
|
||||
def test_empty_checks_are_not_green(self):
|
||||
result = self.api.checks([], [], ['build'])
|
||||
self.assertEqual(result['state'], 'missing')
|
||||
self.assertTrue(result['complete'])
|
||||
self.assertEqual(self.api.checks([], [], None)['state'], 'unknown')
|
||||
self.assertEqual(self.api.checks([], [], [], allow_none=True)['state'], 'not_required')
|
||||
self.assertEqual(self.api.checks([], [], [])['state'], 'missing')
|
||||
|
||||
def test_required_checks_include_check_runs_and_latest_context(self):
|
||||
statuses = [{'context':'build','state':'failure','id':1}, {'context':'build','state':'success','id':2}]
|
||||
runs = [{'name':'test','status':'completed','conclusion':'success','id':3}]
|
||||
self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'success')
|
||||
runs[0]['conclusion'] = 'cancelled'
|
||||
self.assertEqual(self.api.checks(statuses, runs, ['build', 'test'])['state'], 'cancelled')
|
||||
self.assertEqual(self.api.checks(statuses, [], ['test'])['state'], 'missing')
|
||||
|
||||
def test_unrecognized_check_is_never_green(self):
|
||||
self.assertEqual(self.api.checks([{'id':1,'context':'build','state':'surprise'}], [], ['build'])['state'], 'unknown')
|
||||
|
||||
def test_required_review_count(self):
|
||||
r=[{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'}]
|
||||
self.assertFalse(self.api.reviews(r,'a','author',2)['approved'])
|
||||
|
||||
def test_review_must_be_independent_and_current(self):
|
||||
reviews = [{'id':1,'user':{'login':'reviewer'},'state':'APPROVED','commit_id':'old'}]
|
||||
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
|
||||
reviews[0]['commit_id'] = 'new'
|
||||
self.assertTrue(self.api.reviews(reviews, 'new', 'author')['approved'])
|
||||
reviews[0]['user']['login'] = 'author'
|
||||
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
|
||||
|
||||
|
||||
class SnapshotContract(Contract):
|
||||
def fake(self, race=False, missing=False):
|
||||
api = self.api
|
||||
class Fake(api.Client):
|
||||
def __init__(self):
|
||||
super().__init__('github.com', 'owner/repo')
|
||||
self.provider, self.version, self.reads = 'github', 'documented', 0
|
||||
def pr(self, number):
|
||||
self.reads += 1
|
||||
return {'number':number,'state':'open','draft':False,'merged':False,'html_url':'https://github.com/owner/repo/pull/1','head':{'sha':('b' if race and self.reads > 1 else 'a')*40,'ref':'task','repo':{'full_name':'fork/repo'}},'base':{'sha':'c'*40,'ref':'integration','repo':{'full_name':'owner/repo'}},'user':{'login':'author'},'mergeable':True,'mergeable_state':'clean'}
|
||||
def http(self, path, method='GET', body=None, public=False):
|
||||
if '/protection' in path:
|
||||
return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True}}
|
||||
raise AssertionError(path)
|
||||
def pages(self, path, key=None):
|
||||
if '/statuses/' in path or path.endswith('/statuses'):
|
||||
return [] if missing else [{'context':'build','state':'success','id':1}]
|
||||
if '/check-runs' in path:
|
||||
return []
|
||||
if '/reviews' in path:
|
||||
return [{'id':1,'state':'APPROVED','user':{'login':'reviewer'},'commit_id':'a'*40}]
|
||||
if '/comments' in path:
|
||||
return []
|
||||
raise AssertionError(path)
|
||||
def gql(self, query, variables):
|
||||
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
|
||||
return Fake()
|
||||
|
||||
def test_ready_needs_all_evidence(self):
|
||||
result = self.api.snapshot(self.fake(), 1, None)
|
||||
self.assertTrue(result['ready'])
|
||||
self.assertEqual(result['head_repository'], 'fork/repo')
|
||||
result = self.api.snapshot(self.fake(missing=True), 1, None)
|
||||
self.assertFalse(result['ready'])
|
||||
self.assertIn('checks_missing', result['blockers'])
|
||||
|
||||
def test_head_race_discards_readiness(self):
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.snapshot(self.fake(race=True), 1, None)
|
||||
self.assertEqual(ctx.exception.code, 75)
|
||||
|
||||
def test_partial_collections_fail(self):
|
||||
c = self.fake()
|
||||
def broken(path, key=None):
|
||||
raise self.api.Failure(5, 'access', 'denied')
|
||||
c.pages = broken
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.snapshot(c, 1, None)
|
||||
self.assertEqual(ctx.exception.code, 5)
|
||||
|
||||
|
||||
class TransportContract(Contract):
|
||||
def test_access_and_rate_limit_remain_distinct(self):
|
||||
import types
|
||||
original = self.api.run
|
||||
c = self.api.Client('github.com', 'owner/repo')
|
||||
c.provider = 'github'
|
||||
c.token = 'synthetic'
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
actor_patch = patch.dict(os.environ, MOSAIC_GIT_IDENTITY='fixture-user')
|
||||
actor_patch.start()
|
||||
try:
|
||||
for status, headers, expected in [(403, '', 5), (404, '', 5), (429, 'Retry-After: 37\n', 75)]:
|
||||
self.api.run = lambda *a, **kw: types.SimpleNamespace(returncode=0, stdout=f'HTTP/2 {status}\n{headers}\n{{"message":"denied"}}')
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
c.http('/repos/owner/repo')
|
||||
self.assertEqual(ctx.exception.code, expected)
|
||||
if status == 429:
|
||||
self.assertEqual(ctx.exception.retry, 37)
|
||||
finally:
|
||||
self.api.run = original
|
||||
actor_patch.stop()
|
||||
|
||||
def test_curl_errors_never_prove_reads_or_mutations(self):
|
||||
import types
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
for provider,host in [('github','github.com'),('github','enterprise.example.test'),('gitea','git.example.test'),('forgejo','forge.example.test')]:
|
||||
for code in [0,28,56]:
|
||||
for method in ['GET','PUT']:
|
||||
with self.subTest(provider=provider,host=host,code=code,method=method):
|
||||
c=self.api.Client(host,'owner/repo'); c.provider=provider
|
||||
c.token='synthetic'; c.actor_verified=True
|
||||
c.transport=lambda *a: types.SimpleNamespace(returncode=code,stdout='HTTP/2 200\n\n{"ok":true}')
|
||||
with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture-user'):
|
||||
if code==0:
|
||||
self.assertEqual(c.http('/repos/owner/repo',method),{'ok':True})
|
||||
else:
|
||||
with self.assertRaises(self.api.Failure) as caught:
|
||||
c.http('/repos/owner/repo',method)
|
||||
self.assertEqual(caught.exception.code,75)
|
||||
self.assertEqual(caught.exception.outcome,'unknown' if method=='PUT' else 'none')
|
||||
|
||||
def test_pagination_failure_is_not_empty_success(self):
|
||||
c = self.api.Client('github.com', 'owner/repo')
|
||||
c.provider = 'github'
|
||||
calls = []
|
||||
def response(path):
|
||||
calls.append(path)
|
||||
if 'page=2' in path:
|
||||
raise self.api.Failure(5, 'access', 'second page denied')
|
||||
return [{'id':i} for i in range(100)]
|
||||
c.http = response
|
||||
with self.assertRaises(self.api.Failure):
|
||||
c.pages('/collection')
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
|
||||
class ReviewContract(SnapshotContract):
|
||||
def test_unsupported_reply_makes_no_write(self):
|
||||
c = self.fake()
|
||||
c.provider, c.version = 'forgejo', '16.0.0-dev'
|
||||
calls = []
|
||||
c.http = lambda *args, **kwargs: calls.append(args)
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.reply(c, 1, 'forgejo:comment:3', 'body', 'a'*40)
|
||||
self.assertEqual(ctx.exception.code, 4)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_wrong_pr_comment_refuses_write(self):
|
||||
c = self.fake()
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.reply(c, 1, 'github:comment:99', 'body', 'a'*40)
|
||||
self.assertEqual(ctx.exception.code, 3)
|
||||
|
||||
def test_reply_verifies_exact_returned_identity(self):
|
||||
c = self.fake()
|
||||
original = self.api.review_comments
|
||||
calls = []
|
||||
def collection(*args):
|
||||
return {'complete':True,'comments':[{'id':'github:comment:3','thread_id':'github:thread:T','resolved':False}], 'reviews':[]}, [], None
|
||||
def request(path, method='GET', body=None, public=False):
|
||||
calls.append((path,method,body))
|
||||
if path == '/user':
|
||||
return {'login':'acting-user'}
|
||||
if method == 'POST':
|
||||
return {'id':8}
|
||||
if path.endswith('/8'):
|
||||
return {'id':8,'body':'body','user':{'login':'acting-user'},'in_reply_to_id':3,'pull_request_url':'https://api.github.com/repos/owner/repo/pulls/1','commit_id':'a'*40}
|
||||
raise AssertionError(path)
|
||||
c.http = request
|
||||
try:
|
||||
self.api.review_comments = collection
|
||||
result = self.api.reply(c, 1, 'github:comment:3', 'body', 'a'*40)
|
||||
self.assertEqual(result['id'], 'github:comment:8')
|
||||
self.assertTrue(result['verified'])
|
||||
finally:
|
||||
self.api.review_comments = original
|
||||
self.assertEqual(sum(method == 'POST' for _,method,_ in calls), 1)
|
||||
|
||||
def test_enterprise_reply_readback_uses_api_prefix(self):
|
||||
c=self.fake(); c.host='git.example.test'
|
||||
original=self.api.review_comments
|
||||
self.api.review_comments=lambda *args: ({'complete':True,'reviews':[],'comments':[{'id':'github:comment:3','thread_id':'github:thread:T'}]},[],None)
|
||||
def request(path,method='GET',body=None,public=False):
|
||||
if path=='/user': return {'login':'reviewer'}
|
||||
if method=='POST': return {'id':8}
|
||||
return {'id':8,'body':'body','user':{'login':'reviewer'},'in_reply_to_id':3,'pull_request_url':'https://git.example.test/api/v3/repos/owner/repo/pulls/1'}
|
||||
c.http=request
|
||||
try:
|
||||
self.assertTrue(self.api.reply(c,1,'github:comment:3','body','a'*40)['verified'])
|
||||
finally:
|
||||
self.api.review_comments=original
|
||||
|
||||
def test_resolve_requires_thread_owned_by_pr(self):
|
||||
c = self.fake()
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.resolve(c, 1, 'github:thread:foreign', True, 'a'*40)
|
||||
self.assertEqual(ctx.exception.code, 3)
|
||||
|
||||
|
||||
class IntegrationContract(SnapshotContract):
|
||||
def test_unsupported_integration_performs_no_mutation(self):
|
||||
for provider, version in [('gitea','28.2.0'), ('gitea','1.24.7'), ('forgejo','16.0.0-dev')]:
|
||||
c = self.fake()
|
||||
c.provider, c.version = provider, version
|
||||
calls = []
|
||||
c.http = lambda *args, **kwargs: calls.append((args,kwargs))
|
||||
for op in ['auto_merge','merge_queue']:
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.integration(c,1,op,'inspect',None,None)
|
||||
self.assertEqual(ctx.exception.code,4)
|
||||
self.assertEqual(calls,[])
|
||||
def test_integration_inspect_honors_optional_head_pin(self):
|
||||
c=self.fake()
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.integration(c,1,'auto_merge','inspect',None,'b'*40)
|
||||
self.assertEqual(ctx.exception.code,75)
|
||||
result=self.api.integration(c,1,'auto_merge','inspect',None,'a'*40)
|
||||
self.assertEqual(result['head_sha'],'a'*40)
|
||||
|
||||
def test_forge_disallowed_method_is_unsupported(self):
|
||||
c = self.fake()
|
||||
c.http = lambda *args, **kwargs: {'allow_squash_merge':False}
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.forge_method(c,'squash')
|
||||
self.assertEqual(ctx.exception.code,4)
|
||||
def test_github_method_limits(self):
|
||||
c = self.fake()
|
||||
for method in ['rebase-merge','fast-forward-only']:
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.forge_method(c,method)
|
||||
self.assertEqual(ctx.exception.code,4)
|
||||
|
||||
|
||||
class WorkflowContract(Contract):
|
||||
def test_stop_unknown_integration_blocks(self):
|
||||
d=dict(state='open',ready=True,checks={'state':'success'},auto_merge={'state':'unsupported'},queue={'state':'unsupported'})
|
||||
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'blocked')
|
||||
self.assertEqual(self.api.workflow_verdict(d,'full-send'),'ready_to_merge')
|
||||
def test_red_ci_and_terminal_states(self):
|
||||
d=dict(state='open',ready=False,checks={'state':'failure'},auto_merge={'state':'disabled'},queue={'state':'absent'})
|
||||
self.assertEqual(self.api.workflow_verdict(d,'full-send'),'blocked')
|
||||
d['state']='closed'
|
||||
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'closed_unmerged')
|
||||
d['state']='merged'
|
||||
self.assertEqual(self.api.workflow_verdict(d,'full-stop'),'merged')
|
||||
|
||||
|
||||
class IntegrationMutations(SnapshotContract):
|
||||
def integration_client(self,noop=False,immediate=False):
|
||||
c=self.fake(); state={'enabled':False,'queued':False,'writes':0}
|
||||
def http(path,method='GET',body=None,public=False):
|
||||
if '/protection' in path:
|
||||
return {'required_status_checks':{'contexts':['build']},'required_pull_request_reviews':{'required_approving_review_count':1,'dismiss_stale_reviews':True},'enforce_admins':{'enabled':True}}
|
||||
return {'allow_squash_merge':True,'allow_auto_merge':True}
|
||||
def gql(query,variables):
|
||||
if query.startswith('mutation'):
|
||||
state['writes']+=1
|
||||
self.assertEqual(variables.get('head'),'a'*40)
|
||||
if not noop:
|
||||
state['enabled']='enablePullRequestAutoMerge' in query
|
||||
state['queued']='enqueuePullRequest' in query
|
||||
return {}
|
||||
if 'mergeQueue(branch:' in query:
|
||||
return {'repository':{'mergeQueue':{'configuration':{'mergeMethod':'SQUASH'}}}}
|
||||
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':{'mergeMethod':'SQUASH'} if state['enabled'] else None,'mergeQueueEntry':{'id':'entry_one'} if state['queued'] else None,'reviewThreads':{'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
|
||||
c.http=http; c.gql=gql
|
||||
if immediate:
|
||||
original=c.pr
|
||||
def pr(number):
|
||||
value=original(number)
|
||||
if state['writes']:
|
||||
value.update(merged=True,merge_commit_sha='d'*40)
|
||||
return value
|
||||
c.pr=pr
|
||||
return c,state
|
||||
def test_enable_and_enqueue_verify_readback(self):
|
||||
original_policy,original_guard=self.api.declared_method,self.api.queue_guard
|
||||
self.api.declared_method=lambda *a:'squash'
|
||||
self.api.queue_guard=lambda *a:None
|
||||
try:
|
||||
for op,action,expected in [('auto_merge','enable','enabled'),('merge_queue','enqueue','queued')]:
|
||||
c,state=self.integration_client()
|
||||
result=self.api.integration(c,1,op,action,'squash','a'*40)
|
||||
self.assertEqual(result['state'],expected)
|
||||
self.assertEqual(state['writes'],1)
|
||||
finally:
|
||||
self.api.declared_method,self.api.queue_guard=original_policy,original_guard
|
||||
def test_noop_write_fails_without_retry(self):
|
||||
original_policy,original_guard=self.api.declared_method,self.api.queue_guard
|
||||
self.api.declared_method=lambda *a:'squash'; self.api.queue_guard=lambda *a:None
|
||||
try:
|
||||
c,state=self.integration_client(noop=True)
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.integration(c,1,'auto_merge','enable','squash','a'*40)
|
||||
self.assertEqual(ctx.exception.code,6)
|
||||
self.assertEqual(ctx.exception.outcome,'unknown')
|
||||
self.assertEqual(state['writes'],1)
|
||||
c,state=self.integration_client(immediate=True)
|
||||
result=self.api.integration(c,1,'merge_queue','enqueue','squash','a'*40)
|
||||
self.assertEqual(result['state'],'merged')
|
||||
finally:
|
||||
self.api.declared_method,self.api.queue_guard=original_policy,original_guard
|
||||
def test_gitea_method_settings_are_not_github_names(self):
|
||||
c=self.fake(); c.provider='gitea'; c.version='28.2.0'
|
||||
c.http=lambda *a,**kw:{'allow_merge_commits':True,'allow_rebase':True}
|
||||
self.api.forge_method(c,'merge'); self.api.forge_method(c,'rebase')
|
||||
|
||||
|
||||
|
||||
class ResolutionContract(SnapshotContract):
|
||||
def test_native_thread_resolution_and_noop_readback(self):
|
||||
for noop in [False,True]:
|
||||
c=self.fake(); state={'resolved':False,'writes':0}
|
||||
pages=c.pages
|
||||
c.pages=lambda path,key=None: [{'id':3,'pull_request_review_id':1,'user':{'login':'reviewer'},'body':'finding'}] if path.endswith('/pulls/1/comments') else pages(path,key)
|
||||
def gql(query,variables):
|
||||
if query.startswith('mutation'):
|
||||
state['writes']+=1
|
||||
self.assertEqual(variables['id'],'T_one')
|
||||
if not noop: state['resolved']=True
|
||||
return {}
|
||||
return {'repository':{'pullRequest':{'id':'PR_one','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':{'nodes':[{'id':'T_one','isResolved':state['resolved'],'isOutdated':True,'comments':{'nodes':[{'databaseId':3,'id':'C_three','replyTo':None}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}}
|
||||
c.gql=gql
|
||||
if noop:
|
||||
with self.assertRaises(self.api.Failure) as ctx:
|
||||
self.api.resolve(c,1,'github:thread:T_one',True,'a'*40)
|
||||
self.assertEqual(ctx.exception.code,6)
|
||||
else:
|
||||
result=self.api.resolve(c,1,'github:thread:T_one',True,'a'*40)
|
||||
self.assertTrue(result['verified'])
|
||||
self.assertEqual(state['writes'],1)
|
||||
def test_nested_comment_and_thread_pagination(self):
|
||||
c=self.fake(); queries=[]
|
||||
def gql(query,variables):
|
||||
queries.append(variables)
|
||||
if 'node(id:' in query:
|
||||
return {'node':{'comments':{'nodes':[{'databaseId':2,'id':'C2','replyTo':{'databaseId':1}}],'pageInfo':{'hasNextPage':False,'endCursor':None}}}}
|
||||
page={'nodes':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}
|
||||
if variables['cursor'] is None:
|
||||
page={'nodes':[{'id':'T','isResolved':True,'isOutdated':True,'comments':{'nodes':[{'databaseId':1,'id':'C1','replyTo':None}],'pageInfo':{'hasNextPage':True,'endCursor':'nested'}}}],'pageInfo':{'hasNextPage':True,'endCursor':'outer'}}
|
||||
return {'repository':{'pullRequest':{'id':'PR','autoMergeRequest':None,'mergeQueueEntry':None,'reviewThreads':page}}}
|
||||
c.gql=gql
|
||||
threads,_=self.api.github_conversations(c,1)
|
||||
self.assertEqual(len(threads[0]['comments']),2)
|
||||
self.assertEqual(len(queries),3)
|
||||
self.assertEqual(queries[1]['cursor'],'nested')
|
||||
self.assertEqual(queries[2]['cursor'],'outer')
|
||||
|
||||
class ReviewRegressions(SnapshotContract):
|
||||
def test_same_sha_retarget_snapshot_refused(self):
|
||||
for field in ['ref','repo','head_ref','head_repo']:
|
||||
c=self.fake(); original=c.pr
|
||||
def retarget(n):
|
||||
p=original(n)
|
||||
if c.reads>1:
|
||||
side='head' if field.startswith('head_') else 'base'
|
||||
key=field.removeprefix('head_')
|
||||
p[side][key]='forbidden' if key=='ref' else {'full_name':'other/repo'}
|
||||
return p
|
||||
c.pr=retarget
|
||||
with self.assertRaises(self.api.Failure) as caught:
|
||||
self.api.snapshot(c,1,None)
|
||||
self.assertEqual(caught.exception.code,75)
|
||||
|
||||
def test_retarget_merge_never_writes(self):
|
||||
from unittest.mock import patch
|
||||
c=self.fake(); original=c.pr; writes=[]
|
||||
def retarget(n):
|
||||
p=original(n)
|
||||
if c.reads>1: p['base']['ref']='forbidden-target'
|
||||
return p
|
||||
c.pr=retarget
|
||||
http=c.http
|
||||
def request(path,method='GET',body=None,public=False):
|
||||
if method!='GET': writes.append(path)
|
||||
if path==c.root: return {'allow_squash_merge':True}
|
||||
return http(path,method,body,public)
|
||||
c.http=request
|
||||
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'queue_guard'):
|
||||
with self.assertRaises(self.api.Failure) as caught:
|
||||
self.api.merge(c,1,'squash','a'*40)
|
||||
self.assertEqual(caught.exception.code,75)
|
||||
self.assertEqual(writes,[])
|
||||
|
||||
def test_null_thread_is_not_resolved(self):
|
||||
c=self.fake()
|
||||
gql=c.gql
|
||||
def response(q,v):
|
||||
data=gql(q,v)
|
||||
data['repository']['pullRequest']['reviewThreads']['nodes']=[{'id':'T','isResolved':None,'comments':{'nodes':[],'pageInfo':{'hasNextPage':False}}}]
|
||||
return data
|
||||
c.gql=response
|
||||
with self.assertRaises(self.api.Failure) as caught:
|
||||
self.api.snapshot(c,1,None)
|
||||
self.assertEqual(caught.exception.code,6)
|
||||
|
||||
def test_malformed_integration_is_unknown(self):
|
||||
for key in ['autoMergeRequest','mergeQueueEntry']:
|
||||
for value in [{},False,'',{'mergeMethod':None},{'id':False}]:
|
||||
c=self.fake(); gql=c.gql
|
||||
def response(q,v):
|
||||
data=gql(q,v); data['repository']['pullRequest'][key]=value; return data
|
||||
c.gql=response
|
||||
with self.assertRaises(self.api.Failure) as caught:
|
||||
self.api.integration(c,1,'auto_merge','inspect',None,None)
|
||||
self.assertEqual(caught.exception.code,6)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -1,23 +0,0 @@
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import unittest
|
||||
HERE=pathlib.Path(__file__).resolve().parent
|
||||
class Guard(unittest.TestCase):
|
||||
def check(self,command,expected):
|
||||
p=subprocess.run(['bash',str(HERE/'wrapper-guard.sh')],input=json.dumps({'tool_input':{'command':command}}),text=True,capture_output=True)
|
||||
self.assertEqual(p.returncode,expected,p.stderr)
|
||||
def test_native_review_writes_refused(self):
|
||||
for route in ['pulls/1/comments/2/replies','pulls/comments/2/resolve','pulls/comments/2/unresolve']:
|
||||
self.check('curl -X POST https://git.example.test/api/v1/repos/owner/repo/'+route,2)
|
||||
def test_named_graphql_writes_refused(self):
|
||||
for mutation in ['resolveReviewThread','unresolveReviewThread','enablePullRequestAutoMerge','disablePullRequestAutoMerge','enqueuePullRequest','dequeuePullRequest']:
|
||||
self.check("gh api graphql -f query='mutation { "+mutation+"(input:{id:123}) { clientMutationId } }'",2)
|
||||
def test_opaque_graphql_payload_cannot_hide_mutation(self):
|
||||
self.check('gh api graphql --input request.json',2)
|
||||
self.check('curl --data-binary @request.json https://api.github.com/graphql',2)
|
||||
|
||||
def test_graphql_reads_and_wrappers_allowed(self):
|
||||
self.check("gh api graphql -f query='query { repository(owner:123,name:123) { name } }'",0)
|
||||
self.check('/opt/mosaic/tools/git/pr-review-resolve.sh -n 1 --thread-id github:thread:T --resolve --expect-head abc --json',0)
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -1,40 +0,0 @@
|
||||
// Use the repository's existing ESLint/Ajv dependency, without adding a package.
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const eslintRequire = createRequire(require.resolve('eslint/package.json'));
|
||||
const Ajv = eslintRequire('ajv');
|
||||
const schema = JSON.parse(readFileSync(join(__dirname, 'pr-status.schema.json'), 'utf8'));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
assert(ajv.validateSchema(schema), JSON.stringify(ajv.errors));
|
||||
const validate = ajv.compile(schema);
|
||||
const fixture = spawnSync(
|
||||
'python3',
|
||||
[
|
||||
'-I',
|
||||
'-c',
|
||||
`
|
||||
import importlib.util,json,pathlib,sys
|
||||
p=pathlib.Path(sys.argv[1])/'test-forge-contract.py'
|
||||
spec=importlib.util.spec_from_file_location('fixture',p)
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
m.SnapshotContract.setUpClass()
|
||||
c=m.SnapshotContract().fake()
|
||||
s=m.SnapshotContract.api.snapshot(c,1,None)
|
||||
print(json.dumps(dict(schema_version=1,operation='status',provider=c.provider,host=c.host,repo=c.repo,number=1,head_sha=s['head_sha'],data=s,error=None)))
|
||||
`,
|
||||
__dirname,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(fixture.status, 0, fixture.stderr);
|
||||
const snapshot = JSON.parse(fixture.stdout);
|
||||
assert(validate(snapshot), JSON.stringify(validate.errors));
|
||||
delete snapshot.data.threads;
|
||||
assert.equal(validate(snapshot), false, 'missing thread evidence must fail schema');
|
||||
snapshot.data.threads = { unresolved: 0, complete: true };
|
||||
snapshot.data.checks.state = 'invented-green';
|
||||
assert.equal(validate(snapshot), false, 'invented checks state must fail schema');
|
||||
console.log('Schema and negative controls passed');
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$SCRIPT_DIR/test-forge-schema.cjs"
|
||||
@@ -34,23 +34,20 @@ REPO_DIR="$WORK_DIR/repo"
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
SPOOL_DIR="$WORK_DIR/spool"
|
||||
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
# Mirror the real deployed layout (~/.mosaic/tools/{git,_lib}/) under the
|
||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||
HELPER="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic"
|
||||
IMPL="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic.impl"
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$SVC_STORE" \
|
||||
"$FAKE_HOME/.mosaic/tools/git" \
|
||||
"$FAKE_HOME/.mosaic/tools/_lib" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$REPO_DIR" "$BRAIN_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic.impl" "$IMPL"
|
||||
chmod +x "$IMPL"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" config user.email "[email protected]"
|
||||
@@ -58,7 +55,7 @@ git -C "$REPO_DIR" config user.name "Test"
|
||||
|
||||
# Fake shared-account credential loader — stands in for
|
||||
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||
cat > "$FAKE_HOME/.mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
case "$1" in
|
||||
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
@@ -85,7 +82,7 @@ run_helper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||
"$HELPER" get <<EOF
|
||||
bash "$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
@@ -93,80 +90,6 @@ EOF
|
||||
)
|
||||
}
|
||||
|
||||
# ── Lineage harness (rev-code-02 F1/F2 rework) ─────────────────────────────
|
||||
# Establishes a seat CALLER the way production does — frozen into an
|
||||
# ancestor's exec environment — instead of injecting MOSAIC_AGENT_NAME into
|
||||
# the helper's own env. Two scripts are generated into WORK_DIR:
|
||||
#
|
||||
# lineage-root.sh (pid A): invoked with a fence marker and NO agent name.
|
||||
# With a non-empty caller arg it forks the carrier (pid C); with an
|
||||
# empty caller it forks the helper directly (deterministic anonymous
|
||||
# lineage even when the suite itself runs inside a seat).
|
||||
# lineage-carrier.sh (pid C): MOSAIC_AGENT_NAME=<caller> frozen at exec;
|
||||
# forks the helper (pid D) with a fully controlled env.
|
||||
#
|
||||
# The helper's walk then sees exactly: self -> C(caller) or D-direct ->
|
||||
# A(fence, empty name -> stop). EXTRA assignments ride pid D's environment
|
||||
# (that is where a rewrite would live — which is the point of the F1 arms).
|
||||
cat > "$WORK_DIR/lineage-root.sh" <<'LINROOT'
|
||||
#!/usr/bin/env bash
|
||||
# pid A — lineage root. Args: <caller> <carrier-script> <helper> <spool>
|
||||
# <brain> <repo> [extra KEY=VALUE...]
|
||||
set -u
|
||||
caller="$1"; carrier="$2"; helper="$3"; spool="$4"; brain="$5"; repo="$6"; shift 6
|
||||
if [ -n "$caller" ]; then
|
||||
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
|
||||
bash "$carrier" "$helper" "$spool" "$brain" "$repo" "$@"
|
||||
else
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$spool" \
|
||||
MOSAIC_BRAIN_HOME="$brain" "$@" "$helper" get
|
||||
fi
|
||||
LINROOT
|
||||
cat > "$WORK_DIR/lineage-carrier.sh" <<'LINCARR'
|
||||
#!/usr/bin/env bash
|
||||
# pid C — the caller's frozen environment. Forks the helper (pid D).
|
||||
set -u
|
||||
helper="$1"; spool="$2"; brain="$3"; repo="$4"; shift 4
|
||||
cd "$repo"
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$spool" \
|
||||
MOSAIC_BRAIN_HOME="$brain" "$@" "$helper" get
|
||||
LINCARR
|
||||
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
|
||||
|
||||
run_lineage() {
|
||||
# run_lineage <caller|empty-for-anonymous> [helper-env KEY=VALUE...]
|
||||
local caller="$1"; shift
|
||||
printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
|
||||
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
|
||||
"$HELPER" "$SPOOL_DIR" "$BRAIN_DIR" "$REPO_DIR" "$@"
|
||||
}
|
||||
|
||||
assert_refused_lineage() {
|
||||
# assert_refused_lineage <desc> <caller> <want-reason> [helper-env...]
|
||||
local desc="$1" caller="$2" want="$3"; shift 3
|
||||
local stderr_file="$WORK_DIR/stderr-lin.tmp" rc stdout
|
||||
: > "$stderr_file"
|
||||
set +e
|
||||
stdout=$(run_lineage "$caller" "$@" 2>"$stderr_file")
|
||||
rc=$?
|
||||
set -e
|
||||
local stderr; stderr=$(cat "$stderr_file")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2; fail=1
|
||||
fi
|
||||
if [[ -n "$stdout" ]]; then
|
||||
echo "FAIL: $desc — expected empty stdout, got '$stdout'" >&2; fail=1
|
||||
fi
|
||||
if [[ -n "$want" && "$stderr" != *"$want"* ]]; then
|
||||
echo "FAIL: $desc — stderr lacks '$want':" >&2; echo "$stderr" >&2; fail=1
|
||||
fi
|
||||
if [[ "$stdout$stderr" == *"seatG-slot-token"* || "$stdout$stderr" == *"seatE-slot-token"* \
|
||||
|| "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — a slot or shared token VALUE appeared in output" >&2; fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
||||
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
||||
# that actually catches a regression to the old behavior — NO shared token value
|
||||
@@ -299,10 +222,7 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
# Seat arms run through the lineage harness below (rev-code-02 F1/F2 rework):
|
||||
# a seat caller must be established by ancestry, not by the helper's own env.
|
||||
out=$(run_lineage seatE MOSAIC_AGENT_NAME=seatE MOSAIC_GIT_IDENTITY=seatE)
|
||||
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
@@ -316,8 +236,8 @@ assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(ech
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
||||
assert_refused_lineage "seat with empty slot does NOT fall back to the framework store" \
|
||||
seatF no-token-for-identity MOSAIC_AGENT_NAME=seatF MOSAIC_GIT_IDENTITY=seatF
|
||||
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
||||
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
: > "$WORK_DIR/stderr.tmp"
|
||||
set +e
|
||||
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||
@@ -368,7 +288,7 @@ assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$
|
||||
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# ---------------------------------------------------------------------------
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" "$HELPER" store <<EOF
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
password=whatever
|
||||
@@ -390,7 +310,7 @@ hostile_spool="$WORK_DIR/spool-hostile"
|
||||
cd "$hostile_dir"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
"$HELPER" get <<EOF >/dev/null 2>&1
|
||||
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -429,7 +349,7 @@ nospool_err=$(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
"$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -445,193 +365,6 @@ if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. P5-RM-006 (+r1 rework) — caller identity from exec-frozen ancestry.
|
||||
# A seat caller is established by lineage, not by the helper's own env;
|
||||
# disagreement anywhere in the lineage is a rewrite and refuses; an
|
||||
# anonymous caller resolves NOTHING on a fleet host (seat or service);
|
||||
# a loose seat-slot mode refuses. Enforcement-removal red control at 11h.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatG/secrets"
|
||||
echo -n "seatG-slot-token" > "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
|
||||
# 11a. Cross-seat negative (lineage seatE, env ident=seatG): still refused.
|
||||
assert_refused_lineage "seat cannot override identity to another seat's slot" \
|
||||
seatE cross-seat-identity-refused MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11b. Seat asking for a SERVICE identity: cross-seat territory.
|
||||
assert_refused_lineage "seat cannot resolve a service identity either" \
|
||||
seatE cross-seat-identity-refused MOSAIC_GIT_IDENTITY=agentA
|
||||
|
||||
# 11c. Anonymous caller asking for a seat slot is refused.
|
||||
assert_refused_lineage "anonymous caller cannot resolve a seat slot on a fleet host" \
|
||||
"" anonymous-credential-refused MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11d. Anonymous caller asking for a SERVICE identity: ALSO refused
|
||||
# (rev-code-02 F2 — credentialed services are seats; the legacy store is
|
||||
# not anonymously reachable on fleet hosts).
|
||||
assert_refused_lineage "anonymous caller cannot resolve a legacy service credential either" \
|
||||
"" anonymous-credential-refused MOSAIC_GIT_IDENTITY=agentA
|
||||
|
||||
# 11e. Slot permissions: a group-readable slot is refused; mode restored -> serves.
|
||||
chmod 644 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
assert_refused_lineage "loose slot mode is refused" \
|
||||
seatG slot-permission-violation MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
out=$(run_lineage seatG MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG)
|
||||
assert_eq "mode restored to 600: seatG serves again" "password=seatG-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11f. rev-code-02 F1 repro: dual-variable override (caller lineage seatE,
|
||||
# helper env carrying MOSAIC_AGENT_NAME=seatG AND MOSAIC_GIT_IDENTITY=seatG).
|
||||
assert_refused_lineage "F1: dual MOSAIC_AGENT_NAME+MOSAIC_GIT_IDENTITY override refused" \
|
||||
seatE caller-identity-spoof-refused MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11g. Stripped lineage still serves the rightful seat: caller frozen at the
|
||||
# ancestor, helper env clean (self empty), own ident.
|
||||
out=$(run_lineage seatE MOSAIC_GIT_IDENTITY=seatE)
|
||||
assert_eq "lineage consensus with stripped self still serves the owning seat" \
|
||||
"password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11h. RED CONTROL: delete the ancestry binding between markers from a copy
|
||||
# of the IMPLEMENTATION (run directly with the clean marker — a red control
|
||||
# measures the binding itself, deliberately not through the wrapper);
|
||||
# the F1 dual-override request must then RESOLVE seatG's token — the
|
||||
# exact measured failure — proving the binding is the enforcement.
|
||||
RED_HELPER="$WORK_DIR/red/git-credential-mosaic.impl"
|
||||
mkdir -p "$WORK_DIR/red"
|
||||
sed '/P5-RM-006r1 ancestry binding begin/,/P5-RM-006r1 ancestry binding end/d' "$IMPL" > "$RED_HELPER"
|
||||
chmod +x "$RED_HELPER"
|
||||
if cmp -s "$IMPL" "$RED_HELPER"; then
|
||||
echo "FAIL: red control is vacuous — marker deletion removed nothing" >&2
|
||||
fail=1
|
||||
fi
|
||||
set +e
|
||||
red_out=$(printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
|
||||
bash "$WORK_DIR/lineage-root.sh" seatE "$WORK_DIR/lineage-carrier.sh" \
|
||||
"$RED_HELPER" "$SPOOL_DIR" "$BRAIN_DIR" "$REPO_DIR" \
|
||||
_MOSAIC_HELPER_CLEAN=1 MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG 2>/dev/null)
|
||||
set -e
|
||||
if [[ "$(echo "$red_out" | grep '^password=')" != "password=seatG-slot-token" ]]; then
|
||||
echo "FAIL: red control — with the binding removed, the F1 dual-override should have resolved seatG's token, got: $red_out" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok: red control — binding removed -> F1 dual-override resolves the victim token (the binding is the enforcement)"
|
||||
fi
|
||||
|
||||
# 11h2. rev-code-02 R1 F1: PATH-shadowed tr/sed/head/grep must not forge the
|
||||
# ancestry. The dual-override request runs with a hostile PATH whose
|
||||
# utilities claim the victim name for every /proc read; the walker uses
|
||||
# only bash builtins, so the shadows never execute and the refusal holds.
|
||||
HOSTILE_BIN="$WORK_DIR/hostile-bin"
|
||||
mkdir -p "$HOSTILE_BIN"
|
||||
for tool in tr sed head grep cat stat; do
|
||||
printf '#!/usr/bin/env bash\ncat >/dev/null\necho "MOSAIC_AGENT_NAME=seatG"\nexit 0\n' > "$HOSTILE_BIN/$tool"
|
||||
chmod +x "$HOSTILE_BIN/$tool"
|
||||
done
|
||||
assert_refused_lineage "F1-R1: hostile PATH utilities cannot forge ancestry (dual override still refused)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG PATH="$HOSTILE_BIN:$PATH"
|
||||
|
||||
# 11i. Service automation integration arm (rev-code-02 R2 bar): a SERVICE
|
||||
# seat bound by lineage resolves its own slot — the brain-git-sync shape.
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/svc-fixture/secrets"
|
||||
echo -n "svc-fixture-slot-token" > "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
out=$(run_lineage svc-fixture MOSAIC_AGENT_NAME=svc-fixture MOSAIC_GIT_IDENTITY=svc-fixture)
|
||||
assert_eq "service automation with bound seat lineage resolves its own slot" \
|
||||
"password=svc-fixture-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11j. rev-code-02 R3 B1: BASH_ENV shaping. A read() shadow defined through
|
||||
# BASH_ENV must be refused before any resolution — the guard scrubs and
|
||||
# refuses with bash-environment-injection-refused.
|
||||
INJ_SH="$WORK_DIR/inj-read.sh"
|
||||
printf 'read() { builtin read -r _x || return 0; printf "MOSAIC_AGENT_NAME=seatG\\n"; return 0; }\n' > "$INJ_SH"
|
||||
assert_refused_lineage "F1-R3: BASH_ENV read() shadow is dropped at the wrapper boundary (identity gate governs)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_SH"
|
||||
|
||||
# 11k. rev-code-02 R3 B1: exported functions (BASH_FUNC_* import) refused too.
|
||||
assert_refused_lineage "F1-R3: exported BASH_FUNC_* import never crosses the wrapper boundary (identity gate governs)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG \
|
||||
'BASH_FUNC_read%%=() { builtin read -r _x || return 0; printf "MOSAIC_AGENT_NAME=seatG\\n"; return 0; }'
|
||||
|
||||
# 11l. rev-code-02 R3 B2: hostile stat cannot launder a loose slot. Own-slot
|
||||
# lineage (legit caller), 0644 slot, PATH-shadowed stat reporting 600 —
|
||||
# mode inspection must come from the trusted PATH and still refuse.
|
||||
chmod 644 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
printf '#!/usr/bin/env bash\necho 600\n' > "$HOSTILE_BIN/stat"
|
||||
assert_refused_lineage "F2-R3: hostile stat cannot make a 0644 slot pass as 600 (own-slot path)" \
|
||||
svc-fixture slot-permission-violation \
|
||||
MOSAIC_AGENT_NAME=svc-fixture MOSAIC_GIT_IDENTITY=svc-fixture PATH="$HOSTILE_BIN:$PATH"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
|
||||
# 11m. rev-code-02 R3 probe 1 through the PRODUCTION ENTRYPOINT: BASH_ENV
|
||||
# defines unset()/exit() no-ops (defeating in-bash scrub/termination).
|
||||
# The python wrapper never passes BASH_ENV across the boundary, so the
|
||||
# implementation cannot be shaped and the dual override still refuses.
|
||||
INJ_P1="$WORK_DIR/inj-probe1.sh"
|
||||
cat > "$INJ_P1" <<'P1'
|
||||
unset() { return 0; }
|
||||
exit() { return 0; }
|
||||
read() { builtin read -r _x || return 0; printf 'MOSAIC_AGENT_NAME=seatG\n'; return 0; }
|
||||
P1
|
||||
assert_refused_lineage "F1-R4 probe1: BASH_ENV unset/exit no-ops cannot shape the helper (wrapper boundary)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_P1"
|
||||
|
||||
# 11n. rev-code-02 R3 probe 2 through the PRODUCTION ENTRYPOINT: declare()
|
||||
# hides imported functions, unsets the marker, printf() forges the
|
||||
# ancestry. Dropped at the wrapper boundary; refusal holds.
|
||||
INJ_P2="$WORK_DIR/inj-probe2.sh"
|
||||
cat > "$INJ_P2" <<'P2'
|
||||
declare() { return 0; }
|
||||
printf() { builtin printf '%s' "MOSAIC_AGENT_NAME=seatG"; return 0; }
|
||||
read() { builtin read -r _x || return 0; printf 'MOSAIC_AGENT_NAME=seatG\n'; return 0; }
|
||||
P2
|
||||
assert_refused_lineage "F1-R4 probe2: declare-hide + printf-forge cannot shape the helper (wrapper boundary)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_P2"
|
||||
|
||||
# 11o. RED CONTROL for the wrapper boundary (enforcement-removal): invoke the
|
||||
# IMPLEMENTATION directly, bypassing the wrapper, with the PROBE-1 shape
|
||||
# (unset/exit no-ops) and a forged clean marker — exactly the falsified
|
||||
# in-bash world the reviewer measured: the refusal prints, exit is
|
||||
# no-oped, execution continues, and the forged ancestry SERVES seatG.
|
||||
# The wrapper boundary is the enforcement; this arm proves it bites.
|
||||
set +e
|
||||
bypass_out=$(cd "$REPO_DIR" && printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
|
||||
MOSAIC_BRAIN_HOME="$BRAIN_DIR" _MOSAIC_HELPER_CLEAN=1 BASH_ENV="$INJ_P1" \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG \
|
||||
bash "$IMPL" get 2>/dev/null)
|
||||
set -e
|
||||
if [[ "$(echo "$bypass_out" | grep -c '^password=')" -lt 1 ]]; then
|
||||
echo "FAIL: wrapper red control — direct shaped .impl should have served (wrapper is the enforcement), got: $bypass_out" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok: red control — wrapper bypassed + probe1 shape serves (the wrapper boundary is the enforcement)"
|
||||
fi
|
||||
|
||||
# 11p. Direct .impl invocation WITHOUT the clean marker: refused by the
|
||||
# implementation's own entrypoint assert.
|
||||
set +e
|
||||
direct_out=$(cd "$REPO_DIR" && printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
|
||||
MOSAIC_BRAIN_HOME="$BRAIN_DIR" MOSAIC_GIT_IDENTITY=seatE \
|
||||
bash "$IMPL" get 2>"$WORK_DIR/stderr-direct.tmp")
|
||||
direct_rc=$?
|
||||
set -e
|
||||
if [[ "$direct_rc" -eq 0 || -n "$direct_out" ]]; then
|
||||
echo "FAIL: direct .impl without marker must refuse (got rc=$direct_rc out='$direct_out')" >&2
|
||||
fail=1
|
||||
elif ! grep -q 'direct-entrypoint-refused' "$WORK_DIR/stderr-direct.tmp"; then
|
||||
echo "FAIL: direct .impl refusal lacks direct-entrypoint-refused" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "git-credential-mosaic identity resolution regression passed"
|
||||
fi
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
@@ -87,22 +86,9 @@ SH
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
args=("$@")
|
||||
for ((i=0; i<${#args[@]}; i++)); do
|
||||
if [[ "${args[i]}" == --config && "${args[i+1]:-}" == /dev/fd/* ]]; then
|
||||
grep -q '^header = "Authorization: token ' "${args[i+1]}" || exit 96
|
||||
printf '%s\n' 'anonymous-auth-config' >> "$MOSAIC_TEST_LOG"
|
||||
fi
|
||||
done
|
||||
url="${*: -1}"
|
||||
case "$url" in
|
||||
*/pulls/*.diff)
|
||||
@@ -275,31 +261,22 @@ run_in_repo env GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-diff.sh" --repo
|
||||
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/7.diff' "$LOG_FILE"
|
||||
|
||||
: > "$LOG_FILE"
|
||||
# CI wait now consumes the normalized contract rather than this legacy aggregate
|
||||
# fixture. Its host/head/status and empty-check controls live in test-forge-cli.py.
|
||||
python3 -I "$SCRIPT_DIR/test-forge-cli.py" CLI.test_gitea_status_and_ci_wait_use_exact_head
|
||||
run_in_repo "$SCRIPT_DIR/pr-ci-wait.sh" --repo USC/uconnect --host git.uscllc.com -n 9 -t 2 -i 1
|
||||
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/9' "$LOG_FILE"
|
||||
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/commits/abc123/status' "$LOG_FILE"
|
||||
|
||||
|
||||
fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")"
|
||||
: > "$LOG_FILE"
|
||||
run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_LOGIN="$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(k for k,v in d["gitea"].items() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")" MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create
|
||||
run_in_repo env MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create
|
||||
grep -q -- 'tea pr create --repo USC/uconnect --login usc --title USC API fallback --head feature/pr-create' "$LOG_FILE"
|
||||
grep -q '^anonymous-auth-config$' "$LOG_FILE"
|
||||
if grep -q 'Authorization:' "$LOG_FILE"; then
|
||||
echo 'Credential header appeared in provider argv' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q -- 'curl .*Authorization: token usc-token .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"
|
||||
if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; then
|
||||
echo "pr-create.sh API fallback defaulted USC repo to git.mosaicstack.dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
fixture_host=$(git -C "$REPO_DIR" remote get-url origin | python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.stdin.read().strip()).hostname)')
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" fixture-user "$fixture_host" "$(python3 -I -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(v["token"] for v in d["gitea"].values() if v["url"].endswith(sys.argv[2])))' "$CREDENTIALS_FILE" "$fixture_host")"
|
||||
: > "$LOG_FILE"
|
||||
run_in_repo env MOSAIC_GIT_IDENTITY=fixture-user MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536
|
||||
run_in_repo env GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536
|
||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/536' "$LOG_FILE"
|
||||
if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
|
||||
echo "issue-close.sh invented a mosaicstack tea login instead of using API fallback" >&2
|
||||
@@ -540,18 +517,15 @@ git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
FIXTURE_XDG="$WORK_DIR/tokenfix"
|
||||
NOYAML_DIR="$WORK_DIR/noyaml"
|
||||
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
|
||||
# -I deliberately ignores PYTHONPATH. Use -S in a fixture interpreter to
|
||||
# exercise missing third-party modules without relaxing production isolation.
|
||||
REAL_PYTHON="$(command -v python3)"
|
||||
cat > "$NOYAML_DIR/python3" <<SH
|
||||
#!/usr/bin/env bash
|
||||
exec "$REAL_PYTHON" -I -S "\$@"
|
||||
SH
|
||||
chmod +x "$NOYAML_DIR/python3"
|
||||
if python3 -I -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
|
||||
if PATH="$NOYAML_DIR:$PATH" python3 -I -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "FAIL: isolated no-site interpreter still imports PyYAML" >&2
|
||||
exit 1
|
||||
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
|
||||
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
|
||||
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
|
||||
# Confirm the shim really does force ImportError, so the fallback is exercised.
|
||||
if python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
@@ -560,7 +534,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PATH="$NOYAML_DIR:$PATH" bash -c '
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
|
||||
@@ -1,5 +1,316 @@
|
||||
#!/usr/bin/env bash
|
||||
# Explicit actor/host-bound credentials, shared/config refusal and trace controls.
|
||||
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
||||
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
||||
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
||||
# open records under the resolved agent identity, not the shared account.
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree).
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
||||
# 4. No identity requested -> shared credential-loader token (backward
|
||||
# compat, unchanged).
|
||||
# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot
|
||||
# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a
|
||||
# stderr diagnostic naming identity/source/host/expected path). Must NOT
|
||||
# fall through to the shared/default token (Gate-16 author≠reviewer
|
||||
# integrity — never silently borrow another slot's credentials). Covered
|
||||
# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and
|
||||
# for a same-identity cross-host case (token exists for one host, not
|
||||
# the other).
|
||||
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
||||
# per-slot token scheme) -> Patch 2b does not apply; existing
|
||||
# fall-through behavior is unchanged.
|
||||
# 7. Credential store selection: an identity with a directory under
|
||||
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||
# slot; any other identity is a SERVICE and is read from the framework
|
||||
# store. No precedence between them and NO fallback from one to the
|
||||
# other — a seat with an empty slot is REFUSED even when a same-named
|
||||
# token sits in the framework store.
|
||||
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
|
||||
# seats exist, an unattributable API call is refused rather than made
|
||||
# under the shared account. On a host with no fleet the same call still
|
||||
# returns the shared token (case 1), which is what keeps this change a
|
||||
# no-op for non-fleet operators of the framework.
|
||||
#
|
||||
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 -I "$SCRIPT_DIR/test-forge-auth.py"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": "https://git.mosaicstack.dev",
|
||||
"token": "shared-mosaicstack-token"
|
||||
},
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "shared-usc-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
fail=0
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
||||
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
||||
# extra env passed in $@.
|
||||
call_get_gitea_token() {
|
||||
local host="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
||||
# expanded by the INNER bash -c (via the exported env var below), not here.
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||
# token -> that token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
||||
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config
|
||||
# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a
|
||||
# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity.
|
||||
# Must NOT fall through to the shared/default token — silently borrowing
|
||||
# another slot's credentials would post PRs/issues/reviews as the WRONG
|
||||
# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return,
|
||||
# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr
|
||||
# diagnostic naming the identity, its source, the host, and the expected
|
||||
# per-slot token path.
|
||||
# ---------------------------------------------------------------------------
|
||||
assert_failloud() {
|
||||
local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4
|
||||
local stderr_file="$WORK_DIR/stderr.tmp"
|
||||
: > "$stderr_file"
|
||||
set +e
|
||||
local stdout
|
||||
stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file")
|
||||
local rc=$?
|
||||
set -e
|
||||
local stderr
|
||||
stderr=$(cat "$stderr_file")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$stdout" ]]; then
|
||||
echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$ident"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$host"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack),
|
||||
# shared token IS present but must not be borrowed.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||
assert_failloud "fail-loud via git-config identity (recognized host)" \
|
||||
"git.mosaicstack.dev" "no-such-agent" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same
|
||||
# recognized-host / absent-token scenario -> also fails loud.
|
||||
assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \
|
||||
"git.mosaicstack.dev" "no-such-agent-env" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent-env
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
||||
# exists. usc host returns it (happy path, unchanged). mosaicstack host
|
||||
# has NO per-slot token for this identity -> Patch 2b fail-loud applies
|
||||
# there too (must NOT fall back to the shared mosaicstack token, and must
|
||||
# NOT leak the agent's usc token either).
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
||||
out=$(call_get_gitea_token "git.uscllc.com")
|
||||
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
||||
assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \
|
||||
"git.mosaicstack.dev" "agentD" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Scope containment: identity explicitly requested, but the host is NOT a
|
||||
# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does
|
||||
# NOT apply; existing fall-through behavior is unchanged (ends in the
|
||||
# pre-existing generic failure since no shared credentials match either,
|
||||
# NOT the fail-loud diagnostic path).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||
set +e
|
||||
out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/stderr-scope.tmp")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$err" == *"no per-slot token at"* ]]; then
|
||||
echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2
|
||||
echo "$err" >&2
|
||||
fail=1
|
||||
fi
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||
# from its OWN secrets/ slot, not from the framework store. The brain home
|
||||
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
|
||||
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
|
||||
# ---------------------------------------------------------------------------
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
|
||||
# EMPTY slot, while a framework-store token of the identical name is
|
||||
# present and readable. It must be REFUSED rather than served that token:
|
||||
# one credential, one location. A seat that silently reads a same-named
|
||||
# service credential is the same substitution failure as the shared-account
|
||||
# fallback, one store further down.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
|
||||
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
|
||||
"git.mosaicstack.dev" "seatF" \
|
||||
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
|
||||
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
# assert_failloud only screens stderr for the word "shared"; this store's token
|
||||
# is not named that, so check for its value explicitly.
|
||||
set +e
|
||||
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
|
||||
set -e
|
||||
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
|
||||
fail=1
|
||||
fi
|
||||
# Control: that framework-store token IS readable, so the refusal above is the
|
||||
# store rule firing and not an unreadable file. The same id, resolved against a
|
||||
# brain home where it is not a seat, gets it.
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
|
||||
# with case 1: the identical call on a host with no fleet still returns the
|
||||
# shared token.
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
set +e
|
||||
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
|
||||
noid_rc=$?
|
||||
set -e
|
||||
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
|
||||
if [[ "$noid_rc" -eq 0 ]]; then
|
||||
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$noid_out" ]]; then
|
||||
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
|
||||
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
|
||||
echo "$noid_out$noid_err" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
|
||||
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
|
||||
echo "$noid_err" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||
# the fleet gate must fire on the default path too, not only on an
|
||||
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||
# same call now refuses, which also proves case 1 was measuring the
|
||||
# no-fleet branch rather than passing for an unrelated reason.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||
set +e
|
||||
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
|
||||
dflt_rc=$?
|
||||
set -e
|
||||
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
|
||||
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "get_gitea_token identity resolution regression passed"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
@@ -112,9 +111,6 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
}, credentials)
|
||||
PY
|
||||
|
||||
mkdir -p "$HOME_DIR/.config/mosaic"
|
||||
cp "$CREDENTIALS_FILE" "$HOME_DIR/.config/mosaic/credentials.json"
|
||||
python3 -I "$SCRIPT_DIR/fixture-forge-credential.py" "$HOME_DIR" "$HOME_DIR/.mosaic" "$DEFAULT_IDENTITY" "$(python3 -c 'import sys,urllib.parse; print(urllib.parse.urlparse(sys.argv[1]).hostname)' "$API_ROOT")" "$DEFAULT_TOKEN"
|
||||
# tea stub: grant-reviewer.sh must never shell out to tea at all.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
@@ -135,12 +131,6 @@ chmod +x "$BIN_DIR/tea"
|
||||
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
# Public schema fixture for fail-closed provider detection.
|
||||
if [[ "$*" == *'/swagger.v1.json'* ]]; then
|
||||
[[ " $* " != *' --include '* ]] || printf 'HTTP/2 200\r\n\r\n'
|
||||
printf '%s\n' '{"info":{"title":"Gitea API","version":"1.24.7"}}'
|
||||
exit 0
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
||||
@@ -172,7 +162,7 @@ done
|
||||
|
||||
# Resolve the bearer token from the curl --config file (its real, secure
|
||||
# source). The config line is `header = "Authorization: token <value>"`.
|
||||
if [[ -z "$auth_token" && -n "$config_file" && -r "$config_file" ]]; then
|
||||
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
||||
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
||||
if [[ "$config_hdr" == *"token "* ]]; then
|
||||
auth_token="${config_hdr##*token }"
|
||||
@@ -389,7 +379,7 @@ run_grant() {
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
|
||||
MOSAIC_GIT_IDENTITY="$DEFAULT_IDENTITY" \
|
||||
MOSAIC_GIT_IDENTITY= \
|
||||
GITEA_LOGIN= \
|
||||
GITEA_TOKEN= \
|
||||
GITEA_URL= \
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user