/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { encodedTerminalInputActionsByteLength, isWellFormedTerminalInput, parseTerminalInputAction, type TerminalInputAction, } from '@maka/core/terminal-input'; import { isShellRunId, SHELL_RUN_ID_MAX_CHARS, type ShellRunStore } from '@maka/core/shell-run'; import { type ShellRunUpdate, type ToolResultContent } from '@maka/core/events'; 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_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; 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; /** Schedules the run timeout and returns its canceler; injected so tests can drive timeout timing. */ scheduleTimeout?: (run: () => void, delayMs: number) => () => void; } export interface ShellRunBashInput { sessionId: string; sourceRunId?: string; sourceTurnId: string; sourceToolCallId: string; /** User-owned terminals stay outside model context summaries. */ visibility?: 'model' | 'user'; 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; /** Client control may reach user-owned resources; model tools may not. */ caller?: 'model' | 'client'; } 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; } export interface BackgroundTaskStopper { stopBackgroundTask( sessionId: string, ref: string, abortSignal: AbortSignal, caller?: 'model' | 'client', ): Promise; } export interface PtyControlWriter { writeStdin(input: ShellRunWriteInput): Promise; } 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; } }