Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd15ed144 | ||
|
|
9d721fa6f1 | ||
|
|
9e7b563a75 | ||
|
|
f76a5c96b6 | ||
|
|
d6302f8e6f | ||
|
|
9aa4983cf2 | ||
|
|
736b0affc1 |
@@ -6,3 +6,11 @@ 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -208,6 +208,51 @@ 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,10 +27,11 @@ 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 { createShellTools } from './tools/shell-tools.js';
|
||||
import { createShellToolsIfEnabled } 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';
|
||||
@@ -167,7 +168,8 @@ export class AgentService implements OnModuleDestroy {
|
||||
),
|
||||
...createFileTools(sandboxDir),
|
||||
...createGitTools(sandboxDir),
|
||||
...createShellTools(sandboxDir),
|
||||
...createShellToolsIfEnabled(sandboxDir),
|
||||
...createDeliveryTools(sandboxDir),
|
||||
...createWebTools(),
|
||||
...createSearchTools(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
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, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardWritePath, 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 = guardPathUnsafe(path, baseDir);
|
||||
safePath = guardWritePath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 } from './shell-tools.js';
|
||||
export { createShellTools, createShellToolsIfEnabled } 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, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
@@ -101,4 +101,55 @@ 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,47 +1,63 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* 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 sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||
function isContained(candidate: string, root: string): boolean {
|
||||
return candidate === root || candidate.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbsolute = path.resolve(sandboxDir);
|
||||
if (!isContained(resolved, sandboxAbsolute)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||
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;
|
||||
return assertLexicalContainment(userPath, sandboxDir);
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
|
||||
@@ -128,6 +128,14 @@ 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();
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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
|
||||
@@ -29,11 +29,29 @@ 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
|
||||
# git was declined so routine base-image security updates remain maintainable.
|
||||
RUN apk add --no-cache git \
|
||||
# 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 \
|
||||
&& 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
|
||||
|
||||
@@ -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` | Docker Swarm stacks (status/redeploy/list) |
|
||||
| portainer | `tools/portainer/*.sh` | Optional Docker Swarm tools when a Portainer credential is available |
|
||||
| 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) |
|
||||
|
||||
@@ -53,21 +53,23 @@ sends, it does not auto-reply.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| 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`) |
|
||||
| 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`) |
|
||||
|
||||
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
|
||||
instead:
|
||||
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
|
||||
Confirm instead:
|
||||
|
||||
```bash
|
||||
tmux capture-pane -p -t <session>:0.0 | tail -20
|
||||
```
|
||||
|
||||
rc=2 is the normal result when the target is an idle pi seat.
|
||||
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.
|
||||
|
||||
## Durable comms
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ The human is escalation-only for missing access, hard policy conflicts, or irrev
|
||||
|
||||
### Supported Targets
|
||||
|
||||
- **Portainer**: Deploy via `~/.config/mosaic/tools/portainer/stack-redeploy.sh`, then verify with `stack-status.sh`.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
name: mosaic-deploy
|
||||
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.'
|
||||
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.'
|
||||
---
|
||||
|
||||
# mosaic-deploy
|
||||
|
||||
End-to-end deployment flow for Mosaic Stack projects.
|
||||
End-to-end deployment flow.
|
||||
|
||||
## Full Deploy Sequence
|
||||
|
||||
```
|
||||
push branch → open PR → CI passes → merge → portainer redeploy
|
||||
push branch → open PR → CI passes → merge → documented deploy path
|
||||
```
|
||||
|
||||
### Step 1: Push branch and open PR
|
||||
@@ -49,25 +49,32 @@ 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: Redeploy Portainer stack
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||
```
|
||||
|
||||
Check deployment:
|
||||
Check a Portainer deployment:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 50
|
||||
```
|
||||
|
||||
## Stack Name Map
|
||||
## Optional Portainer Stack Map
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
| Project | Stack Name |
|
||||
| ------------ | ----------------- |
|
||||
@@ -77,6 +84,6 @@ shape:
|
||||
## Notes
|
||||
|
||||
- Workers open PRs but **never merge** — orchestrator or Merge Guard handles step 3+
|
||||
- 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
|
||||
- Docker Swarm image pinning: `-p` does not change a digest-pinned image. Follow the stack README's documented deployment procedure.
|
||||
- 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,15 +1,19 @@
|
||||
---
|
||||
name: mosaic-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.
|
||||
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.
|
||||
---
|
||||
|
||||
# mosaic-portainer
|
||||
|
||||
Manage Portainer stacks via pre-built Mosaic scripts.
|
||||
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.
|
||||
|
||||
## Setup
|
||||
|
||||
Always load credentials before running scripts:
|
||||
After confirming a Portainer credential is available, load it before running scripts:
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh
|
||||
@@ -33,11 +37,11 @@ All scripts live in `~/.config/mosaic/tools/portainer/`.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Redeploy a stack with fresh images:**
|
||||
**Redeploy a stack through Portainer:**
|
||||
|
||||
```bash
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials portainer
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n mosaic-stack -p
|
||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name> -p
|
||||
```
|
||||
|
||||
**Check all stack statuses:**
|
||||
@@ -51,12 +55,10 @@ 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 mosaic-stack -l 100
|
||||
~/.config/mosaic/tools/portainer/stack-logs.sh -n <stack-name> -l 100
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- 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)
|
||||
- `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`.
|
||||
|
||||
@@ -1,220 +1,64 @@
|
||||
#!/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.
|
||||
#!/usr/bin/python3
|
||||
# git-credential-mosaic — production entrypoint (P0-SEC R4, rev-code-02 B1).
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
# 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.
|
||||
#
|
||||
# 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>
|
||||
# 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.
|
||||
#
|
||||
# ── 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.
|
||||
# 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.
|
||||
|
||||
[ "$1" = "get" ] || exit 0
|
||||
import os
|
||||
import sys
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
# 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
|
||||
# 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",
|
||||
)
|
||||
|
||||
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
|
||||
env = {"_MOSAIC_HELPER_CLEAN": "1"}
|
||||
for name in KEEP:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
env[name] = value
|
||||
|
||||
# ── 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
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/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."
|
||||
|
||||
@@ -34,20 +34,23 @@ 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 (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# Mirror the real deployed layout (~/.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/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
HELPER="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic"
|
||||
IMPL="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic.impl"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$SVC_STORE" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$FAKE_HOME/.mosaic/tools/git" \
|
||||
"$FAKE_HOME/.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]"
|
||||
@@ -55,7 +58,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/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
cat > "$FAKE_HOME/.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 ;;
|
||||
@@ -82,7 +85,7 @@ run_helper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||
bash "$HELPER" get <<EOF
|
||||
"$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
@@ -90,6 +93,80 @@ 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
|
||||
@@ -222,7 +299,10 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
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)
|
||||
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=')"
|
||||
|
||||
@@ -236,8 +316,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_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"
|
||||
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
|
||||
: > "$WORK_DIR/stderr.tmp"
|
||||
set +e
|
||||
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||
@@ -288,7 +368,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" bash "$HELPER" store <<EOF
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" "$HELPER" store <<EOF
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
password=whatever
|
||||
@@ -310,7 +390,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 \
|
||||
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||
"$HELPER" get <<EOF >/dev/null 2>&1
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -349,7 +429,7 @@ nospool_err=$(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
"$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -365,6 +445,193 @@ 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
|
||||
|
||||
@@ -28,6 +28,7 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
|
||||
# --- tools/tmux: require a live tmux server ---
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
|
||||
|
||||
# --- single-suite directories: unmeasured in CI ---
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivered verdicts are runtime-agnostic (cursor-row draft transition, or
|
||||
# the queued banner); locate_input_box() below adds positive DRAFT evidence
|
||||
# for panes that render a recognizable box, and never gates delivery on a
|
||||
# runtime's rendering shape.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
@@ -99,11 +101,33 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||||
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||||
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||||
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||||
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||||
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||||
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||||
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||||
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||||
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||||
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||||
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||||
local cy line
|
||||
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||||
[ -n "$cy" ] || return 1
|
||||
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||||
}
|
||||
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||||
[ -n "$snippet" ] || return 1
|
||||
grep -qF "$snippet" <<<"$(_cursor_line)"
|
||||
}
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not
|
||||
# be located at all. Found-but-empty is a real, distinct answer (an empty input
|
||||
# box is what a submitted message leaves behind), so the caller must branch on
|
||||
# the return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
@@ -113,10 +137,15 @@ sleep 0.5
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
# Compose authority rule (#1332 O1): this function is POSITIVE DRAFT EVIDENCE
|
||||
# ONLY. A located box still carrying our tail is affirmative proof the message
|
||||
# was not consumed (the cursor-row check's blind spot: a redrawn TUI can park
|
||||
# the cursor off the input line, which the draft-transition anchor cannot see).
|
||||
# Its failure to find a box proves NOTHING and must never produce an
|
||||
# UNDELIVERED verdict: a shapeless-but-submitting pane delivers via the
|
||||
# cursor-row transition regardless (measured, scratch probe 2026-09-04;
|
||||
# shapeless REPL consumed the message while shape probing alone reported
|
||||
# "may be UNDELIVERED" — the exact #1257 regression this split prevents).
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
@@ -139,13 +168,12 @@ locate_input_box() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||||
# proof we are on the right pane and the paste landed — the anchor the transition
|
||||
# check measures against.
|
||||
saw_draft=0
|
||||
_draft_on_input && saw_draft=1
|
||||
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
@@ -155,19 +183,30 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
# POSITIVE draft evidence from a located input box, when one exists. This is
|
||||
# the cursor-row check's blind spot: a redrawn TUI (pi's box) can park the
|
||||
# cursor off the input line, which the draft-transition anchor cannot see,
|
||||
# while a pane in COOKED mode (a plain shell whose foreground process never
|
||||
# reads stdin) echoes our paste via the kernel line discipline and moves the
|
||||
# cursor off it on Enter, indistinguishable from a real submit by cursor row
|
||||
# alone. If a locatable box still carries our tail, that is affirmative proof
|
||||
# the message was not consumed. Absence of a recognizable shape is never used
|
||||
# for anything — that inference is the original E7 bug, and the delivered
|
||||
# verdict stays with the runtime-agnostic cursor-row transition.
|
||||
if inputbox=$(locate_input_box "$pane"); then
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
fi
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
if [ "$saw_draft" = 1 ]; then
|
||||
if _draft_on_input; then
|
||||
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||||
fi
|
||||
status="delivered"; break # left the input line => positively submitted
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
status="delivered"; break
|
||||
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||||
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||||
status="unconfirmed"; continue
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
@@ -176,6 +215,6 @@ case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
|
||||
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
|
||||
# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ >
|
||||
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
|
||||
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
|
||||
# and the operator's rc=2-driven retry duplicates it.
|
||||
#
|
||||
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
|
||||
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
|
||||
#
|
||||
# Submission counting is EXACT and terminal-echo-independent: the fixture message
|
||||
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
|
||||
# number of times the REPL actually executed the send. This does not depend on how
|
||||
# many times the marker string is painted on screen.
|
||||
set -u
|
||||
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
|
||||
SOCKET="glyphagnostic-$$"
|
||||
TMP="$(mktemp -d)"
|
||||
tmux() { command tmux -L "$SOCKET" "$@"; }
|
||||
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
|
||||
trap cleanup EXIT
|
||||
pass=0; fail=0
|
||||
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
|
||||
|
||||
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
|
||||
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
|
||||
|
||||
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
|
||||
|
||||
# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission.
|
||||
mk ctl '❯ '
|
||||
SINK="$TMP/sink.ctl"
|
||||
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
|
||||
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "control: ❯-box submits => exit 0, exactly one submission"
|
||||
else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
|
||||
|
||||
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
|
||||
# (subs==1) yet shipping reports exit 2. Must be exit 0.
|
||||
mk sub $'──────── \n'
|
||||
SINK="$TMP/sink.sub"
|
||||
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
|
||||
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
|
||||
else no "glyphless: U+2500 box that submits => exit 0" \
|
||||
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
|
||||
|
||||
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
|
||||
# glyphless box, shipping => two submissions (the reported duplicate). The
|
||||
# property: one logical send => exactly one submission. Same fix closes it.
|
||||
mk dup $'──────── \n'
|
||||
SINK="$TMP/sink.dup"
|
||||
tries=0
|
||||
for attempt in 1 2; do
|
||||
tries=$((tries+1))
|
||||
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
|
||||
sleep 0.4
|
||||
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
|
||||
done
|
||||
if [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
|
||||
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
|
||||
|
||||
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
|
||||
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
|
||||
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
|
||||
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
|
||||
mk_rawstuck estuck
|
||||
SINK="$TMP/sink.estuck"
|
||||
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
|
||||
sleep 0.3
|
||||
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
|
||||
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
|
||||
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
|
||||
|
||||
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
|
||||
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
|
||||
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
|
||||
mk_busy ebusy
|
||||
SINK="$TMP/sink.ebusy"
|
||||
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
|
||||
if [ "$rc" = 0 ]; then
|
||||
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
|
||||
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
|
||||
|
||||
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
|
||||
# => must fail, never infer delivered from absence of a glyph/snippet.
|
||||
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
|
||||
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
|
||||
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
|
||||
|
||||
echo "---"; echo "pass=$pass fail=$fail"
|
||||
[ "$fail" = 0 ]
|
||||
@@ -4,10 +4,13 @@
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
|
||||
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
|
||||
# #1257; see the note at the fixture for why the old exit-2
|
||||
# assertion was wrong.
|
||||
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
|
||||
# => must fail loud. This carries the historical
|
||||
# false-positive guard that fixture 2 used to be credited with.
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
@@ -17,10 +20,16 @@
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
# 6. DELIVERED — a SHAPELESS REPL (no glyph, no box) that submits => exit 0.
|
||||
# The 2026-09-04 scratch probe regression: shape probing alone reports "may
|
||||
# be UNDELIVERED" on this pane while the message is consumed; the cursor-row
|
||||
# draft transition is the authoritative runtime-agnostic verdict.
|
||||
# 6b. UNCONFIRMED— a shapeless pane in raw/no-echo mode that never reads stdin
|
||||
# (shapeless) => exit 2 "could not confirm submission" (never delivered).
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SEND="${SEND:-$HERE/send-message.sh}"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
@@ -44,19 +53,44 @@ else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
|
||||
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
|
||||
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
|
||||
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
|
||||
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
|
||||
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
|
||||
# may be undelivered, and the retry that advice invites is the duplicate.
|
||||
#
|
||||
# The guard this fixture was reaching for is real and is NOT dropped: "never
|
||||
# infer delivered from absence" is now enforced positively by fixture 2b below
|
||||
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
|
||||
# still carrying our tail => draft). Absence alone decides nothing either way.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
|
||||
else
|
||||
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
|
||||
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
|
||||
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
|
||||
# there is no positive evidence of submission and the tool MUST fail loud. This
|
||||
# is the historical false-positive guard, kept as a positive test.
|
||||
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
|
||||
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
|
||||
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
|
||||
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -126,6 +160,51 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 6 and 6b: a SHAPELESS REPL. The pane renders nothing at all: no
|
||||
# prompt glyph and no rule box, so locate_input_box() alone can never see it
|
||||
# and shape-probing alone reports "may be UNDELIVERED" on a delivered message
|
||||
# (measured live 2026-09-04, scratch probe: a shapeless consumer CONSUMED the
|
||||
# message while the shipped shape probe exited 2 with retry advice - the exact
|
||||
# #1257 regression). The cursor-row draft transition is the authoritative,
|
||||
# runtime-agnostic delivered verdict: fixture 6's consumer submits => exit 0.
|
||||
# Fixture 6b is the guard arm: a shapeless pane whose foreground never reads
|
||||
# stdin keeps the echoed paste on the cursor line across every flush Enter =>
|
||||
# DRAFT => exit 2, never delivered.
|
||||
cat > "$TMP/shapeless.py" <<'SHAPELESS'
|
||||
import sys
|
||||
for line in sys.stdin:
|
||||
pass # consume and render nothing
|
||||
SHAPELESS
|
||||
tmux -L "$SOCKET" new-session -d -s shapeless -c "$TMP" "exec python3 -u '$TMP/shapeless.py'"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=shapeless" -m "fixture six shapeless consumed ok" 2>"$TMP/e6"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: shapeless REPL that submits => exit 0 ✓ delivered (probe regression)"
|
||||
else
|
||||
no "delivered: shapeless REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e6")]"
|
||||
fi
|
||||
|
||||
# MEASURED LIMIT (2026-09-04, this suite's development): a shapeless pane in
|
||||
# COOKED mode whose foreground never reads stdin (e.g. 'sleep infinity') scrolls
|
||||
# its kernel echo off the cursor row on the flush Enter, so no runtime-agnostic
|
||||
# signal available to the sender distinguishes it from a delivering pane. The
|
||||
# non-reading guard therefore requires either a locatable box still carrying the
|
||||
# tail (fixture 3) or raw/no-echo mode (fixture 2b). Real REPL seats read stdin,
|
||||
# which is why this limit is not reachable against agent seats; recorded here so
|
||||
# nobody rediscovers it as a silent gap.
|
||||
tmux -L "$SOCKET" new-session -d -s shapelessraw -c "$TMP" 'stty raw -echo; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=shapelessraw" -r 1 -m "fixture six b shapeless raw never consumed" 2>"$TMP/e6b"); then
|
||||
no "unconfirmed: shapeless raw non-reading pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e6b"; then
|
||||
ok "unconfirmed: shapeless raw non-reading pane => exit 2 + 'could not confirm submission'"
|
||||
else
|
||||
no "unconfirmed: shapeless raw non-reading pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e6b")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic structural check for the explicit dogfood Compose overlay.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/worktree" "$tmp/common.git" "$tmp/seat/secrets"
|
||||
|
||||
base_config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
docker compose --profile stack config --format json
|
||||
)
|
||||
|
||||
BASE_CONFIG_JSON="$base_config_json" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["BASE_CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
env = gateway["environment"]
|
||||
for key in (
|
||||
"MOSAIC_AGENT_NAME",
|
||||
"MOSAIC_GIT_IDENTITY",
|
||||
"MOSAIC_BRAIN_HOME",
|
||||
"AGENT_FILE_SANDBOX_DIR",
|
||||
"AGENT_USER_TOOLS",
|
||||
"AGENT_SHELL_ENABLED",
|
||||
"AGENT_DELIVERY_ENABLED",
|
||||
"MOSAIC_GIT_TOOLS_DIR",
|
||||
"MOSAIC_INTEGRATION_TRUNK",
|
||||
):
|
||||
assert key not in env, f"base compose unexpectedly sets dogfood variable {key}"
|
||||
|
||||
targets = {mount["target"] for mount in gateway["volumes"]}
|
||||
assert "/workspace/stack" not in targets
|
||||
assert not any(target.startswith("/opt/mosaic/brain/") for target in targets)
|
||||
PY
|
||||
|
||||
config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.dogfood.yml \
|
||||
--profile stack \
|
||||
config --format json
|
||||
)
|
||||
|
||||
CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_COMMON_GIT="$tmp/common.git" EXPECT_SEAT="$tmp/seat" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
assert gateway.get("init") is True, "gateway must run below an init process for R4 lineage"
|
||||
env = gateway["environment"]
|
||||
|
||||
expected_env = {
|
||||
"MOSAIC_AGENT_NAME": "code-dogfood-01",
|
||||
"MOSAIC_GIT_IDENTITY": "code-dogfood-01",
|
||||
"MOSAIC_BRAIN_HOME": "/opt/mosaic/brain",
|
||||
"AGENT_FILE_SANDBOX_DIR": "/workspace/stack",
|
||||
"AGENT_SHELL_ENABLED": "false",
|
||||
"AGENT_DELIVERY_ENABLED": "true",
|
||||
"MOSAIC_GIT_TOOLS_DIR": "/opt/mosaic/tools/git",
|
||||
"MOSAIC_INTEGRATION_TRUNK": "next",
|
||||
}
|
||||
for key, value in expected_env.items():
|
||||
assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}"
|
||||
|
||||
allowed = set(env["AGENT_USER_TOOLS"].split(","))
|
||||
assert allowed == {
|
||||
"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",
|
||||
}, f"unexpected dogfood tool set: {sorted(allowed)}"
|
||||
assert "shell_exec" not in allowed
|
||||
|
||||
mounts = {mount["target"]: mount for mount in gateway["volumes"]}
|
||||
worktree = mounts["/workspace/stack"]
|
||||
assert worktree["type"] == "bind"
|
||||
assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
|
||||
assert not worktree.get("read_only", False), "dogfood worktree must be writable"
|
||||
|
||||
common_git = mounts[os.environ["EXPECT_COMMON_GIT"]]
|
||||
assert common_git["type"] == "bind"
|
||||
assert common_git["source"] == os.environ["EXPECT_COMMON_GIT"]
|
||||
assert not common_git.get("read_only", False), "common Git directory must accept branch updates"
|
||||
|
||||
seat = mounts["/opt/mosaic/brain/fleet/agents/code-dogfood-01"]
|
||||
assert seat["type"] == "bind"
|
||||
assert seat["source"] == os.environ["EXPECT_SEAT"]
|
||||
assert seat.get("read_only") is True, "seat credential slot must be read-only"
|
||||
|
||||
other_seat_mounts = [
|
||||
target
|
||||
for target in mounts
|
||||
if target.startswith("/opt/mosaic/brain/fleet/agents/")
|
||||
and target != "/opt/mosaic/brain/fleet/agents/code-dogfood-01"
|
||||
]
|
||||
assert other_seat_mounts == [], f"other seat mounts leaked: {other_seat_mounts}"
|
||||
PY
|
||||
|
||||
# Each required path must fail closed rather than falling back to the current checkout.
|
||||
expect_missing_path() {
|
||||
local missing=$1 output rc
|
||||
set +e
|
||||
case "$missing" in
|
||||
MOSAIC_DOGFOOD_WORKTREE)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_WORKTREE \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_COMMON_GIT_DIR \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
MOSAIC_DOGFOOD_SEAT_HOME)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_SEAT_HOME \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR="$tmp/common.git" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
*)
|
||||
echo "FAIL: test requested unknown path variable $missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "FAIL: dogfood compose accepted missing $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *"$missing"* ]]; then
|
||||
echo "FAIL: missing-path failure did not name $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
expect_missing_path MOSAIC_DOGFOOD_WORKTREE
|
||||
expect_missing_path MOSAIC_DOGFOOD_COMMON_GIT_DIR
|
||||
expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME
|
||||
|
||||
printf 'dogfood compose verification passed\n'
|
||||
Reference in New Issue
Block a user