blob: e1c866db91803d59b6b7335ae5a17694afc2d20f [file]
import {
isShellRunId,
SHELL_RUN_ID_MAX_CHARS,
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 { ToolExecutionPermissionContext } from './additional-permissions.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 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 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;
const SHELL_RUN_RESOURCE_PATH_PATTERN = /^\/background-tasks\/([^/]+)$/;
type ShellRunToolResult = Extract<ToolResultContent, { kind: 'shell_run' }>;
export interface ShellRunProcessManagerInput {
store: ShellRunStore;
newId: () => string;
now: () => number;
onShellRunUpdate?: (update: ShellRunUpdate) => void;
maxLiveShellRuns?: number;
maxLivePtyRuns?: number;
flushIntervalMs?: number;
flushBytes?: number;
maxRetainedChars?: number;
maxLiveEmitChars?: number;
killGraceMs?: number;
exitAcknowledgementMs?: number;
}
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;
/** One-call grants consumed by ToolRuntime for this exact invocation. */
permissionContext?: ToolExecutionPermissionContext;
/** 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;
size?: { cols: number; rows: number };
abortSignal?: AbortSignal;
}
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.size === undefined) {
throw new Error('WriteStdin requires input 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.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}`);
}
}
}
export function isWellFormedTerminalInput(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
if (index + 1 >= value.length) return false;
const next = value.charCodeAt(index + 1);
if (next < 0xdc00 || next > 0xdfff) return false;
index += 1;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return false;
}
}
return true;
}
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;
}
}