/** * SSH Console tools: SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot. * * These are the AI-facing wrappers around the in-memory ConsoleSession * registry. Each call goes through the same 12-step preflight as SshExec * (piece membership, access decision, enabled / abuse / host-key state) * via the exported `preflight` helper in ssh.ts. After that: * * - SshConsoleEnsure: find-or-open the session keyed by * (localTaskId, connectionId). A task can hold multiple concurrent * sessions, one per connection: opening a DIFFERENT connection ADDS a * session (it does not close the others). `force_replace` only restarts * the SAME connection's session (close 'connection_change' + reopen). * Enforces per-task, per-user, and per-connection session caps. * * - SshConsoleSend: deny-list check the input lines, then forward the * bytes to the session (which writes them straight to the PTY — same * path as human keystrokes from the WS). Wait the caller-supplied * waitMs (capped) and return a screen snapshot + new-output byte count * so the LLM can read the post-action terminal state. * * - SshConsoleSnapshot: kind=screen returns the rendered terminal view; * kind=scrollback returns the (ANSI-stripped) raw byte history capped * at max_bytes. Does not consume input. * * Plan: docs/superpowers/plans/2026-05-13-ssh-console.md (Phase 3). */ import type { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './tool-context.js'; import { ConsoleSession } from '../../ssh/console-session.js'; import { checkConsoleInput } from '../../ssh/console-deny-check.js'; import { clearBuffer } from '../../ssh/crypto.js'; import { logger } from '../../logger.js'; import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js'; import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js'; // ────────────────────────────────────────────────────────────────────── // openConsoleSession — the shared find-or-open core // ────────────────────────────────────────────────────────────────────── // // Extracted from the SshConsoleEnsure tool so a future HTTP endpoint can // create a session WITHOUT fabricating a ToolContext. The tool wrapper // (ensureSessionInternal) builds OpenConsoleParams from its ToolContext // (initiator: 'agent') and maps the structured result back to the tool's // existing return/error shape. Every preflight gate, host-key check, // disabled/abuse check, key decryption, shell-channel open, session // register, per-connection cap, and the `ssh.console.open` audit are // preserved verbatim — in the same order, with the same semantics. /** Explicit collaborators the find-or-open core needs. `sub` carries the * sessionRegistry, connectionRepo, access resolver, key decryptors, audit * repo, abuse repo, config (cols/rows defaults + caps) and openShellChannel. * `preflight` is threaded explicitly (it is the shared access/state gate). */ export interface OpenConsoleDeps { sub: SshSubsystem; preflight: typeof preflight; } export interface OpenConsoleParams { taskId: string; connectionId: string; /** Connection-resolution / audit owner (job.ownerId ?? 'local' upstream). */ ownerId: string | null; /** The acting principal (startedByUserId). */ userId: string; /** For the grant check (access resolver matches against this piece). */ pieceName: string; /** Workspace/space-scoped SSH connection IDs (['*'] for user-initiated console). */ allowedConnections: string[]; cols?: number; rows?: number; forceReplace?: boolean; /** Audit marker — distinguishes human-opened from agent-opened sessions. */ initiator: 'agent' | 'user'; /** Optional job id for the audit rows (agent path passes ctx.jobId). */ jobId?: string | null; /** * Per-space isolation (spec §11): the space of the job/task opening the * console. Gates access to space-scoped connections in the SAME way SshExec * does via ctx.spaceId. MUST be the already-resolved space id (personal-WS * NULL→personal-space handled by the caller, mirroring the worker). When * omitted, resolveAccess treats the caller as a no-space (legacy) job and * denies every space-scoped connection with `space_mismatch`. */ spaceId?: string | null; } export interface OpenConsoleResult { ok: boolean; /** True if a live session already existed and was reused (no open). */ alreadyActive?: boolean; connectionId?: string; cols?: number; rows?: number; /** Structured error code on failure. */ error?: | 'no_grant' | 'host_key_not_verified' | 'host_key_mismatch' | 'task_session_cap' | 'user_session_cap' | 'abuse_locked' | 'connection_disabled' | 'connection_not_found' | 'missing_connection_id' | 'missing_task_context' | 'no_user_context' | 'piece_not_configured' | 'piece_not_allowed' | 'preflight_denied' | 'decrypt_failed' | 'open_shell_failed'; /** Human/LLM-readable message (mirrors the tool's existing error strings). */ message?: string; /** The live session on success (consumed by the tool wrapper; not serialized). */ session?: ConsoleSession; } // ────────────────────────────────────────────────────────────────────── // Per-task open lock (TOCTOU guard for the find-or-open core) // ────────────────────────────────────────────────────────────────────── // // Opening a console session is a check-then-act sequence: look up any // existing (task, connection) session, enforce the per-task / per-user caps, // open the SSH channel, then register. Two concurrent opens for the SAME task // must not both pass the cap check, and must not both register a fresh session // for the same (task, connection) — the later register() would silently // overwrite (and orphan) the first, leaking its SSH client + PTY. We serialize // the whole existence-check → cap-check → channel-open → register sequence per // task with a minimal chained-promise mutex (opens are infrequent, so the // per-task serialization cost is negligible). const taskOpenLocks = new Map>(); async function withTaskLock(taskId: string, fn: () => Promise): Promise { const prev = taskOpenLocks.get(taskId) ?? Promise.resolve(); let release!: () => void; const gate = new Promise((r) => { release = r; }); const chained = prev.then(() => gate); taskOpenLocks.set(taskId, chained); // Wait for the previous holder to release. Swallow its rejection — each // holder is independent, so one open failing must not wedge the queue. await prev.catch(() => {}); try { return await fn(); } finally { release(); // If nobody queued behind us, drop the map entry so it can't grow // unbounded across tasks. Safe under JS's single-threaded model: any // later waiter's synchronous set() has already replaced this entry // before this finally runs. if (taskOpenLocks.get(taskId) === chained) { taskOpenLocks.delete(taskId); } } } /** * Find-or-open a ConsoleSession bound to (taskId, connectionId). Runs the * full preflight (piece membership, access decision, enabled / abuse / * host-key state), decrypts key material, opens the shell channel, builds + * registers the ConsoleSession, enforces the per-connection cap, and writes * the `ssh.console.open` audit (with `initiator` in the detail). * * Add-not-replace: a task may hold several sessions at once, one per * connection. Opening a NEW connection adds a session; it never closes the * task's other connections. Only `force_replace` on the SAME connection * restarts that one shell. The whole sequence is serialized per task * (`withTaskLock`) so concurrent opens can't exceed the caps or orphan a * session by double-registering the same (task, connection). * * Returns a structured OpenConsoleResult. On the happy path / reuse, * `ok: true` with `session` set; on any gate failure, `ok: false` with a * structured `error` code + `message`. */ export async function openConsoleSession( deps: OpenConsoleDeps, params: OpenConsoleParams, ): Promise { const connectionId = params.connectionId; if (!connectionId) { return { ok: false, error: 'missing_connection_id', message: 'SshConsoleEnsure: connection_id is required.' }; } const localTaskId = params.taskId ?? ''; if (!localTaskId) { return { ok: false, error: 'missing_task_context', message: 'SshConsoleEnsure: this tool requires a local task context (ctx.taskId).', }; } // Serialize the existence-check → cap-check → channel-open → register for // this task. See withTaskLock above for why (TOCTOU on caps + register). return withTaskLock(localTaskId, () => openConsoleSessionLocked(deps, params, connectionId, localTaskId)); } async function openConsoleSessionLocked( deps: OpenConsoleDeps, params: OpenConsoleParams, connectionId: string, localTaskId: string, ): Promise { const { sub } = deps; // Existence check is per (task, connection) now. Same connection already // live → reuse (or restart on force_replace). A DIFFERENT connection is // simply a new session below — opening it never touches other connections. const existing = sub.sessionRegistry.get(localTaskId, connectionId); if (existing) { if (params.forceReplace === true) { // Same-connection restart: close ONLY this connection's session, then // fall through to open a fresh one. Sibling sessions on other // connections of this task are left untouched. await sub.sessionRegistry.closeSession(localTaskId, connectionId, 'connection_change'); } else { return { ok: true, alreadyActive: true, connectionId: existing.connectionId, cols: existing.cols, rows: existing.rows, session: existing, }; } } // Per-task cap: how many live sessions this task already holds across all // its connections. At/over the cap → refuse, closing nothing. const maxSessionsPerTask = sub.config.console.maxSessionsPerTask; if ( typeof maxSessionsPerTask === 'number' && sub.sessionRegistry.listForTask(localTaskId).length >= maxSessionsPerTask ) { return { ok: false, error: 'task_session_cap', message: `SshConsoleEnsure: this task already has the maximum of ${maxSessionsPerTask} open console ` + `sessions. Close one (or reuse an existing connection) before opening another.`, }; } // Per-user cap: how many live sessions this initiator owns across ALL // tasks/connections. 0 = unlimited. At/over the cap → refuse. const maxSessionsPerUser = sub.config.console.maxSessionsPerUser; if (typeof maxSessionsPerUser === 'number' && maxSessionsPerUser > 0) { const ownedByUser = sub.sessionRegistry .listAll() .filter((s) => s.startedByUserId === params.userId).length; if (ownedByUser >= maxSessionsPerUser) { return { ok: false, error: 'user_session_cap', message: `SshConsoleEnsure: you already have the maximum of ${maxSessionsPerUser} open console ` + `sessions across all tasks. Close one before opening another.`, }; } } // Build the minimal ToolContext-shaped object the shared `preflight` // helper reads (userId, ownerId, pieceName, allowedSshConnections, jobId). // The HTTP caller never constructs a ToolContext — params are explicit and // this synthesis is internal to the shared core. const preCtx = { workspacePath: '', editAllowed: false, taskId: localTaskId, userId: params.userId, ownerId: params.ownerId, jobId: params.jobId ?? undefined, pieceName: params.pieceName, allowedSshConnections: params.allowedConnections, // Per-space isolation (spec §11): thread the task/job space so the shared // preflight's resolveAccess gates space-scoped connections identically to // SshExec. Without this every workspace-registered connection is denied // with `space_mismatch`. spaceId: params.spaceId ?? undefined, } as ToolContext; // Full 12-step preflight (same path as SshExec). const pre = deps.preflight({ toolName: 'SshExec', connectionId, ctx: preCtx, sub, auditAction: 'ssh.console.open', }); if (!pre.ok) { return { ok: false, error: 'preflight_denied', message: pre.error.output, }; } const { connection, actingUserId, pieceName } = pre; // Console requires a verified host key — there is no LLM-actionable // recovery from first_observe / mismatch on a long-lived shell. if (connection.hostKeyVerifiedAt === null) { sub.auditRepo.beginAndComplete( { action: 'ssh.console.open', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: preCtx.jobId ?? undefined, detail: { reason: 'host_key_not_verified', initiator: params.initiator }, }, 'denied', ); return { ok: false, error: 'host_key_not_verified', message: `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`, }; } const cols = typeof params.cols === 'number' && params.cols > 0 ? Math.floor(params.cols) : sub.config.console.defaultCols; const rows = typeof params.rows === 'number' && params.rows > 0 ? Math.floor(params.rows) : sub.config.console.defaultRows; // Decrypt key material — same flow as SshExec; we clear on failure but // keep alive past this call because the ssh2 Client needs the PEM through // the entire session. ConsoleSession.close() does NOT clear these // buffers (it can't see them) — we accept that the PEM stays in memory // for the lifetime of the session, which already holds the decrypted // channel and host connection state. let pemBuf: Buffer | null = null; let passBuf: Buffer | null = null; try { pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId); passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId); } catch (e) { if (pemBuf) clearBuffer(pemBuf); if (passBuf) clearBuffer(passBuf); sub.auditRepo.beginAndComplete( { action: 'ssh.console.open', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: preCtx.jobId ?? undefined, detail: { reason: 'decrypt_failed', msg: (e as Error).message, initiator: params.initiator }, }, 'failed', ); return { ok: false, error: 'decrypt_failed', message: 'SshConsoleEnsure: failed to decrypt stored key material.' }; } // Open the channel. On failure clear the PEM and bail. let channel: import('ssh2').ClientChannel; let client: import('ssh2').Client; let hostFingerprint: string; try { const shellResult = await sub.openShellChannel({ connection: { id: connection.id, ownerId: connection.ownerId, host: connection.host, port: connection.port, username: connection.username, privateKeyPem: pemBuf, passphrase: passBuf ?? undefined, hostKeyB64: connection.hostKeyB64, hostKeyVerified: true, allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses, }, cols, rows, timeoutMs: sub.config.callTimeoutSeconds * 1000, }); channel = shellResult.channel; client = shellResult.client; hostFingerprint = shellResult.hostFingerprint; } catch (e) { clearBuffer(pemBuf); clearBuffer(passBuf); sub.abuseRepo.checkAndRecordFailure({ connectionId, ownerId: connection.ownerId, userId: actingUserId, host: connection.host, username: connection.username, }); sub.auditRepo.beginAndComplete( { action: 'ssh.console.open', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: preCtx.jobId ?? undefined, detail: { reason: 'open_shell_failed', msg: (e as Error).message, initiator: params.initiator }, }, 'failed', ); return { ok: false, error: 'open_shell_failed', message: `SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}` }; } // Build the session and register it. From here on the channel + client // + PEM belong to the session; we don't clear them on the happy path. // The session ends the client (and thus releases the PEM-bound // connection) when it closes. const session = new ConsoleSession({ localTaskId, connectionId, ownerId: connection.ownerId, startedByUserId: actingUserId, cols, rows, scrollbackCap: sub.config.console.scrollbackBytes, channel, client, auditRepo: sub.auditRepo, }); sub.sessionRegistry.register(session); sub.abuseRepo.recordSuccess(connectionId); sub.auditRepo.beginAndComplete( { action: 'ssh.console.open', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: preCtx.jobId ?? undefined, detail: { cols, rows, host_fingerprint: hostFingerprint, initiator: params.initiator }, }, 'success', ); // Enforce the per-connection session cap (evict oldest). Close each evicted // session by its exact (task, connection) key — NOT closeForTask, which // would collaterally kill other connections the evicted task holds. const evict = sub.sessionRegistry.enforceCap(connectionId); for (const e of evict) { sub.sessionRegistry.closeSession(e.localTaskId, e.connectionId, 'session_cap_evict').catch((err) => logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`), ); } return { ok: true, alreadyActive: false, connectionId, cols, rows, session }; } // ────────────────────────────────────────────────────────────────────── // Agent current-connection pointer + shared session routing (Task 4) // ────────────────────────────────────────────────────────────────────── // // Task 3 made openConsoleSession add-not-replace, so a task can now hold // several live console sessions at once (one per connection). Send / Run / // Snapshot need to resolve an omitted connection_id to the RIGHT one rather // than an arbitrary "the" active session. We track, per task, which // connection the agent last acted on (Ensure/Send/Run/Snapshot) and use // that as the default when connection_id is omitted and there's more than // one live session. // // Populated by ensureSessionInternal (the agent-facing wrapper) — NOT by // the shared openConsoleSession core, which is also used by the // human-initiated HTTP console endpoint. Attributing a human's console // open to "the agent's current connection" would be wrong. const agentCurrentConnection = new Map(); function markAgentConnection(taskId: string, connectionId: string): void { agentCurrentConnection.set(taskId, connectionId); } /** Test-only: clear pointer state between test cases (module-scope Map * would otherwise leak taskId reuse — e.g. 'task-1' — across test files). */ export function __resetAgentConnectionPointer(): void { agentCurrentConnection.clear(); } export type ResolveSessionResult = | { ok: true; session: ConsoleSession; connectionId: string } | { ok: false; error: 'not_found'; connectionId: string } | { ok: false; error: 'ambiguous'; connectionIds: string[] } | { ok: false; error: 'none' }; /** * Shared routing for SshConsoleSend / SshConsoleRun / SshConsoleSnapshot * when connection_id may be omitted: * * 1. connection_id given → exact (taskId, connectionId) lookup. No live * session there → 'not_found'. We do NOT auto-open here: silently * opening a fresh session for an explicit-but-wrong id would mask a * typo'd connection_id instead of surfacing it. * 2. else the agent's current-connection pointer, if set and still live. * A pointer whose session has since closed is treated as unset (and * dropped) rather than resolved. * 3. else, if the task has exactly one live session, that one * (unambiguous single-session case — most tasks). * 4. else, if the task has more than one live session, 'ambiguous' — the * agent must disambiguate with connection_id. * 5. else 'none' — no live session at all for this task. */ export function resolveTargetSession( sub: SshSubsystem, taskId: string, connectionIdMaybe: string | undefined, ): ResolveSessionResult { if (connectionIdMaybe) { const session = sub.sessionRegistry.get(taskId, connectionIdMaybe); if (!session) return { ok: false, error: 'not_found', connectionId: connectionIdMaybe }; return { ok: true, session, connectionId: connectionIdMaybe }; } const pointerConnId = agentCurrentConnection.get(taskId); if (pointerConnId) { const session = sub.sessionRegistry.get(taskId, pointerConnId); if (session) return { ok: true, session, connectionId: pointerConnId }; // Stale pointer (session closed since) — drop it and fall through to // the listForTask-based resolution below. agentCurrentConnection.delete(taskId); } const sessions = sub.sessionRegistry.listForTask(taskId); if (sessions.length === 1) { const only = sessions[0]!; return { ok: true, session: only, connectionId: only.connectionId }; } if (sessions.length > 1) { return { ok: false, error: 'ambiguous', connectionIds: sessions.map((s) => s.connectionId) }; } return { ok: false, error: 'none' }; } /** * Screen-auto-inject lookup consumed by buildDynamicContextBlock (prompt.ts) * via __setActiveSessionLookup — shows the agent every one of its live console * screens in the job's trailing「現在のコンテキスト」user block (one block per * open connection; moved out of the system prompt for KV-cache stability, * PR #935). Returns ALL live sessions for the task, unfiltered; prompt.ts * is responsible for scoping the result down to the current movement's * `allowedSshConnections` (Task 5) — this function must not do that * filtering itself, since it has no visibility into which movement is * asking. */ export function listAgentConsoleSessions(sub: SshSubsystem, taskId: string): ConsoleSession[] { return sub.sessionRegistry.listForTask(taskId); } /** * Manually close a single (task, connection) console session on behalf of a * human actor (task owner/admin or the session's own starter — the HTTP route * enforces that gate). Closes via the registry with reason `user_close` AND * records an audit row naming the ACTOR. * * Why the extra audit row: ConsoleSession.close() already writes an * `ssh.console.close` audit, but it attributes the action to * `startedByUserId` — correct for idle/duration/transport teardown, WRONG for * a manual close by a different user (e.g. an admin killing someone's stray * session). This row captures "who actually pressed close" with the original * starter preserved in the detail for forensics. * * Returns `{ ok: false, error: 'not_found' }` when no live session exists for * the pair (raced with idle sweep / another close) so the caller can 404. */ export async function closeConsoleSessionByUser( sub: SshSubsystem, args: { taskId: string; connectionId: string; actorUserId: string }, ): Promise<{ ok: boolean; error?: 'not_found' }> { const session = sub.sessionRegistry.get(args.taskId, args.connectionId); if (!session) return { ok: false, error: 'not_found' }; sub.auditRepo.beginAndComplete( { action: 'ssh.console.close', connectionId: args.connectionId, ownerId: session.ownerId, actingUserId: args.actorUserId, detail: { reason: 'user_close', initiator: 'user', manual: true, started_by_user_id: session.startedByUserId, }, }, 'success', ); await sub.sessionRegistry.closeSession(args.taskId, args.connectionId, 'user_close'); return { ok: true }; } /** No-session-at-all wording differs slightly per tool (kept close to the * pre-Task-4 text so existing agent guidance / tests stay stable). */ function noActiveSessionMessage(toolName: string): string { if (toolName === 'SshConsoleSnapshot') { return `${toolName}: no live session for this task. Call SshConsoleEnsure({connection_id}) first.`; } return ( `${toolName}: connection_id is required when this task has no active session. ` + `Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.` ); } function notFoundMessage(toolName: string, connectionId: string): string { if (toolName === 'SshConsoleSnapshot') { return `${toolName}: no live session for connection ${connectionId} on this task. Call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`; } return `${toolName}: no session for connection ${connectionId}; call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`; } function ambiguousMessage(toolName: string, connectionIds: string[]): string { return `${toolName}: connection_id required (multiple sessions open: ${connectionIds.join(', ')}).`; } function resolveErrorToToolResult( toolName: string, resolved: Exclude, ): ToolResult { switch (resolved.error) { case 'not_found': return err(notFoundMessage(toolName, resolved.connectionId)); case 'ambiguous': return err(ambiguousMessage(toolName, resolved.connectionIds)); case 'none': return err(noActiveSessionMessage(toolName)); } } // ────────────────────────────────────────────────────────────────────── // Tool definitions // ────────────────────────────────────────────────────────────────────── export const TOOL_DEFS: Record = { SshConsoleEnsure: { type: 'function', function: { name: 'SshConsoleEnsure', description: 'SSH console セッションを確保します(無ければ open、有れば再利用)。接続ごとに1セッションで、別の connection_id を指定すると既存を閉じずに新しいセッションを追加します(タスクあたりの上限あり)。force_replace=true は同じ接続のセッションを閉じて張り直す用途。詳細は ReadToolDoc({ name: "SshConsoleEnsure" })。', parameters: { type: 'object', properties: { connection_id: { type: 'string', description: 'SSH 接続の UUID (SshListConnections で取得した id)。label やホスト名ではない。' }, cols: { type: 'number' }, rows: { type: 'number' }, force_replace: { type: 'boolean', description: '同じ connection_id の既存セッションを閉じて新規 open し直す(詰まり時の再接続用)。default=false。別の接続を開く場合は不要——自動で追加される。', }, }, required: ['connection_id'], }, }, }, SshConsoleSend: { type: 'function', function: { name: 'SshConsoleSend', description: 'console に入力を送る。通常のシェルコマンド実行には SshConsoleRun を使うこと(blocking + exit_code 取得)。SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id は開いているセッションが1つなら省略可(複数なら必須)。詳細は ReadToolDoc({ name: "SshConsoleSend" })。', parameters: { type: 'object', properties: { connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。明示するとその接続のセッションに送る。' }, input: { type: 'string' }, wait_ms: { type: 'number' }, confirm_interrupt: { type: 'boolean', description: 'Ctrl-C (\\x03) を含む入力が interrupt_blocked=true で返った場合、true を付けて再送すると強制送信する。', }, }, required: ['input'], }, }, }, SshConsoleSnapshot: { type: 'function', function: { name: 'SshConsoleSnapshot', description: 'console の現在画面または scrollback を取得します。connection_id はタスクに既に active session があれば省略可 (推奨)。詳細は ReadToolDoc({ name: "SshConsoleSnapshot" })。', parameters: { type: 'object', properties: { connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。' }, kind: { type: 'string', enum: ['screen', 'scrollback'] }, max_bytes: { type: 'number' }, }, required: [], }, }, }, SshConsoleRun: { type: 'function', function: { name: 'SshConsoleRun', description: 'console でコマンドを実行し、完了まで待機して output と exit_code を返します(blocking)。通常のコマンドはこれを使う。長コマンドは timeout_ms を延ばすこと(max 600000)。詳細は ReadToolDoc({ name: "SshConsoleRun" })。', parameters: { type: 'object', properties: { command: { type: 'string', description: '実行するシェルコマンド。' }, connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。' }, timeout_ms: { type: 'number', description: 'タイムアウト(ms)。デフォルト 120000、最大 600000。タイムアウト時はコマンドを kill しない。' }, idle_ms: { type: 'number', description: '出力が途切れた後のアイドル判定時間(ms)。0=無効(デフォルト)。' }, }, required: ['command'], }, }, }, }; const CONSOLE_TOOL_NAMES = new Set([ 'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun', ]); // ────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────── function err(output: string): ToolResult { return { output, isError: true }; } function ok(output: string): ToolResult { return { output, isError: false }; } /** * Subsystem + console.enabled gate. Console tools return a distinct error * vs the SshExec "not initialised" so the LLM can tell the feature is * off at the config layer (admin must toggle ssh.console.enabled). */ function checkConsoleGate(sub: SshSubsystem | null): ToolResult | null { if (!sub) { return err('SSH subsystem is not initialised (ssh.enabled=false or MCP_ENCRYPTION_KEY missing).'); } if (sub.maintenance.isActive()) { return err('SSH subsystem is in maintenance — retry in a moment.'); } if (!sub.config.console.enabled) { return err('SSH Console is disabled (ssh.console.enabled=false in config.yaml).'); } return null; } // ────────────────────────────────────────────────────────────────────── // SshConsoleEnsure // ────────────────────────────────────────────────────────────────────── interface EnsureResult { /** True if a fresh session was opened on this call. */ opened: boolean; session: ConsoleSession; } /** * Internal find-or-open helper. Returns a live ConsoleSession bound to * (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by * SshConsoleSend / SshConsoleSnapshot when no session is attached yet. * * Thin wrapper over the shared `openConsoleSession` core: it builds * OpenConsoleParams from the ToolContext (initiator: 'agent') and maps the * structured result back to the tool's existing return/error shape so the * tool's external behavior is byte-for-byte unchanged. */ async function ensureSessionInternal( input: Record, ctx: ToolContext, sub: SshSubsystem, ): Promise { const connectionId = typeof input.connection_id === 'string' ? input.connection_id : ''; const cols = typeof input.cols === 'number' ? input.cols : undefined; const rows = typeof input.rows === 'number' ? input.rows : undefined; const result = await openConsoleSession( { sub, preflight }, { taskId: ctx.taskId ?? '', connectionId, ownerId: ctx.ownerId ?? null, userId: (ctx.userId ?? ctx.ownerId ?? '').toString(), pieceName: ctx.pieceName ?? '', allowedConnections: ctx.allowedSshConnections ?? [], cols, rows, forceReplace: input.force_replace === true, initiator: 'agent', jobId: ctx.jobId ?? undefined, // Per-space isolation (spec §11): the worker already resolved ctx.spaceId // (personal-WS NULL→personal-space). Pass it so the console gate matches // SshExec — otherwise workspace-registered connections fail space_mismatch. spaceId: ctx.spaceId ?? null, }, ); if (!result.ok || !result.session) { return err(result.message ?? 'SshConsoleEnsure: failed to open session.'); } // Record this as the agent's current connection for this task — Send / // Run / Snapshot resolve an omitted connection_id to this pointer when // the task has more than one live session. ctx.taskId is guaranteed // non-empty here (openConsoleSession already rejected an empty taskId // with 'missing_task_context' before result.ok could be true). markAgentConnection(ctx.taskId as string, result.session.connectionId); return { opened: !result.alreadyActive, session: result.session }; } async function ensureTool( input: Record, ctx: ToolContext, sub: SshSubsystem, ): Promise { const r = await ensureSessionInternal(input, ctx, sub); if ('isError' in r) return r; const { opened, session } = r; return ok( JSON.stringify( { ok: true, opened, reused: !opened, connection_id: session.connectionId, cols: session.cols, rows: session.rows, }, null, 2, ), ); } // ────────────────────────────────────────────────────────────────────── // SshConsoleSend // ────────────────────────────────────────────────────────────────────── /** Cap waitMs to a sane range so the LLM can't burn an entire timeout * window on one Send call. Min 0 (no wait), max 5s. */ const MAX_WAIT_MS = 5_000; /** * Decode common escape sequences in AI input strings. Some LLMs serialize * tool args such that "\n" arrives as a real LF (0x0a), others surface it * literally as the 2-byte "\\n". Real bytes are preserved (the regex only * matches the literal 2-char forms), so this is safe to apply uniformly. * * Recognized: \n \r \t \0 \\ \" \xHH (2-hex). Unknown sequences such as * \q pass through unchanged. */ export function unescapeAiInput(text: string): string { return text.replace(/\\(x([0-9a-fA-F]{2})|.)/g, (_match, full: string, hex?: string) => { if (hex) return String.fromCharCode(parseInt(hex, 16)); switch (full) { case 'n': return '\n'; case 'r': return '\r'; case 't': return '\t'; case '0': return '\0'; case '\\': return '\\'; case '"': return '"'; case "'": return "'"; default: return '\\' + full; } }); } async function sendInput( input: Record, ctx: ToolContext, sub: SshSubsystem, ): Promise { const rawText = typeof input.input === 'string' ? input.input : ''; if (rawText.length === 0) { return err('SshConsoleSend: input is required (non-empty string).'); } const localTaskId = ctx.taskId ?? ''; if (!localTaskId) { return err('SshConsoleSend: this tool requires a local task context (ctx.taskId).'); } // Resolve the target session: explicit connection_id (exact lookup, no // auto-open), else the agent's current-connection pointer, else the // task's sole live session, else an ambiguity/no-session error. See // resolveTargetSession for the full precedence (Task 4: a task can now // hold several live sessions at once). const connectionIdInput = typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined; const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput); if (!resolved.ok) { return resolveErrorToToolResult('SshConsoleSend', resolved); } const { session, connectionId } = resolved; // Many LLMs serialize tool args correctly so "\n" in input means an // actual LF byte. But some surface escape sequences *literally* // (the tool args carry the 2-byte sequence "\\n" instead of "\n"), // and shells don't treat literal "\n" as Enter. Unescape common // sequences (\n \r \t \0 \\ \" \xNN) so AI input behaves as the // caller intended regardless of which encoding path the LLM took. // Real LF/CR bytes pass through unchanged (the regex only matches // the 2-byte "\\X" forms). const text = unescapeAiInput(rawText); // Auto-append \n when input clearly looks like a shell command missing its // newline (printable text, length >= 2, no existing line terminator, no // control byte that would indicate TUI / completion / SIGINT). The agent // frequently forgets to add \n and the shell then buffers the bytes as // partial readline without executing. Auto-append makes this the safe // default; agents that need raw partial input (sudo password, vim normal // mode keys, etc.) must include a control byte or shorter sequence. const hasLineTerminator = /[\r\n]/.test(text); const hasControlChar = /[\x00-\x08\x0b-\x1f\x7f]/.test(text); const autoNewlineAppended = text.length >= 2 && !hasLineTerminator && !hasControlChar; const sendText = autoNewlineAppended ? text + '\n' : text; // Length cap (UTF-8 byte length, not character count — matches the // raw bytes we'll write to the PTY). const bytesToSend = Buffer.byteLength(sendText, 'utf8'); const maxBytes = sub.config.console.maxInputBytesPerSend; if (bytesToSend > maxBytes) { return err( `SshConsoleSend: input is ${bytesToSend}B but max_input_bytes_per_send=${maxBytes}.`, ); } // Preflight to get the canonical connection for deny-list eval + audit // logging (latest snapshot in case admin policy changed since the // session was opened). const pre = preflight({ toolName: 'SshExec', connectionId, ctx, sub, auditAction: 'ssh.console.send', }); if (!pre.ok) return pre.error; const { connection, actingUserId, pieceName } = pre; // Deny check (built-in + per-connection patterns). Line-wise so the // LLM gets actionable feedback ("line 2 matched rm-rf"). const denyResult = checkConsoleInput( sendText, connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null, connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null, ); if (!denyResult.ok) { sub.auditRepo.beginAndComplete( { action: 'ssh.console.input_rejected', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { reason: denyResult.reason, line_index: denyResult.lineIndex, matched: denyResult.matched, }, }, 'denied', ); return err( `SshConsoleSend: input rejected by ${denyResult.reason} (line ${denyResult.lineIndex + 1}, pattern ${denyResult.matched ?? 'n/a'}).`, ); } // Record bytes_before so we can compute new_output_bytes after wait. const outputBytesBefore = session.totalOutputBytes; // Ctrl-C interrupt guard: if the payload contains \x03 and the session // looks like it is still running (small idleMs / recent AI input / // young session), block the write and ask the caller to confirm. if (sendText.includes('\x03')) { const liveness = { idleMs: session.idleMs(), msSinceAiInput: Date.now() - session.lastAiInputAt, sessionAgeMs: Date.now() - session.startedAt, }; if (shouldGuardInterrupt(liveness, input.confirm_interrupt === true)) { return ok( JSON.stringify({ ok: false, interrupt_blocked: true, require_confirm: true, reason: 'command appears to still be running', idle_ms: liveness.idleMs, hint: 'To wait for completion use SshConsoleRun. To really abort, resend with confirm_interrupt:true.', }), ); } } // Write to the session as 'ai' source. ConsoleSession.write forwards // bytes straight to the PTY so the shell echoes them back the same // way it does for human keystrokes — partial input is allowed and // shows up on screen immediately. LF→CR normalization happens inside // ConsoleSession.write so the PTY treats \n as Enter. session.write(Buffer.from(sendText, 'utf8'), 'ai'); // Wait so the LLM sees post-action terminal state. Cap at MAX_WAIT_MS; // 0 is allowed (return immediately, useful for control bytes like ^C). const waitMsRaw = typeof input.wait_ms === 'number' && Number.isFinite(input.wait_ms) ? input.wait_ms : 200; const waitMs = Math.max(0, Math.min(MAX_WAIT_MS, Math.floor(waitMsRaw))); if (waitMs > 0) { await new Promise((resolve) => setTimeout(resolve, waitMs)); } const screen = session.snapshotScreen(); const newOutputBytes = session.totalOutputBytes - outputBytesBefore; // (auto-newline was applied earlier — agent self-correction is no longer needed) // Record this as the agent's current connection now that the write // actually happened (the interrupt-guard early return above never // reaches here, since it didn't write anything). markAgentConnection(localTaskId, connectionId); sub.auditRepo.beginAndComplete( { action: 'ssh.console.send', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { bytes_sent: bytesToSend, wait_ms: waitMs, new_output_bytes: newOutputBytes, ...(autoNewlineAppended ? { auto_newline_appended: true } : {}), }, }, 'success', ); return ok( JSON.stringify( { ok: true, bytes_sent: bytesToSend, wait_ms: waitMs, new_output_bytes: newOutputBytes, screen_after: screen.text, cursor: screen.cursor, cols: screen.cols, rows: screen.rows, idle_ms: session.idleMs(), maybe_waiting_for_input: detectWaitingForInput(screen.text), ...(autoNewlineAppended ? { auto_newline_appended: true } : {}), }, null, 2, ), ); } // ────────────────────────────────────────────────────────────────────── // SshConsoleSnapshot // ────────────────────────────────────────────────────────────────────── /** Hard cap on scrollback bytes returned in one call — prevents the LLM * from blowing its context window by asking for the full ringbuffer. */ const MAX_SCROLLBACK_RETURN_BYTES = 64 * 1024; async function snapshot( input: Record, ctx: ToolContext, sub: SshSubsystem, ): Promise { const localTaskId = ctx.taskId ?? ''; if (!localTaskId) { return err('SshConsoleSnapshot: this tool requires a local task context (ctx.taskId).'); } // Snapshot does NOT auto-Ensure — if there is no live session, the LLM // should call SshConsoleEnsure / Send first. resolveTargetSession routes // an omitted connection_id to the agent's current-connection pointer, the // task's sole live session, or a clear ambiguity/no-session error (Task 4: // a task can now hold several live sessions at once). const connectionIdInput = typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined; const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput); if (!resolved.ok) { return resolveErrorToToolResult('SshConsoleSnapshot', resolved); } const { session, connectionId } = resolved; // Preflight (for audit + access check; we still want to see read attempts // in the audit log). const pre = preflight({ toolName: 'SshExec', connectionId, ctx, sub, auditAction: 'ssh.console.snapshot', }); if (!pre.ok) return pre.error; const { connection, actingUserId, pieceName } = pre; // Record this as the agent's current connection now that access has been // confirmed (the operation cannot fail past this point). markAgentConnection(localTaskId, connectionId); const kind = input.kind === 'scrollback' ? 'scrollback' : 'screen'; if (kind === 'screen') { const screen = session.snapshotScreen(); sub.auditRepo.beginAndComplete( { action: 'ssh.console.snapshot', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { kind: 'screen', cols: screen.cols, rows: screen.rows }, }, 'success', ); return ok( JSON.stringify( { ok: true, kind: 'screen', text: screen.text, cursor: screen.cursor, cols: screen.cols, rows: screen.rows, }, null, 2, ), ); } // kind === 'scrollback' const maxBytesRaw = typeof input.max_bytes === 'number' && Number.isFinite(input.max_bytes) ? input.max_bytes : MAX_SCROLLBACK_RETURN_BYTES; const maxBytes = Math.max(1, Math.min(MAX_SCROLLBACK_RETURN_BYTES, Math.floor(maxBytesRaw))); const sb = session.snapshotScrollback({ maxBytes }); sub.auditRepo.beginAndComplete( { action: 'ssh.console.snapshot', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { kind: 'scrollback', max_bytes: maxBytes, byte_count: sb.byteCount, truncated: sb.truncated, }, }, 'success', ); return ok( JSON.stringify( { ok: true, kind: 'scrollback', text: sb.text, byte_count: sb.byteCount, truncated: sb.truncated, }, null, 2, ), ); } // ────────────────────────────────────────────────────────────────────── // SshConsoleRun // ────────────────────────────────────────────────────────────────────── /** Default timeout for SshConsoleRun: 2 minutes. */ const DEFAULT_RUN_TIMEOUT_MS = 120_000; /** Maximum allowed timeout for SshConsoleRun: 10 minutes. */ const MAX_RUN_TIMEOUT_MS = 600_000; /** Maximum output bytes returned to caller. */ const MAX_RUN_OUTPUT_BYTES = 32 * 1024; /** Maximum screen_tail bytes returned to caller. */ const MAX_RUN_SCREEN_BYTES = 8 * 1024; function clampTimeout(raw: unknown): number { const v = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_RUN_TIMEOUT_MS; return Math.min(MAX_RUN_TIMEOUT_MS, Math.max(1_000, Math.floor(v))); } function capTail(s: string, maxBytes: number): string { if (s.length <= maxBytes) return s; return s.slice(s.length - maxBytes); } /** Tiny ANSI stripper — delegates to the same patterns as ConsoleSession.stripAnsi. */ function stripAnsiSafe(s: string): string { return s .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '') .replace(/\x1b[@-Z\\^_]/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); } type RunOutcome = | { kind: 'done'; exitCode: number } | { kind: 'human' } | { kind: 'idle' } | { kind: 'timeout' }; async function run( input: Record, ctx: ToolContext, sub: SshSubsystem, ): Promise { const command = typeof input.command === 'string' ? input.command.trim() : ''; if (!command) { return err('SshConsoleRun: command is required (non-empty string).'); } const localTaskId = ctx.taskId ?? ''; if (!localTaskId) { return err('SshConsoleRun: this tool requires a local task context (ctx.taskId).'); } // Resolve the target session: explicit connection_id (exact lookup, no // auto-open), else the agent's current-connection pointer, else the // task's sole live session, else an ambiguity/no-session error (Task 4: // a task can now hold several live sessions at once). const connectionIdInput = typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined; const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput); if (!resolved.ok) { return resolveErrorToToolResult('SshConsoleRun', resolved); } const { session, connectionId } = resolved; // Preflight for audit + access check. const pre = preflight({ toolName: 'SshExec', connectionId, ctx, sub, auditAction: 'ssh.console.run', }); if (!pre.ok) return pre.error; const { connection, actingUserId, pieceName } = pre; // Record this as the agent's current connection now that access has been // confirmed (the run below can still time out / be interrupted, but it // did act on this connection). markAgentConnection(localTaskId, connectionId); // Deny-check the command. const denyResult = checkConsoleInput( command + '\n', connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null, connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null, ); if (!denyResult.ok) { sub.auditRepo.beginAndComplete( { action: 'ssh.console.run', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { reason: denyResult.reason, line_index: denyResult.lineIndex, matched: denyResult.matched, }, }, 'denied', ); return err( `SshConsoleRun: command rejected by ${denyResult.reason} (pattern ${denyResult.matched ?? 'n/a'}).`, ); } // Build the marker command. const nonce = makeNonce(); const { text: markerText } = makeMarkerCommand(command, nonce); const timeoutMs = clampTimeout(input.timeout_ms); const idleMs = typeof input.idle_ms === 'number' && Number.isFinite(input.idle_ms) ? Math.max(0, Math.floor(input.idle_ms)) : 0; const startAt = Date.now(); const humanBaseline = session.lastHumanInputAt; const scrollStart = session.scrollbackBytes().length; // Block until done, human interrupt, idle, or timeout. const result = await new Promise((resolve) => { let acc = ''; let lastChunkAt = Date.now(); let finished = false; const finish = (outcome: RunOutcome) => { if (finished) return; finished = true; cleanup(); resolve(outcome); }; const unsub = session.onOutput((chunk: Buffer) => { acc += chunk.toString('utf8'); lastChunkAt = Date.now(); const m = parseMarker(stripAnsiSafe(acc), nonce); if (m.found) finish({ kind: 'done', exitCode: m.exitCode! }); }); const timer = setInterval(() => { if (session.lastHumanInputAt > humanBaseline) return finish({ kind: 'human' }); if (idleMs > 0 && Date.now() - lastChunkAt >= idleMs) return finish({ kind: 'idle' }); if (Date.now() - startAt >= timeoutMs) return finish({ kind: 'timeout' }); }, 100); const cleanup = () => { unsub(); clearInterval(timer); }; // Send the marker command to the session. session.write(Buffer.from(markerText, 'utf8'), 'ai'); }); // Build output from scrollback since scrollStart. const rawAll = session.scrollbackBytes().slice(scrollStart).toString('utf8'); const output = capTail(extractOutput(stripAnsiSafe(rawAll), command, nonce), MAX_RUN_OUTPUT_BYTES); const screen = session.snapshotScreen(); const screenTail = capTail(screen.text, MAX_RUN_SCREEN_BYTES); sub.auditRepo.beginAndComplete( { action: 'ssh.console.run', connectionId, ownerId: connection.ownerId, actingUserId, pieceName, jobId: ctx.jobId ?? undefined, detail: { command, timeout_ms: timeoutMs, outcome: result.kind, exit_code: result.kind === 'done' ? result.exitCode : null, output_bytes: output.length, }, }, result.kind === 'done' ? 'success' : 'success', ); return ok( JSON.stringify( { ok: true, done: result.kind === 'done', exit_code: result.kind === 'done' ? result.exitCode : null, output, screen_tail: screenTail, idle_ms: session.idleMs(), interrupted_by: result.kind === 'human' ? 'human' : null, timed_out: result.kind === 'timeout', maybe_waiting_for_input: detectWaitingForInput(screenTail), marker_found: result.kind === 'done', }, null, 2, ), ); } // ────────────────────────────────────────────────────────────────────── // Dispatcher // ────────────────────────────────────────────────────────────────────── export async function executeTool( name: string, input: Record, ctx: ToolContext, ): Promise { if (!CONSOLE_TOOL_NAMES.has(name)) return null; const sub = getSshSubsystem(); const gate = checkConsoleGate(sub); if (gate) return gate; // checkConsoleGate guarantees sub is non-null when it returns null. const subsystem = sub as SshSubsystem; if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem); if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem); if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem); if (name === 'SshConsoleRun') return run(input, ctx, subsystem); return null; }