security: constrain dogfood delivery execution (#1487)
This commit is contained in:
+4
-2
@@ -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 stack-dogfood seat.
|
||||
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
||||
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
|
||||
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood
|
||||
|
||||
@@ -226,10 +226,12 @@ seat outside the container, then set these paths in `.env`:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
|
||||
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
|
||||
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood
|
||||
```
|
||||
|
||||
The seat home must contain only that seat's credential at
|
||||
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-stack-dogfood.token`. Never place the token value in
|
||||
`.env`. Start the overlay with:
|
||||
|
||||
@@ -240,10 +242,11 @@ 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 `stack-dogfood` slot and fail
|
||||
if it is absent.
|
||||
|
||||
This deployment route is separate from the local source-development restrictions
|
||||
below.
|
||||
|
||||
@@ -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: 'stack-dogfood',
|
||||
MOSAIC_AGENT_NAME: 'stack-dogfood',
|
||||
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',
|
||||
'stack-dogfood',
|
||||
'secrets',
|
||||
'gitea-mosaicstack-stack-dogfood.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 stack-dogfood.');
|
||||
|
||||
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('stack-dogfood');
|
||||
}
|
||||
});
|
||||
|
||||
it('opens PRs only through pr-create.sh against next', async () => {
|
||||
const sandbox = tempDir('mosaic-delivery-pr-');
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const runner: ProcessRunner = async (file, args) => {
|
||||
calls.push({ file, args });
|
||||
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
|
||||
return ok('https://git.mosaicstack.dev/mosaicstack/stack/pulls/999\n');
|
||||
};
|
||||
const openPr = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
|
||||
(tool) => tool.name === 'git_open_pull_request',
|
||||
)!;
|
||||
|
||||
const result = await execute(openPr, {
|
||||
issue: 1487,
|
||||
title: 'docs: dogfood proof',
|
||||
body: 'Measured from the in-stack agent.',
|
||||
});
|
||||
expect(textOf(result)).toContain('/pulls/999');
|
||||
expect(calls[1]!.file).toBe('/opt/mosaic/tools/git/pr-create.sh');
|
||||
expect(calls[1]!.args).toEqual([
|
||||
'-t',
|
||||
'docs: dogfood proof',
|
||||
'-b',
|
||||
'Measured from the in-stack agent.',
|
||||
'-B',
|
||||
'next',
|
||||
'-H',
|
||||
'feat/1487-dogfood-proof',
|
||||
'-i',
|
||||
'1487',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks publish paths outside the sandbox before staging', async () => {
|
||||
const root = tempDir('mosaic-delivery-path-');
|
||||
const sandbox = path.join(root, 'sandbox');
|
||||
const outside = path.join(root, 'outside.md');
|
||||
fs.mkdirSync(sandbox);
|
||||
fs.writeFileSync(outside, 'OUTSIDE_DELIVERY_SENTINEL');
|
||||
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
||||
const runner: ProcessRunner = async (file, args) => {
|
||||
calls.push({ file, args });
|
||||
return args[0] === 'branch' ? ok('feat/1487-dogfood-proof\n') : ok();
|
||||
};
|
||||
const publish = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
|
||||
(tool) => tool.name === 'git_publish_branch',
|
||||
)!;
|
||||
|
||||
const result = await execute(publish, {
|
||||
issue: 1487,
|
||||
paths: [outside],
|
||||
commitMessage: 'docs: must not publish',
|
||||
});
|
||||
expect(textOf(result)).toContain('Path escape attempt blocked');
|
||||
expect(textOf(result)).not.toContain('OUTSIDE_DELIVERY_SENTINEL');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { guardPath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const PROCESS_TIMEOUT_MS = 120_000;
|
||||
const MAX_OUTPUT_BYTES = 100 * 1024;
|
||||
const SAFE_IDENTITY = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
const SAFE_BRANCH = /^(?:feat|fix|docs|test)\/[a-z0-9][a-z0-9._/-]*$/i;
|
||||
|
||||
export interface ProcessResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export type ProcessRunner = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
|
||||
) => Promise<ProcessResult>;
|
||||
|
||||
export interface DeliveryToolEnvironment {
|
||||
AGENT_DELIVERY_ENABLED?: string;
|
||||
MOSAIC_GIT_TOOLS_DIR?: string;
|
||||
MOSAIC_GIT_IDENTITY?: string;
|
||||
MOSAIC_AGENT_NAME?: string;
|
||||
MOSAIC_BRAIN_HOME?: string;
|
||||
MOSAIC_CREDENTIAL_SPOOL?: string;
|
||||
MOSAIC_CREDENTIAL_LINEAGE_FENCE?: string;
|
||||
MOSAIC_INTEGRATION_TRUNK?: string;
|
||||
HOME?: string;
|
||||
PATH?: string;
|
||||
LANG?: string;
|
||||
LC_ALL?: string;
|
||||
}
|
||||
|
||||
function runProcess(
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
|
||||
): Promise<ProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(file, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
let outputBytes = 0;
|
||||
|
||||
const append = (current: string, chunk: Buffer): string => {
|
||||
const remaining = MAX_OUTPUT_BYTES - outputBytes;
|
||||
if (remaining <= 0) return current;
|
||||
outputBytes += chunk.length;
|
||||
return current + chunk.subarray(0, remaining).toString();
|
||||
};
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout = append(stdout, chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr = append(stderr, chunk);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode: null, stdout, stderr: `${stderr}${String(error)}`, timedOut });
|
||||
});
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode, stdout, stderr, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanEnvironment(env: DeliveryToolEnvironment): NodeJS.ProcessEnv {
|
||||
const clean: NodeJS.ProcessEnv = {
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
for (const key of [
|
||||
'HOME',
|
||||
'PATH',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'MOSAIC_GIT_IDENTITY',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_BRAIN_HOME',
|
||||
'MOSAIC_CREDENTIAL_SPOOL',
|
||||
'MOSAIC_CREDENTIAL_LINEAGE_FENCE',
|
||||
] as const) {
|
||||
const value = env[key];
|
||||
if (value !== undefined) clean[key] = value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function textResult(text: string): {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details: undefined;
|
||||
} {
|
||||
return { content: [{ type: 'text', text }], details: undefined };
|
||||
}
|
||||
|
||||
function describeFailure(label: string, result: ProcessResult): string {
|
||||
if (result.timedOut) return `${label} timed out`;
|
||||
const diagnostic = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
|
||||
return `${label} failed (exit ${result.exitCode ?? 'null'}): ${diagnostic}`;
|
||||
}
|
||||
|
||||
function currentBranchPattern(issue: number): RegExp {
|
||||
return new RegExp(`^(?:feat|fix|docs|test)/${issue}(?:[-/].+)$`, 'i');
|
||||
}
|
||||
|
||||
export function createDeliveryTools(
|
||||
sandboxDir: string,
|
||||
sourceEnv: DeliveryToolEnvironment = process.env,
|
||||
runner: ProcessRunner = runProcess,
|
||||
): ToolDefinition[] {
|
||||
if (sourceEnv.AGENT_DELIVERY_ENABLED !== 'true') return [];
|
||||
|
||||
const identity = sourceEnv.MOSAIC_GIT_IDENTITY ?? '';
|
||||
const agentName = sourceEnv.MOSAIC_AGENT_NAME ?? '';
|
||||
const toolsDir = sourceEnv.MOSAIC_GIT_TOOLS_DIR ?? '';
|
||||
const baseBranch = sourceEnv.MOSAIC_INTEGRATION_TRUNK ?? 'next';
|
||||
if (!SAFE_IDENTITY.test(identity) || identity !== agentName) {
|
||||
throw new Error('Delivery tools require matching safe MOSAIC agent and git identities');
|
||||
}
|
||||
if (!path.isAbsolute(toolsDir)) {
|
||||
throw new Error('Delivery tools require an absolute MOSAIC_GIT_TOOLS_DIR');
|
||||
}
|
||||
if (!SAFE_BRANCH.test(`feat/${baseBranch}`) || baseBranch.includes('/')) {
|
||||
throw new Error('Delivery tools require a safe integration branch name');
|
||||
}
|
||||
|
||||
const env = cleanEnvironment(sourceEnv);
|
||||
const queueGuard = path.join(toolsDir, 'ci-queue-wait.sh');
|
||||
const prCreate = path.join(toolsDir, 'pr-create.sh');
|
||||
|
||||
const run = (file: string, args: readonly string[], timeoutMs = PROCESS_TIMEOUT_MS) =>
|
||||
runner(file, args, { cwd: sandboxDir, env, timeoutMs });
|
||||
|
||||
const readBranch = async (): Promise<{ branch?: string; error?: string }> => {
|
||||
const result = await run('/usr/bin/git', ['branch', '--show-current'], 15_000);
|
||||
if (result.exitCode !== 0) return { error: describeFailure('git branch', result) };
|
||||
const branch = result.stdout.trim();
|
||||
if (!SAFE_BRANCH.test(branch))
|
||||
return { error: `Unsafe delivery branch: ${branch || '<empty>'}` };
|
||||
if (branch === baseBranch || branch === 'main') {
|
||||
return { error: `Refusing delivery from protected branch ${branch}` };
|
||||
}
|
||||
return { branch };
|
||||
};
|
||||
|
||||
const publish: ToolDefinition = {
|
||||
name: 'git_publish_branch',
|
||||
label: 'Publish Git Branch',
|
||||
description:
|
||||
'Stage explicit files in the current sandbox branch, commit them as the dedicated dogfood identity, run the CI queue guard, and push the branch. No shell or raw provider API is used.',
|
||||
parameters: Type.Object({
|
||||
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
|
||||
paths: Type.Array(Type.String(), {
|
||||
minItems: 1,
|
||||
maxItems: 100,
|
||||
description: 'Files to stage, relative to the sandbox root',
|
||||
}),
|
||||
commitMessage: Type.String({ minLength: 1, maxLength: 4000 }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { issue, paths, commitMessage } = params as {
|
||||
issue: number;
|
||||
paths: string[];
|
||||
commitMessage: string;
|
||||
};
|
||||
const branchResult = await readBranch();
|
||||
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
|
||||
const branch = branchResult.branch;
|
||||
if (!currentBranchPattern(issue).test(branch)) {
|
||||
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
|
||||
}
|
||||
|
||||
const relativePaths: string[] = [];
|
||||
try {
|
||||
const sandboxRoot = guardPath('.', sandboxDir);
|
||||
for (const candidate of paths) {
|
||||
const resolved = guardPath(candidate, sandboxDir);
|
||||
const relative = path.relative(sandboxRoot, resolved);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new SandboxEscapeError(candidate, sandboxDir, resolved);
|
||||
}
|
||||
relativePaths.push(relative);
|
||||
}
|
||||
} catch (error) {
|
||||
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const add = await run('/usr/bin/git', ['add', '--', ...relativePaths], 30_000);
|
||||
if (add.exitCode !== 0) return textResult(`Error: ${describeFailure('git add', add)}`);
|
||||
|
||||
const commit = await run(
|
||||
'/usr/bin/git',
|
||||
[
|
||||
'-c',
|
||||
`user.name=${identity}`,
|
||||
'-c',
|
||||
`user.email=${identity}@mosaic.invalid`,
|
||||
'commit',
|
||||
'-m',
|
||||
commitMessage,
|
||||
'--',
|
||||
...relativePaths,
|
||||
],
|
||||
60_000,
|
||||
);
|
||||
if (commit.exitCode !== 0)
|
||||
return textResult(`Error: ${describeFailure('git commit', commit)}`);
|
||||
|
||||
const queue = await run(queueGuard, ['--purpose', 'push', '-B', branch]);
|
||||
if (queue.exitCode !== 0) {
|
||||
return textResult(`Error: ${describeFailure('CI queue guard', queue)}`);
|
||||
}
|
||||
|
||||
const push = await run(
|
||||
'/usr/bin/git',
|
||||
['push', '--set-upstream', 'origin', branch],
|
||||
PROCESS_TIMEOUT_MS,
|
||||
);
|
||||
if (push.exitCode !== 0) return textResult(`Error: ${describeFailure('git push', push)}`);
|
||||
|
||||
return textResult(`Published branch ${branch} as ${identity}.`);
|
||||
},
|
||||
};
|
||||
|
||||
const openPr: ToolDefinition = {
|
||||
name: 'git_open_pull_request',
|
||||
label: 'Open Pull Request',
|
||||
description:
|
||||
'Open a pull request from the current sandbox branch through the Mosaic pr-create wrapper. The wrapper targets the configured integration branch and links the tracking issue.',
|
||||
parameters: Type.Object({
|
||||
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
|
||||
title: Type.String({ minLength: 1, maxLength: 240 }),
|
||||
body: Type.String({ maxLength: 20_000 }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { issue, title, body } = params as { issue: number; title: string; body: string };
|
||||
const branchResult = await readBranch();
|
||||
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
|
||||
const branch = branchResult.branch;
|
||||
if (!currentBranchPattern(issue).test(branch)) {
|
||||
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
|
||||
}
|
||||
|
||||
const result = await run(prCreate, [
|
||||
'-t',
|
||||
title,
|
||||
'-b',
|
||||
body,
|
||||
'-B',
|
||||
baseBranch,
|
||||
'-H',
|
||||
branch,
|
||||
'-i',
|
||||
String(issue),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
return textResult(`Error: ${describeFailure('pr-create wrapper', result)}`);
|
||||
}
|
||||
return textResult(result.stdout.trim() || `Pull request opened from ${branch}.`);
|
||||
},
|
||||
};
|
||||
|
||||
return [publish, openPr];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
|
||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
||||
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
|
||||
};
|
||||
let safePath: string;
|
||||
try {
|
||||
safePath = guardPathUnsafe(path, baseDir);
|
||||
safePath = guardWritePath(path, baseDir);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxEscapeError) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export { createBrainTools } from './brain-tools.js';
|
||||
export { createCoordTools } from './coord-tools.js';
|
||||
export { createDeliveryTools } from './delivery-tools.js';
|
||||
export { createFileTools } from './file-tools.js';
|
||||
export { createGitTools } from './git-tools.js';
|
||||
export { createSearchTools } from './search-tools.js';
|
||||
export { createShellTools } from './shell-tools.js';
|
||||
export { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
|
||||
export { createWebTools } from './web-tools.js';
|
||||
export { createSkillTools } from './skill-tools.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
@@ -101,4 +101,55 @@ describe('guardPath', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a symlink inside the sandbox that resolves outside it', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
|
||||
try {
|
||||
const target = path.join(outside, 'credential.token');
|
||||
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
|
||||
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
|
||||
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardWritePath', () => {
|
||||
it('allows a new file under an existing real sandbox directory', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
try {
|
||||
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
|
||||
try {
|
||||
const target = path.join(outside, 'credential.token');
|
||||
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
|
||||
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
|
||||
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects new files under a directory symlink that leaves the sandbox', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
|
||||
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +1,63 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
|
||||
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
|
||||
*
|
||||
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
|
||||
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
|
||||
* within the user path — so symlink escape attempts are caught too.
|
||||
*
|
||||
* @param userPath - The path provided by the agent (may be relative or absolute)
|
||||
* @param sandboxDir - The allowed root directory (already validated on session creation)
|
||||
* @returns The resolved absolute path, guaranteed to be within sandboxDir
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxResolved = fs.realpathSync.native(sandboxDir);
|
||||
function isContained(candidate: string, root: string): boolean {
|
||||
return candidate === root || candidate.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
// Normalize both paths to resolve any symlinks in the sandbox root itself.
|
||||
// For the user path, we check containment BEFORE resolving symlinks in the path
|
||||
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
|
||||
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
|
||||
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbsolute = path.resolve(sandboxDir);
|
||||
if (!isContained(resolved, sandboxAbsolute)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path without resolving symlinks in the user-provided portion.
|
||||
* Use for paths that may not exist yet (creates, writes).
|
||||
*
|
||||
* Performs a lexical containment check only using path.resolve.
|
||||
* Resolve an existing path and verify both its lexical path and real symlink
|
||||
* target remain inside the sandbox.
|
||||
*/
|
||||
export function guardPath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
const resolvedReal = fs.realpathSync.native(resolved);
|
||||
if (!isContained(resolvedReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
|
||||
}
|
||||
return resolvedReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a writable file path whose parent already exists. Existing targets
|
||||
* are resolved fully. New targets use the real parent directory, which blocks
|
||||
* writes through a parent symlink that leaves the sandbox.
|
||||
*/
|
||||
export function guardWritePath(userPath: string, sandboxDir: string): string {
|
||||
const resolved = assertLexicalContainment(userPath, sandboxDir);
|
||||
const sandboxReal = fs.realpathSync.native(sandboxDir);
|
||||
let writableReal: string;
|
||||
try {
|
||||
writableReal = fs.realpathSync.native(resolved);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') throw error;
|
||||
const parentReal = fs.realpathSync.native(path.dirname(resolved));
|
||||
writableReal = path.join(parentReal, path.basename(resolved));
|
||||
}
|
||||
if (!isContained(writableReal, sandboxReal)) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
|
||||
}
|
||||
return writableReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
|
||||
* targets, where the path may name a deleted file and Git does not dereference
|
||||
* a tracked symlink.
|
||||
*/
|
||||
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
|
||||
const resolved = path.resolve(sandboxDir, userPath);
|
||||
const sandboxAbs = path.resolve(sandboxDir);
|
||||
|
||||
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
|
||||
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return assertLexicalContainment(userPath, sandboxDir);
|
||||
}
|
||||
|
||||
export class SandboxEscapeError extends Error {
|
||||
|
||||
@@ -128,6 +128,14 @@ function runCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export function createShellToolsIfEnabled(
|
||||
sandboxDir: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ToolDefinition[] {
|
||||
if (env['AGENT_SHELL_ENABLED'] === 'false') return [];
|
||||
return createShellTools(sandboxDir);
|
||||
}
|
||||
|
||||
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
||||
const defaultCwd = sandboxDir ?? process.cwd();
|
||||
|
||||
|
||||
@@ -9,12 +9,24 @@ services:
|
||||
MOSAIC_GIT_IDENTITY: stack-dogfood
|
||||
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}
|
||||
|
||||
@@ -33,13 +33,15 @@ ENV NODE_ENV=production
|
||||
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
|
||||
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
|
||||
RUN apk add --no-cache bash curl git jq python3 \
|
||||
&& ln -sf /bin/bash /usr/bin/bash \
|
||||
&& 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
|
||||
|
||||
@@ -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,7 +51,7 @@ 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
|
||||
|
||||
@@ -59,6 +64,10 @@ expected_env = {
|
||||
"MOSAIC_GIT_IDENTITY": "stack-dogfood",
|
||||
"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 +81,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,6 +92,11 @@ assert worktree["type"] == "bind"
|
||||
assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
|
||||
assert not worktree.get("read_only", False), "dogfood worktree must be writable"
|
||||
|
||||
common_git = mounts[os.environ["EXPECT_COMMON_GIT"]]
|
||||
assert common_git["type"] == "bind"
|
||||
assert common_git["source"] == os.environ["EXPECT_COMMON_GIT"]
|
||||
assert not common_git.get("read_only", False), "common Git directory must accept branch updates"
|
||||
|
||||
seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"]
|
||||
assert seat["type"] == "bind"
|
||||
assert seat["source"] == os.environ["EXPECT_SEAT"]
|
||||
@@ -105,6 +121,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 +146,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 +169,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'
|
||||
|
||||
Reference in New Issue
Block a user