blob: 39eea9fd953271f693e7e4194d2e781d9d0959f3 [file]
import { z } from 'zod';
import { jsonSchema, zodSchema } from 'ai';
import {
encodedTerminalInputActionsByteLength,
isActiveShellRunStatus,
normalizeTerminalInputActionDefaults,
parseTerminalInputAction,
TERMINAL_INPUT_MODIFIERS,
TERMINAL_INPUT_NAMED_KEYS,
TERMINAL_MOUSE_BUTTONS,
TERMINAL_MOUSE_EVENTS,
TERMINAL_MOUSE_SCROLL_DIRECTIONS,
type TerminalInputAction,
} from '@maka/core';
import { redactSecrets } from '@maka/core';
import type { ToolResultContent } from '@maka/core';
import type { ToolExecutionFacts } from '@maka/core';
import type { SandboxBoundaryExpansion } from '@maka/core';
import type { MakaTool, MakaToolContext } from './tool-runtime.js';
import type { SandboxType } from './sandbox/types.js';
import { isLikelySandboxDenial } from './sandbox/detect.js';
import { runShellWithBoundedTail, type BoundedShellResult } from './shell-exec.js';
import { bashToolShellGuidance, defaultShellPlan, type ShellPlan } from './shell-detect.js';
import { truncateToolOutput } from './tool-output.js';
import {
DEFAULT_BASH_TIMEOUT_MS,
MAX_PTY_COLS,
MAX_PTY_ROWS,
MAX_FOREGROUND_BASH_TIMEOUT_MS,
MAX_SHELL_RUN_RESOURCE_REF_CHARS,
MAX_SHELL_RUN_TIMEOUT_MS,
MAX_WRITE_STDIN_ACTIONS,
MAX_WRITE_STDIN_INPUT_BYTES,
MIN_PTY_COLS,
MIN_PTY_ROWS,
type BackgroundTaskStopper,
type PtyControlWriter,
type ShellRunBashInput,
isShellRunResourceRef,
isWellFormedTerminalInput,
} from './shell-run-contract.js';
import type { ChildFdInput } from './child-fd-input.js';
import { bashToolResultToModelOutput } from './bash-model-output.js';
import {
preflightDeclaredSandboxBoundary,
sandboxBoundaryExpansionSchema,
} from './sandbox-boundary-declaration.js';
export interface ForegroundBashExecuteInput {
command: string;
cwd: string;
timeoutMs?: number;
ctx: MakaToolContext;
}
export interface ForegroundBashResult {
exitCode: number;
stdout: string;
stderr: string;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
timedOut?: boolean;
aborted?: boolean;
sandboxType?: SandboxType;
sandboxed?: boolean;
}
export interface BuildForegroundBashToolOptions {
description: string;
executionFacts?: ToolExecutionFacts;
defaultTimeoutMs?: (command: string) => number | undefined;
maxTimeoutMs?: number;
emitReturnedOutput?: boolean;
execute: (input: ForegroundBashExecuteInput) => Promise<ForegroundBashResult>;
afterResult?: (
input: { command: string; cwd: string; timeoutMs?: number },
result: ForegroundBashResult,
ctx: MakaToolContext,
) => Promise<void> | void;
}
type TerminalToolResult = Extract<ToolResultContent, { kind: 'terminal' }>;
type ShellRunToolResult = Extract<ToolResultContent, { kind: 'shell_run' }>;
export interface ShellRunLauncher {
runForegroundBash(input: ShellRunBashInput): Promise<TerminalToolResult>;
runBackgroundBash(input: ShellRunBashInput): Promise<ShellRunToolResult>;
}
export function buildForegroundBashTool(options: BuildForegroundBashToolOptions): MakaTool {
const maxTimeoutMs = options.maxTimeoutMs ?? 600_000;
return {
name: 'Bash',
activityKind: 'command',
description: options.description,
parameters: z.object({
command: z.string().describe('The shell command to execute'),
timeout_ms: z.number().int().positive().max(maxTimeoutMs).optional(),
}),
toModelOutput: ({ output }) => bashToolResultToModelOutput(output),
...(options.executionFacts ? { executionFacts: options.executionFacts } : {}),
impl: async ({ command, timeout_ms }, ctx) => {
const timeoutMs = timeout_ms ?? options.defaultTimeoutMs?.(command);
const result = await options.execute({ command, cwd: ctx.cwd, timeoutMs, ctx });
if (options.emitReturnedOutput) {
if (result.stdout) ctx.emitOutput('stdout', result.stdout);
if (result.stderr) ctx.emitOutput('stderr', result.stderr);
}
await options.afterResult?.(
{ command, cwd: ctx.cwd, ...(timeoutMs !== undefined ? { timeoutMs } : {}) },
result,
ctx,
);
return shapeTerminalResult({
cwd: ctx.cwd,
command,
result,
});
},
};
}
export function buildLocalForegroundBashTool(
options: { executionFacts?: ToolExecutionFacts; shell?: ShellPlan } = {},
): MakaTool {
const shell = options.shell ?? defaultShellPlan();
return buildForegroundBashTool({
description:
withShellGuidance('Run a shell command in the session cwd.', shell) +
' Subject to permission policy.',
...(options.executionFacts ? { executionFacts: options.executionFacts } : {}),
defaultTimeoutMs: () => 120_000,
execute: async ({ command, cwd, timeoutMs, ctx }) =>
runShellWithBoundedTail(command, {
cwd,
timeoutMs: timeoutMs ?? 120_000,
abortSignal: ctx.abortSignal,
emitOutput: ctx.emitOutput,
shell,
}),
});
}
export function buildManagedBashTool(
shellRuns: ShellRunLauncher,
options: {
executionFacts?: ToolExecutionFacts;
shell?: ShellPlan;
/** Opening sentence of the description, before the shared foreground/background/PTY contract. */
lead?: string;
/**
* Whether this host has a sandbox boundary the model can be asked to declare.
* False drops `required_boundary` from the schema entirely rather than
* accepting and ignoring it: a parameter no host enforces is pure noise in
* the model's tool selection. Headless runs inside an external isolation
* boundary with no in-process sandbox manager, so it passes false.
*/
declareSandboxBoundary?: boolean;
/**
* Foreground timeout when the model does not ask for one, per command —
* the same hook shape buildForegroundBashTool exposes, so a host that
* carves out a slow command keeps that carve-out on both paths instead of
* re-implementing it on one.
*
* A host default is CLAMPED to MAX_FOREGROUND_BASH_TIMEOUT_MS rather than
* passed through: the launcher REJECTS anything larger, so an operator who
* raised their own floor past ten minutes would otherwise break every
* foreground command instead of merely capping it. A timeout the model asks
* for explicitly is still rejected above the maximum — that is a stated
* schema bound, not a host misconfiguration.
*/
defaultTimeoutMs?: (command: string) => number | undefined;
/** Observes each committed result; used by hosts that record tool evidence. */
afterResult?: (
input: { command: string; cwd: string; timeoutMs?: number },
result: TerminalToolResult | ShellRunToolResult,
ctx: MakaToolContext,
) => Promise<void> | void;
transformCommand?: (input: {
command: string;
pty: boolean;
requiredBoundary?: SandboxBoundaryExpansion;
ctx: MakaToolContext;
}) =>
| {
argv?: readonly string[];
cwd: string;
env?: NodeJS.ProcessEnv;
fdInputs?: readonly ChildFdInput[];
sandboxType?: SandboxType;
onCompletion?: (outcome: { successful: boolean }) => void;
}
| undefined;
} = {},
): MakaTool {
const shell = options.shell ?? defaultShellPlan();
const declareSandboxBoundary = options.declareSandboxBoundary !== false;
const managedBashFields = {
command: z.string().describe('The shell command to execute'),
timeout_ms: z.number().int().positive().max(MAX_SHELL_RUN_TIMEOUT_MS).optional(),
run_in_background: z.boolean().optional(),
pty: z.boolean().optional(),
};
const refineManagedBash = (
{ timeout_ms, run_in_background, pty }: z.infer<z.ZodObject<typeof managedBashFields>>,
ctx: z.core.$RefinementCtx,
) => {
if (
!run_in_background &&
timeout_ms !== undefined &&
timeout_ms > MAX_FOREGROUND_BASH_TIMEOUT_MS
) {
ctx.addIssue({
code: 'too_big',
maximum: MAX_FOREGROUND_BASH_TIMEOUT_MS,
origin: 'number',
inclusive: true,
path: ['timeout_ms'],
message: `Foreground Bash timeout may not exceed ${MAX_FOREGROUND_BASH_TIMEOUT_MS}ms`,
});
}
if (pty && !run_in_background) {
ctx.addIssue({
code: 'custom',
path: ['pty'],
message: 'PTY Bash requires run_in_background=true',
});
}
};
return {
name: 'Bash',
activityKind: 'command',
description:
withShellGuidance(options.lead ?? 'Run a shell command in the session cwd.', shell) +
` Foreground is the default (timeout ${DEFAULT_BASH_TIMEOUT_MS}ms, maximum ${MAX_FOREGROUND_BASH_TIMEOUT_MS}ms).` +
` Set run_in_background=true only when the command should continue as a tracked runtime background task; background commands have no default timeout (maximum explicit timeout ${MAX_SHELL_RUN_TIMEOUT_MS}ms).` +
' Set pty=true together with run_in_background=true only for terminal semantics or later input; use the returned ref with Read or WriteStdin.' +
(declareSandboxBoundary ? ' Enforced by the current session sandbox boundary.' : ''),
parameters: declareSandboxBoundary
? z
.object({
...managedBashFields,
required_boundary: sandboxBoundaryExpansionSchema
.optional()
.describe(
'Declare the exact filesystem or network sandbox authority this command requires. Do not infer it from command text.',
),
})
.strict()
.superRefine(refineManagedBash)
: z.object(managedBashFields).strict().superRefine(refineManagedBash),
toModelOutput: ({ output }) => bashToolResultToModelOutput(output),
...(options.executionFacts ? { executionFacts: options.executionFacts } : {}),
impl: async (input, ctx) => {
const { command, timeout_ms, run_in_background, pty } = input;
const normalizedRequiredBoundary = await preflightDeclaredSandboxBoundary(
'required_boundary' in input ? input.required_boundary : undefined,
ctx,
);
const transformed = options.transformCommand?.({
command,
pty: pty === true,
...(normalizedRequiredBoundary ? { requiredBoundary: normalizedRequiredBoundary } : {}),
ctx,
});
const onCompletion = onceCompletion(transformed?.onCompletion);
const timeoutMs =
timeout_ms ??
(run_in_background
? undefined
: clampHostForegroundTimeout(options.defaultTimeoutMs?.(command)));
try {
const result = await shellRuns[
run_in_background ? 'runBackgroundBash' : 'runForegroundBash'
]({
sessionId: ctx.sessionId,
...(ctx.runId ? { sourceRunId: ctx.runId } : {}),
sourceTurnId: ctx.turnId,
sourceToolCallId: ctx.toolCallId,
cwd: transformed?.cwd ?? ctx.cwd,
command,
...(pty !== undefined ? { pty } : {}),
...(transformed?.argv ? { argv: transformed.argv } : { shell }),
...(transformed?.env ? { env: transformed.env } : {}),
...(transformed?.fdInputs ? { fdInputs: transformed.fdInputs } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
abortSignal: ctx.abortSignal,
emitOutput: ctx.emitOutput,
...(transformed?.sandboxType ? { sandboxType: transformed.sandboxType } : {}),
...(onCompletion ? { onCompletion } : {}),
});
if (result.kind === 'terminal' || !isActiveShellRunStatus(result.status)) {
onCompletion?.({
successful: result.status === 'completed' && result.exitCode === 0,
});
}
await options.afterResult?.(
{
command,
cwd: transformed?.cwd ?? ctx.cwd,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
},
result,
ctx,
);
return result;
} catch (error) {
onCompletion?.({ successful: false });
throw error;
}
},
};
}
function clampHostForegroundTimeout(value: number | undefined): number | undefined {
if (value === undefined) return undefined;
return Math.min(value, MAX_FOREGROUND_BASH_TIMEOUT_MS);
}
function onceCompletion(
callback: ((outcome: { successful: boolean }) => void) | undefined,
): ((outcome: { successful: boolean }) => void) | undefined {
if (!callback) return undefined;
let completed = false;
return (outcome) => {
if (completed) return;
completed = true;
callback(outcome);
};
}
export function withShellGuidance(lead: string, shell: ShellPlan): string {
const guidance = bashToolShellGuidance(shell);
return guidance ? `${lead} ${guidance}` : lead;
}
export function buildStopBackgroundTaskTool(backgroundTasks: BackgroundTaskStopper): MakaTool {
return {
name: 'StopBackgroundTask',
activityKind: 'command',
description:
'Stop a background task by runtime ref. Currently supports background shell run refs returned by Bash and shown in the turn tail.',
parameters: z.object({
ref: z
.string()
.describe(
'The runtime background task ref, for example maka://runtime/background-tasks/<id>',
),
}),
impl: ({ ref }, ctx) => backgroundTasks.stopBackgroundTask(ctx.sessionId, ref, ctx.abortSignal),
};
}
export function buildWriteStdinTool(ptyControls: PtyControlWriter): MakaTool {
const terminalAction = z.unknown().transform((value, context): TerminalInputAction => {
try {
return parseTerminalInputAction(value);
} catch (error) {
context.addIssue({
code: 'custom',
message: error instanceof Error ? error.message : 'Invalid terminal input action',
});
return value as TerminalInputAction;
}
});
const strictParameters = z.preprocess(
normalizeProviderWriteStdinInput,
z
.object({
ref: z
.string()
.max(MAX_SHELL_RUN_RESOURCE_REF_CHARS)
.refine(isShellRunResourceRef, 'ref must be a canonical PTY Bash runtime ref'),
input: z
.string()
.min(1, 'input must not be empty')
.refine(isWellFormedTerminalInput, 'input must be well-formed Unicode')
.refine(
(value) => Buffer.byteLength(value, 'utf8') <= MAX_WRITE_STDIN_INPUT_BYTES,
`input must not exceed ${MAX_WRITE_STDIN_INPUT_BYTES} bytes`,
)
.optional(),
actions: z.array(terminalAction).min(1).max(MAX_WRITE_STDIN_ACTIONS).optional(),
size: z
.object({
cols: z.number().int().min(MIN_PTY_COLS).max(MAX_PTY_COLS),
rows: z.number().int().min(MIN_PTY_ROWS).max(MAX_PTY_ROWS),
})
.strict()
.optional(),
})
.strict()
.refine((value) => value.input === undefined || value.actions === undefined, {
message: 'raw input and terminal actions are mutually exclusive',
})
.refine(
(value) =>
value.input !== undefined || value.actions !== undefined || value.size !== undefined,
{
message: 'input, actions, and/or size is required',
},
)
.superRefine((value, context) => {
if (!value.actions) return;
try {
if (encodedTerminalInputActionsByteLength(value.actions) > MAX_WRITE_STDIN_INPUT_BYTES) {
context.addIssue({
code: 'custom',
path: ['actions'],
message: `actions must not exceed ${MAX_WRITE_STDIN_INPUT_BYTES} encoded bytes`,
});
}
} catch {
// The key action reports its own precise validation issue.
}
}),
);
const providerAction = z
.object({
type: z.enum(['text', 'key', 'mouse']).describe('Action kind'),
text: z
.string()
.describe('Visible text for a text action; omit it for a key action')
.optional(),
key: z
.string()
.describe(
`Named key (${TERMINAL_INPUT_NAMED_KEYS.join(', ')}) or one printable ASCII character for a key action; omit it for a text action`,
)
.optional(),
event: z
.enum(TERMINAL_MOUSE_EVENTS)
.describe('Mouse event; omit it for text and key actions')
.optional(),
x: z
.number()
.int()
.min(0)
.describe('Zero-based terminal cell column for a mouse action')
.optional(),
y: z
.number()
.int()
.min(0)
.describe('Zero-based terminal cell row for a mouse action')
.optional(),
button: z
.enum(TERMINAL_MOUSE_BUTTONS)
.describe('Mouse button; required for click, press, and release')
.optional(),
direction: z
.enum(TERMINAL_MOUSE_SCROLL_DIRECTIONS)
.describe('Scroll direction; required only for scroll')
.optional(),
modifiers: z
.array(z.enum(TERMINAL_INPUT_MODIFIERS))
.describe('Optional unique modifiers for a key or mouse action')
.optional(),
})
.strict();
const providerParameters = z
.object({
ref: z
.string()
.max(MAX_SHELL_RUN_RESOURCE_REF_CHARS)
.describe('The runtime ref returned by a PTY Bash task'),
actions: z
.array(providerAction)
.max(MAX_WRITE_STDIN_ACTIONS)
.describe(
'Ordered terminal input actions. Text uses type and text. Key uses type, key, and optional modifiers. Mouse uses type, event, zero-based x/y, event-specific button or direction, and optional modifiers. Omit it for a resize-only call.',
)
.optional(),
size: z
.object({
cols: z.number().describe('Terminal columns').optional(),
rows: z.number().describe('Terminal rows').optional(),
})
.strict()
.optional(),
})
.strict()
.describe('Send ordered terminal actions and/or resize a background PTY');
const providerSchema = zodSchema(providerParameters);
const parameters = jsonSchema(async () => await providerSchema.jsonSchema, {
validate: async (value) => {
const result = await strictParameters.safeParseAsync(value);
return result.success
? { success: true, value: result.data }
: { success: false, error: result.error };
},
});
const parseInput = (value: unknown) => strictParameters.parse(value);
return {
name: 'WriteStdin',
activityKind: 'command',
description:
'Send an ordered sequence of text, key, and mouse actions to a background PTY and/or resize it, then return the terminal state at the next parser cut. ' +
`Named keys are ${TERMINAL_INPUT_NAMED_KEYS.join(', ')}. Use a printable ASCII key with ctrl or alt for chords such as Ctrl-B; use text for ordinary typing. ` +
'Mouse coordinates are zero-based terminal cells and work only while the application has enabled SGR cell mouse reporting. ' +
'Actions are written atomically in their listed order. Text is ordinary audited tool-call data, not a secure secret channel. ' +
'The returned output is the terminal state at that cut, not output attributed to this input; use Read on the ref to observe later output.',
parameters,
permissionArgs: (input) => parseInput(input),
impl: (input, ctx) => {
const { ref, input: rawInput, actions, size } = parseInput(input);
return ptyControls.writeStdin({
sessionId: ctx.sessionId,
ref,
...(rawInput !== undefined ? { input: rawInput } : {}),
...(actions !== undefined ? { actions } : {}),
...(size !== undefined ? { size } : {}),
abortSignal: ctx.abortSignal,
});
},
};
}
function normalizeProviderWriteStdinInput(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const normalized = { ...(value as Record<string, unknown>) };
if (normalized.actions === null || isEmptyArray(normalized.actions)) {
delete normalized.actions;
} else if (Array.isArray(normalized.actions)) {
normalized.actions = normalized.actions.map(normalizeTerminalInputActionDefaults);
}
if (normalized.size === null || isEmptyProviderSize(normalized.size)) delete normalized.size;
return normalized;
}
function isEmptyArray(value: unknown): value is [] {
return Array.isArray(value) && value.length === 0;
}
function isEmptyProviderSize(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entries = Object.entries(value as Record<string, unknown>);
return (
entries.every(([key]) => key === 'cols' || key === 'rows') &&
entries.every(([, field]) => field === undefined || field === null || field === 0)
);
}
export function shapeTerminalResult(input: {
cwd: string;
command: string;
result: ForegroundBashResult | BoundedShellResult;
}): TerminalToolResult {
const stdout = redactSecrets(input.result.stdout);
const stderr = redactSecrets(input.result.stderr);
const stdoutView = truncateToolOutput(stdout, { direction: 'tail' });
const stderrView = truncateToolOutput(stderr, { direction: 'tail' });
return {
kind: 'terminal',
cwd: input.cwd,
cmd: redactSecrets(input.command),
status: terminalStatus(input.result),
exitCode: input.result.exitCode,
output: {
mode: 'pipes',
stdout: stdoutView.content,
stderr: stderrView.content,
stdoutTruncated: Boolean(input.result.stdoutTruncated) || stdoutView.truncated,
stderrTruncated: Boolean(input.result.stderrTruncated) || stderrView.truncated,
redacted: stdout !== input.result.stdout || stderr !== input.result.stderr,
},
...(isLikelySandboxDenial({
stdout: input.result.stdout,
stderr: input.result.stderr,
sandboxed: 'sandboxed' in input.result && input.result.sandboxed === true,
})
? {
sandboxDenial: {
likely: true,
...('sandboxType' in input.result &&
(input.result.sandboxType === 'macos-seatbelt' || input.result.sandboxType === 'linux')
? { backend: input.result.sandboxType }
: {}),
},
}
: {}),
};
}
function terminalStatus(
result: ForegroundBashResult | BoundedShellResult,
): TerminalToolResult['status'] {
if (result.timedOut) return 'timed_out';
if (result.aborted) return 'cancelled';
return result.exitCode === 0 ? 'completed' : 'failed';
}