blob: a312138dde7a12b0ef6455ab6178dcb2946eb77f [file]
import {
encodedTerminalInputActionsByteLength,
isShellRunId,
isWellFormedTerminalInput,
parseTerminalInputAction,
SHELL_RUN_ID_MAX_CHARS,
type TerminalInputAction,
type ShellRunStore,
type ShellRunUpdate,
type ToolResultContent,
} from '@maka/core';
import type { ShellPlan } from './shell-detect.js';
import type { ChildFdInput } from './child-fd-input.js';
import type { SandboxType } from './sandbox/types.js';
export const DEFAULT_BASH_TIMEOUT_MS = 120_000;
export const MAX_FOREGROUND_BASH_TIMEOUT_MS = 10 * 60 * 1_000;
export const MAX_WRITE_STDIN_INPUT_BYTES = 64 * 1024;
export const MAX_WRITE_STDIN_ACTIONS = 64;
export const MIN_PTY_COLS = 2;
export const MAX_PTY_COLS = 240;
export const MIN_PTY_ROWS = 1;
export const MAX_PTY_ROWS = 100;
export const MAX_SHELL_RUN_TIMEOUT_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_MAX_LIVE_SHELL_RUNS = 64;
export const DEFAULT_MAX_LIVE_PTY_RUNS = 8;
export const DEFAULT_SHELL_RUN_FLUSH_INTERVAL_MS = 1_000;
export const DEFAULT_SHELL_RUN_FLUSH_BYTES = 64 * 1024;
export const DEFAULT_PIPE_OUTPUT_DRAIN_MS = 2_000;
export const SHELL_RUN_CONTEXT_SUMMARY_LIMIT = 8;
export const SHELL_RUN_RESOURCE_PREFIX = 'maka://runtime/background-tasks';
export const MAX_SHELL_RUN_RESOURCE_REF_CHARS =
SHELL_RUN_RESOURCE_PREFIX.length + 1 + SHELL_RUN_ID_MAX_CHARS;
export { isWellFormedTerminalInput };
const SHELL_RUN_RESOURCE_PATH_PATTERN = /^\/background-tasks\/([^/]+)$/;
type ShellRunToolResult = Extract<ToolResultContent, { kind: 'shell_run' }>;
export class ShellRunPtyControlClosedError extends Error {
constructor() {
super('This PTY is stopping and no longer accepts input; use Read to observe its final state');
this.name = 'ShellRunPtyControlClosedError';
}
}
export interface ShellRunProcessManagerInput {
store: ShellRunStore;
newId: () => string;
now: () => number;
onShellRunUpdate?: (update: ShellRunUpdate) => void;
onPtyData?: (event: ShellRunPtyDataEvent) => void;
maxLiveShellRuns?: number;
maxLivePtyRuns?: number;
flushIntervalMs?: number;
flushBytes?: number;
maxRetainedChars?: number;
maxLiveEmitChars?: number;
killGraceMs?: number;
exitAcknowledgementMs?: number;
pipeOutputDrainMs?: number;
/** Schedules the automatic durable flush and returns its canceler; injected so tests can drive flush timing. */
scheduleFlush?: (run: () => void, delayMs: number) => () => void;
}
export interface ShellRunBashInput {
sessionId: string;
sourceRunId?: string;
sourceTurnId: string;
sourceToolCallId: string;
cwd: string;
command: string;
/** Final executable argv. When present, bypasses host-shell parsing. */
argv?: readonly string[];
env?: NodeJS.ProcessEnv;
/** Binary payloads exposed to pipe-mode children on inherited descriptors. */
fdInputs?: readonly ChildFdInput[];
pty?: boolean;
timeoutMs?: number;
abortSignal?: AbortSignal;
emitOutput: (stream: 'stdout' | 'stderr', chunk: string) => void;
shell?: ShellPlan;
/** Effective command sandbox selected before process launch. */
sandboxType?: SandboxType;
/** Invoked exactly once after startup failure or terminal process completion. */
onCompletion?: (outcome: { successful: boolean }) => void;
}
export interface ShellRunWriteInput {
sessionId: string;
ref: string;
input?: string;
actions?: readonly TerminalInputAction[];
size?: { cols: number; rows: number };
abortSignal?: AbortSignal;
}
export interface ShellRunPtyDataEvent {
sessionId: string;
ref: string;
sequence: number;
data: string;
}
export interface ShellRunPtySnapshot {
sessionId: string;
ref: string;
sequence: number;
buffer: string;
size: { cols: number; rows: number };
}
export interface RuntimeResourceReader {
readRuntimeResource(
sessionId: string,
ref: string,
abortSignal: AbortSignal,
): Promise<ToolResultContent>;
}
export interface BackgroundTaskStopper {
stopBackgroundTask(
sessionId: string,
ref: string,
abortSignal: AbortSignal,
): Promise<ToolResultContent>;
}
export interface PtyControlWriter {
writeStdin(input: ShellRunWriteInput): Promise<ShellRunToolResult>;
}
export function validateWriteStdinInput(input: ShellRunWriteInput): void {
if (input.input !== undefined && input.actions !== undefined) {
throw new Error('WriteStdin raw input and terminal actions are mutually exclusive');
}
if (input.input === undefined && input.actions === undefined && input.size === undefined) {
throw new Error('WriteStdin requires input, actions, and/or size');
}
if (input.input !== undefined) {
if (input.input.length === 0) throw new Error('WriteStdin input must not be empty');
if (!isWellFormedTerminalInput(input.input))
throw new Error('WriteStdin input must be well-formed Unicode');
const bytes = Buffer.byteLength(input.input, 'utf8');
if (bytes > MAX_WRITE_STDIN_INPUT_BYTES) {
throw new Error(`WriteStdin input exceeds the ${MAX_WRITE_STDIN_INPUT_BYTES}-byte limit`);
}
}
if (input.actions !== undefined) validateTerminalInputActions(input.actions);
if (input.size) {
if (
!Number.isInteger(input.size.cols) ||
input.size.cols < MIN_PTY_COLS ||
input.size.cols > MAX_PTY_COLS
) {
throw new Error(`WriteStdin cols must be between ${MIN_PTY_COLS} and ${MAX_PTY_COLS}`);
}
if (
!Number.isInteger(input.size.rows) ||
input.size.rows < MIN_PTY_ROWS ||
input.size.rows > MAX_PTY_ROWS
) {
throw new Error(`WriteStdin rows must be between ${MIN_PTY_ROWS} and ${MAX_PTY_ROWS}`);
}
}
}
function validateTerminalInputActions(actions: readonly TerminalInputAction[]): void {
if (actions.length === 0) throw new Error('WriteStdin actions must not be empty');
if (actions.length > MAX_WRITE_STDIN_ACTIONS) {
throw new Error(`WriteStdin actions must not exceed ${MAX_WRITE_STDIN_ACTIONS} entries`);
}
for (const action of actions) {
parseTerminalInputAction(action);
}
if (encodedTerminalInputActionsByteLength(actions) > MAX_WRITE_STDIN_INPUT_BYTES) {
throw new Error(`WriteStdin actions exceed the ${MAX_WRITE_STDIN_INPUT_BYTES}-byte limit`);
}
}
export function shellRunResourceRef(shellRunId: string): string {
if (!isShellRunId(shellRunId)) throw new Error('Invalid shell run id');
return `${SHELL_RUN_RESOURCE_PREFIX}/${encodeURIComponent(shellRunId)}`;
}
export function isShellRunResourceRef(ref: string): boolean {
return parseShellRunResourceRef(ref) !== null;
}
export function parseShellRunResourceRef(ref: string): { shellRunId: string } | null {
let url: URL;
try {
url = new URL(ref);
} catch {
return null;
}
if (
url.protocol !== 'maka:' ||
url.hostname !== 'runtime' ||
url.username ||
url.password ||
url.port ||
url.search ||
url.hash
)
return null;
const match = SHELL_RUN_RESOURCE_PATH_PATTERN.exec(url.pathname);
if (!match) return null;
const encodedId = match[1];
if (!encodedId) return null;
try {
const shellRunId = decodeURIComponent(encodedId);
if (!isShellRunId(shellRunId) || ref !== shellRunResourceRef(shellRunId)) return null;
return { shellRunId };
} catch {
return null;
}
}