Compare commits

..
Author SHA1 Message Date
code-be-02 2cfcb63cd8 fix(mosaic): honor seat-owned Gitea slots 2026-10-09 12:02:15 -05:00
fred 2101c9b446 fix(git-tools): issue-comment.sh resolves API base without monolith GITEA_URL (#1502)
ci/woodpecker/push/publish Pipeline was successful
2026-09-11 22:50:23 +00:00
code-infra-01andorch-01 5d27700026 fix(#1257): confirm delivery by draft transition, not prompt detection (adopts #1262) (#1332)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-09-04 22:25:13 +00:00
orch-01 d6302f8e6f docs: make Portainer optional deployment path (#1492)
ci/woodpecker/push/publish Pipeline was successful
2026-09-02 23:23:07 +00:00
marcieandorch-01 9aa4983cf2 fix: use canonical dogfood seat identity (#1490)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 23:43:22 +00:00
marcieandorch-01 736b0affc1 compose: add wrapper-first dogfood workspace (#1488)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-30 22:29:30 +00:00
32 changed files with 1552 additions and 146 deletions
+5 -3
View File
@@ -8,7 +8,9 @@ GATEWAY_HOST_PORT=14242
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
# Both paths are required when that overlay is used. Use a dedicated next-based
# worktree and the external home of the unprivileged stack-dogfood seat.
# 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_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
+14 -9
View File
@@ -221,16 +221,19 @@ 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 `stack-dogfood`
seat outside the container, then set these paths in `.env`:
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_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/code-dogfood-01
```
The seat home must contain only that seat's credential at
`secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in
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
@@ -240,10 +243,12 @@ docker compose \
--profile stack up -d
```
The overlay scopes regular-agent tools to the mounted checkout. For issue and PR
operations, instruct the agent to use `/opt/mosaic/tools/git/`. The gateway image
configures `git-credential-mosaic` as Git's system credential helper, so pushes and
`pr-create.sh` resolve only the `stack-dogfood` slot and fail if it is absent.
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.
+4 -2
View File
@@ -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];
}
+2 -2
View File
@@ -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 {
+2 -1
View File
@@ -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 });
}
});
});
+48 -32
View File
@@ -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();
+20 -5
View File
@@ -2,21 +2,36 @@
# 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: stack-dogfood
MOSAIC_GIT_IDENTITY: stack-dogfood
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_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,shell_exec
# 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 stack-dogfood seat directory}
target: /opt/mosaic/brain/fleet/agents/stack-dogfood
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
+9 -3
View File
@@ -36,13 +36,19 @@ 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 and PR-create wrapper
# as fleet seats. Copy only that operation and its shared dependencies. Unrelated
# fleet operations, including merge and infrastructure tools, stay out of the image.
# 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
@@ -0,0 +1,12 @@
{
"summary": "No important actionable issues found. Helper failures remain terminal, and the previously reported token-export regression is fixed. Bash syntax, package JSON, and caller-token export checks passed. The full regression suite was not run because the sandbox is read-only.",
"verdict": "approve",
"confidence": 0.88,
"findings": [],
"stats": {
"files_reviewed": 6,
"blockers": 0,
"should_fix": 0,
"suggestions": 0
}
}
@@ -0,0 +1,13 @@
{
"summary": "No confident security findings in the six supplied changed files. Seat lookups use the production helper, helper failures remain terminal, and returned credentials are validated. Bash syntax checks passed. Runtime regression tests were not run because the sandbox is read-only.",
"risk_level": "none",
"confidence": 0.87,
"findings": [],
"stats": {
"files_reviewed": 6,
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
}
}
@@ -0,0 +1,23 @@
# #1311 Credential seat-store alignment
## Scope
Restore the missing `load_credentials` Gitea seat-slot behavior in the Stack framework. A known fleet seat must obtain its token through the production credential helper, while its Gitea URL remains provider configuration. A missing seat slot must fail closed and never fall back to the service store.
## Evidence
- Base: `2101c9b4468b22f57b2f02bb2c9da12067225819` (`origin/next`).
- Historical branch `origin/fix/credentials-gitea-seat-slots` contains the pre-refactor direct-loader fix, but it predates the current Python wrapper and ancestry fence.
- Red reproduction: `test-credentials-gitea-seats.sh` failed on base for populated seats, empty-seat no-fallback, and cross-seat ancestry cases.
- Canonical source: `packages/mosaic/framework/`. The package installer and `mosaic-doctor` describe the shipped framework as the source for deployed framework tools. The brain runtime copy is an estate runtime copy, not this framework change's source.
## Decision
The loader delegates seat token resolution to its sibling `git-credential-mosaic` production entrypoint. It does not invoke `.impl` directly or read a seat slot itself. This preserves the wrapper's environment sanitization and the implementation's process-ancestry fence. Non-seat and no-identity service-store behavior, non-Gitea services, and pre-existing `GITEA_TOKEN` precedence remain unchanged. The loader continues to export a caller-supplied token so child tool processes receive it.
## Validation and review
- The final hermetic matrix passes for Mosaic and USC seat slots, empty and cross-seat refusals, service-store paths, explicit and unexported caller tokens, and Woodpecker isolation. It fails against `origin/next` for the expected missing behavior.
- `bash -n` passes for the loader and new suite. The framework test-enumeration guard passes with the new suite enumerated.
- `pnpm --dir packages/mosaic run test:framework-shell` reaches `invariant_r_unittest.py` and stops on its existing host-runtime check: the test expects Pi `0.84.1` while this host reports `1.0.3`. No file in that invariant or its runtime probe changed in this task. The suite passes its earlier systemd check when the user-bus environment is supplied.
- Initial independent code review found that the new conditional stopped exporting a caller-supplied unexported `GITEA_TOKEN`. The fix exports it in both Gitea arms and adds Mosaic/USC child-process regressions. Re-review approved with no findings. Independent security review reported risk level `none`.
+1 -1
View File
@@ -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`.
@@ -24,6 +24,14 @@
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
#
# Gitea has one additional identity-aware path. When the resolved git identity
# names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through
# tools/git/git-credential-mosaic rather than reading a slot directly. That
# production entrypoint enforces the clean-environment and process-ancestry
# fence before it reads a seat slot. A seat-slot miss is terminal: this loader
# never substitutes the service-store token for it. URLs remain provider
# configuration and continue to come from this loader's service store.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
@@ -94,6 +102,85 @@ _mosaic_load_woodpecker_legacy() {
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
}
_mosaic_resolve_git_identity() {
local ident="${MOSAIC_GIT_IDENTITY:-}"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
fi
printf '%s' "$ident"
}
_mosaic_git_identity_is_seat() {
local ident="$1" brain_home
[[ -n "$ident" ]] || return 1
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
[[ -d "$brain_home/fleet/agents/$ident" ]]
}
_mosaic_gitea_seat_token_from_helper() {
# Use the production wrapper, not its Bash implementation. The wrapper
# removes BASH_ENV/function injection before the implementation evaluates
# MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence.
local host="$1" ident="$2" script_dir helper response key value
local username="" password="" username_seen=0 password_seen=0
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
helper="$script_dir/../git/git-credential-mosaic"
if [[ ! -x "$helper" ]]; then
echo "Error: Gitea seat credential helper is unavailable: $helper" >&2
return 1
fi
if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then
return 1
fi
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
case "$key" in
username)
if (( username_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2
return 1
fi
username="$value"
username_seen=1
;;
password)
if (( password_seen )); then
echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2
return 1
fi
password="$value"
password_seen=1
;;
*)
echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2
return 1
;;
esac
done <<< "$response"
if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then
echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2
return 1
fi
printf '%s' "$password"
}
_mosaic_gitea_token() {
# $1 is the Gitea host and $2 is the legacy service-store jq path.
# Seats are delegated to the fenced helper; all other identities retain the
# existing service-store behavior. A failed seat delegation returns nonzero
# to the caller and deliberately cannot fall through to _mosaic_read_cred.
local host="$1" service_jq_path="$2" ident
ident="$(_mosaic_resolve_git_identity)"
if _mosaic_git_identity_is_seat "$ident"; then
_mosaic_gitea_seat_token_from_helper "$host" "$ident"
return
fi
_mosaic_read_cred "$service_jq_path"
}
load_credentials() {
local service="$1"
@@ -183,16 +270,31 @@ EOF
;;
gitea-mosaicstack)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.mosaicstack.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; }
# An explicit caller value retains the loader's established precedence.
# Otherwise, a known seat delegates to the ancestry-fenced helper and a
# non-seat identity uses the established service-store lookup.
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
# Preserve the loader's contract even when the caller supplied an
# unexported shell variable before invoking load_credentials.
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; }
;;
gitea-usc)
export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}"
export GITEA_TOKEN="${GITEA_TOKEN:-$(_mosaic_read_cred '.gitea.usc.token')}"
GITEA_URL="${GITEA_URL%/}"
[[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; }
if [[ -z "${GITEA_TOKEN:-}" ]]; then
local _gitea_token
_gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1
GITEA_TOKEN="$_gitea_token"
fi
export GITEA_TOKEN
[[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; }
;;
woodpecker-*)
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Hermetic regression for load_credentials Gitea seat-slot resolution.
#
# It runs against copied framework tools under a fake HOME, fixture credentials,
# and fake seat slots only. No real credential path is read.
#
# Contract pinned here:
# G1-G3 an ancestry-owned seat resolves its own host-scoped token through
# the production git-credential-mosaic entrypoint, whether identity
# comes from env or per-worktree git config.
# G4 an owned seat with no slot fails closed and never uses the service
# token.
# G5 a seat cannot request another seat's slot through load_credentials;
# the helper's ancestry fence remains the authorization boundary.
# G6/G7 no seat identity and a non-seat identity retain service-store
# behavior.
# G8-G10 explicitly supplied GITEA_TOKEN values keep their established
# precedence and are exported for child tool processes.
# G11 non-Gitea services remain unaffected.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/credentials-gitea-seats}"
FAKE_HOME="$WORK_DIR/home"
FRAMEWORK_TOOLS="$FAKE_HOME/.config/mosaic/tools"
BRAIN_DIR="$WORK_DIR/brain"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
SPOOL_DIR="$WORK_DIR/spool"
LOADER="$FRAMEWORK_TOOLS/_lib/credentials.sh"
HELPER="$FRAMEWORK_TOOLS/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$FRAMEWORK_TOOLS/_lib" "$FRAMEWORK_TOOLS/git" "$BRAIN_DIR/fleet/agents" \
"$REPO_DIR" "$SPOOL_DIR"
cp "$SCRIPT_DIR/credentials.sh" "$LOADER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic" "$HELPER"
cp "$SCRIPT_DIR/../git/git-credential-mosaic.impl" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
chmod +x "$HELPER" "$FRAMEWORK_TOOLS/git/git-credential-mosaic.impl"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.name 'Credential seat test'
git -C "$REPO_DIR" config user.email '[email protected]'
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "fixture-service-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "fixture-usc-service-token"
}
},
"woodpecker": {
"default": "mosaic",
"mosaic": {
"url": "https://ci.example.invalid",
"token": "fixture-woodpecker-token"
}
}
}
JSON
for seat in seat-owner seat-config seat-usc seat-victim empty-seat; do
mkdir -p "$BRAIN_DIR/fleet/agents/$seat/secrets"
done
printf '%s' 'fixture-owner-slot-token' > "$BRAIN_DIR/fleet/agents/seat-owner/secrets/gitea-mosaicstack-seat-owner.token"
printf '%s' 'fixture-config-slot-token' > "$BRAIN_DIR/fleet/agents/seat-config/secrets/gitea-mosaicstack-seat-config.token"
printf '%s' 'fixture-usc-slot-token' > "$BRAIN_DIR/fleet/agents/seat-usc/secrets/gitea-usc-seat-usc.token"
printf '%s' 'fixture-victim-slot-token' > "$BRAIN_DIR/fleet/agents/seat-victim/secrets/gitea-mosaicstack-seat-victim.token"
chmod 600 "$BRAIN_DIR"/fleet/agents/*/secrets/*.token
# Establishes a seat identity in an exec-frozen ancestor. The empty-name root
# carries the lineage fence, preventing the helper from seeing this suite's
# real parent process outside its hermetic fixture.
cat > "$WORK_DIR/lineage-root.sh" <<'ROOT'
#!/usr/bin/env bash
set -euo pipefail
caller="$1"
carrier="$2"
shift 2
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
bash "$carrier" "$@"
ROOT
cat > "$WORK_DIR/lineage-carrier.sh" <<'CARRIER'
#!/usr/bin/env bash
set -euo pipefail
loader="$1"
brain="$2"
credentials="$3"
repo="$4"
spool="$5"
identity_source="$6"
target="$7"
preexisting_token="$8"
service="${9:-gitea-mosaicstack}"
cd "$repo"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$identity_source" in
env) export MOSAIC_GIT_IDENTITY="$target" ;;
config) git config mosaic.gitIdentity "$target" ;;
none) ;;
*) echo "unknown identity source: $identity_source" >&2; exit 2 ;;
esac
if [[ "$preexisting_token" != '-' ]]; then
export GITEA_TOKEN="$preexisting_token"
fi
export MOSAIC_BRAIN_HOME="$brain"
export MOSAIC_CREDENTIALS_FILE="$credentials"
export MOSAIC_CREDENTIAL_SPOOL="$spool"
# shellcheck source=/dev/null
source "$loader"
load_credentials "$service"
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
CARRIER
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
run_lineage() {
local caller="$1" source="$2" target="$3" preset="$4" service="${5:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
"$LOADER" "$BRAIN_DIR" "$CREDENTIALS_FILE" "$REPO_DIR" "$SPOOL_DIR" \
"$source" "$target" "$preset" "$service"
}
run_plain() {
local source="$1" target="$2" preset="$3" service="${4:-gitea-mosaicstack}"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
LOADER="$LOADER" REPO_DIR="$REPO_DIR" IDENTITY_SOURCE="$source" TARGET="$target" \
PRESET="$preset" SERVICE="$service" bash -c '
set -euo pipefail
cd "$REPO_DIR"
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
git config --unset mosaic.gitIdentity 2>/dev/null || true
case "$IDENTITY_SOURCE" in
env) export MOSAIC_GIT_IDENTITY="$TARGET" ;;
config) git config mosaic.gitIdentity "$TARGET" ;;
none) ;;
*) exit 2 ;;
esac
if [[ "$PRESET" != "-" ]]; then export GITEA_TOKEN="$PRESET"; fi
source "$LOADER"
load_credentials "$SERVICE"
printf "url=%s\\ntoken=%s\\n" "$GITEA_URL" "$GITEA_TOKEN"
'
}
run_unexported_token() {
local service="$1"
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" LOADER="$LOADER" SERVICE="$service" \
bash -s <<'UNEXPORTED'
set -euo pipefail
unset GITEA_URL GITEA_TOKEN MOSAIC_GIT_IDENTITY
GITEA_TOKEN='fixture-unexported-token'
source "$LOADER"
load_credentials "$SERVICE"
child_token="$(bash -c 'printf "%s" "${GITEA_TOKEN:-}"')"
[[ "$child_token" == "$GITEA_TOKEN" ]] || {
echo 'GITEA_TOKEN was not exported to a child process' >&2
exit 1
}
printf 'url=%s\ntoken=%s\n' "$GITEA_URL" "$GITEA_TOKEN"
UNEXPORTED
}
fail=0
assert_success() {
local desc="$1" expected_token="$2" expected_url="$3"
shift 3
local err="$WORK_DIR/stderr.tmp" out rc token url
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: $desc — expected success, got rc=$rc" >&2
cat "$err" >&2
fail=1
return
fi
token="$(printf '%s\n' "$out" | awk -F= '/^token=/{print substr($0, 7)}')"
url="$(printf '%s\n' "$out" | awk -F= '/^url=/{print substr($0, 5)}')"
if [[ "$token" != "$expected_token" ]]; then
echo "FAIL: $desc — resolved the wrong token source" >&2
fail=1
fi
if [[ "$url" != "$expected_url" ]]; then
echo "FAIL: $desc — URL did not come from provider configuration" >&2
fail=1
fi
}
assert_refused() {
local desc="$1" expected_reason="$2"
shift 2
local err="$WORK_DIR/stderr.tmp" out rc
: > "$err"
set +e
out=$("$@" 2>"$err")
rc=$?
set -e
local diagnostics
diagnostics="$(cat "$err")"
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected refusal, got success" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: $desc — refusal emitted credential output" >&2
fail=1
fi
if [[ "$diagnostics" != *"$expected_reason"* ]]; then
echo "FAIL: $desc — refusal did not retain helper reason $expected_reason" >&2
fail=1
fi
if [[ "$out$diagnostics" == *'fixture-service-token'* || "$out$diagnostics" == *'fixture-victim-slot-token'* ]]; then
echo "FAIL: $desc — refusal exposed or fell back to another store" >&2
fail=1
fi
}
# G1: env identity, owning seat, populated Mosaic slot.
assert_success 'G1 env-owned seat uses its Mosaic slot through the helper' 'fixture-owner-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner -
# G2: the same contract when the helper and loader resolve git config identity.
assert_success 'G2 config-owned seat uses its Mosaic slot through the helper' 'fixture-config-slot-token' 'https://git.mosaicstack.dev' \
run_lineage seat-config config seat-config -
# G3: the USC arm remains host-scoped rather than borrowing Mosaic credentials.
assert_success 'G3 USC-owned seat uses its USC slot through the helper' 'fixture-usc-slot-token' 'https://git.uscllc.com' \
run_lineage seat-usc env seat-usc - gitea-usc
# G4: a seat slot miss must be terminal, never service-store fallback.
assert_refused 'G4 empty owned seat refuses without service fallback' 'no-token-for-identity' \
run_lineage empty-seat env empty-seat -
# G5: a child cannot select another seat by rewriting MOSAIC_GIT_IDENTITY.
assert_refused 'G5 cross-seat identity is refused by ancestry fencing' 'cross-seat-identity-refused' \
run_lineage seat-owner env seat-victim -
# G6/G7: non-seat paths retain the existing shared service-store behavior.
assert_success 'G6 no identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain none '' -
assert_success 'G7 non-seat identity keeps the service-store path' 'fixture-service-token' 'https://git.mosaicstack.dev' \
run_plain env service-automation -
# G8: caller-provided env values retain the established loader precedence.
assert_success 'G8 explicit GITEA_TOKEN remains caller-owned' 'fixture-preexisting-token' 'https://git.mosaicstack.dev' \
run_lineage seat-owner env seat-owner fixture-preexisting-token
# G9/G10: pre-existing shell variables keep the loader's export contract.
assert_success 'G9 unexported Mosaic token reaches child processes' 'fixture-unexported-token' 'https://git.mosaicstack.dev' \
run_unexported_token gitea-mosaicstack
assert_success 'G10 unexported USC token reaches child processes' 'fixture-unexported-token' 'https://git.uscllc.com' \
run_unexported_token gitea-usc
# G11: only Gitea has seat slots; Woodpecker remains service-scoped.
wp_out=$(env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_BRAIN_HOME="$BRAIN_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" MOSAIC_GIT_IDENTITY=seat-owner \
LOADER="$LOADER" bash -c '
set -euo pipefail
unset WOODPECKER_URL WOODPECKER_TOKEN
source "$LOADER"
load_credentials woodpecker
printf "%s|%s" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
')
if [[ "$wp_out" != 'https://ci.example.invalid|fixture-woodpecker-token' ]]; then
echo 'FAIL: G11 Woodpecker changed under a Gitea seat identity' >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo 'credentials Gitea seat-store regression passed'
fi
exit "$fail"
@@ -44,8 +44,8 @@ account/token configured through `tools/_lib/credentials.sh`. That means every a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity**:
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
@@ -65,6 +65,12 @@ The store is chosen by what the identity **is**, not by which file happens to ex
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
The Gitea arms of `load_credentials` obtain a seat token through the production
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
its established environment precedence.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
@@ -155,8 +155,15 @@ gitea_resolve_api_for_login() {
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
# No monolith-configured Gitea URL for this host (#1450): seat-token-only
# hosts carry no gitea-mosaicstack/gitea-usc credentials.sh entry and no
# bare GITEA_URL, so get_gitea_url_for_host has nothing to match against.
# Synthesize the API base directly from the git remote's own host --
# exactly the trust model issue-create.sh's REST fallback already uses
# successfully on these hosts. This is NOT a cross-host guess: $host came
# from get_remote_host() reading THIS repo's own origin remote, so the
# resolved base always matches the repo actually being acted on.
configured_url="https://${host}"
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
@@ -464,7 +464,7 @@ assert_refused_lineage "seat cannot override identity to another seat's slot" \
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: refused (T94 jarvis@ class).
# 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
@@ -44,6 +44,14 @@
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
# 12. (#1450, 2026-09-11) on a SEAT-TOKEN-ONLY host — identity resolved purely
# via MOSAIC_GIT_IDENTITY's per-slot token file, no tea login involved, and
# no monolith credentials.json entry for this Gitea host (so
# get_gitea_url_for_host has nothing to match) — the wrapper still
# resolves the API base directly from the git remote's own host (no
# cross-host fallback/guessing) instead of failing closed with
# "Configured Gitea URL not found", and the POST + exact-ID read-back both
# run under that same seat identity, never the (absent) host default.
set -euo pipefail
@@ -129,6 +137,12 @@ OVERRIDE_TOKEN="override-token-placeholder"
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# A seat-token-only identity (#1450): resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file under $HOME/.config/mosaic/secrets/gitea-tokens/ -- no tea
# login, no MOSAIC_CREDENTIALS_FILE entry for this host at all.
SEAT_IDENTITY="seat-only-agent"
SEAT_LOGIN="seat-only-actor"
SEAT_TOKEN="seat-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
@@ -169,6 +183,19 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
}, credentials)
PY
# A monolith credentials file that EXISTS but carries no gitea.mosaicstack (or
# gitea.usc) entry -- the seat-token-only condition (#1450). Distinct from
# $CREDENTIALS_FILE above, which does carry a configured URL for the other
# cases in this suite.
EMPTY_CREDENTIALS_FILE="$WORK_DIR/credentials-empty.json"
printf '{}' > "$EMPTY_CREDENTIALS_FILE"
# The seat identity's per-slot token file, exactly as a provisioned agent seat
# carries one: $HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-<agent>.token.
# get_gitea_token() resolves this BEFORE ever consulting MOSAIC_CREDENTIALS_FILE.
mkdir -p "$HOME_DIR/.config/mosaic/secrets/gitea-tokens"
printf '%s' "$SEAT_TOKEN" > "$HOME_DIR/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-${SEAT_IDENTITY}.token"
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
@@ -250,6 +277,7 @@ case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
"$ISSUE_COMMENT_SEAT_TOKEN") acting_identity="$ISSUE_COMMENT_SEAT_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
@@ -446,6 +474,53 @@ run_comment() {
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Seat-token-only variant (#1450): no monolith credentials.json entry for this
# host at all (MOSAIC_CREDENTIALS_FILE points at an empty {}), and identity
# resolves purely via MOSAIC_GIT_IDENTITY's per-slot token file. Everything
# else is identical to run_comment() -- same sandboxing, same always-exported
# constants -- so a diff against run_comment() is exactly these two overrides.
run_comment_seat() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$EMPTY_CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="$SEAT_IDENTITY" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_SEAT_LOGIN="$SEAT_LOGIN" \
ISSUE_COMMENT_SEAT_TOKEN="$SEAT_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
@@ -473,7 +548,7 @@ assert_no_temp_leak() {
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" -e "$SEAT_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
@@ -650,4 +725,27 @@ assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
# Case 12 (#1450, 2026-09-11): a SEAT-TOKEN-ONLY host -- no monolith
# credentials.json entry for this Gitea host at all (get_gitea_url_for_host has
# nothing to match), identity resolved purely via MOSAIC_GIT_IDENTITY's
# per-slot token file. The wrapper must still resolve the API base directly
# from the git remote's own host (no cross-host fallback/guessing -- proven by
# reusing this suite's existing $API_BASE/$API_ROOT constants unmodified) and
# run the POST, the /user lookup, and the exact-id read-back all under the
# seat identity, never a host-default identity that does not even exist here.
run_comment_seat fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_ROOT/user $SEAT_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $SEAT_LOGIN$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: seat-token-only run was attributed to the (nonexistent) host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "seat-token-no-monolith"
assert_token_not_in_argv "seat-token-no-monolith"
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -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 ]
File diff suppressed because one or more lines are too long
+39 -7
View File
@@ -5,7 +5,7 @@ set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/worktree" "$tmp/seat/secrets"
mkdir -p "$tmp/worktree" "$tmp/common.git" "$tmp/seat/secrets"
base_config_json=$(
cd "$repo_root"
@@ -26,6 +26,10 @@ for key in (
"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}"
@@ -38,6 +42,7 @@ 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 \
@@ -46,19 +51,24 @@ config_json=$(
config --format json
)
CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_SEAT="$tmp/seat" python3 <<'PY'
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": "stack-dogfood",
"MOSAIC_GIT_IDENTITY": "stack-dogfood",
"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}"
@@ -72,8 +82,10 @@ assert allowed == {
"git_status",
"git_log",
"git_diff",
"shell_exec",
"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"]
@@ -81,7 +93,12 @@ assert worktree["type"] == "bind"
assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
assert not worktree.get("read_only", False), "dogfood worktree must be writable"
seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"]
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"
@@ -90,7 +107,7 @@ other_seat_mounts = [
target
for target in mounts
if target.startswith("/opt/mosaic/brain/fleet/agents/")
and target != "/opt/mosaic/brain/fleet/agents/stack-dogfood"
and target != "/opt/mosaic/brain/fleet/agents/code-dogfood-01"
]
assert other_seat_mounts == [], f"other seat mounts leaked: {other_seat_mounts}"
PY
@@ -105,6 +122,19 @@ expect_missing_path() {
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
@@ -117,6 +147,7 @@ expect_missing_path() {
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
)
@@ -139,6 +170,7 @@ expect_missing_path() {
}
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'