283 lines
9.5 KiB
TypeScript
283 lines
9.5 KiB
TypeScript
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];
|
|
}
|