Files
maestro/src/engine/tools/ssh-console.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

1390 lines
56 KiB
TypeScript

/**
* 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<string, Promise<void>>();
async function withTaskLock<T>(taskId: string, fn: () => Promise<T>): Promise<T> {
const prev = taskOpenLocks.get(taskId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((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<OpenConsoleResult> {
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<OpenConsoleResult> {
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<string, string>();
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<ResolveSessionResult, { ok: true }>,
): 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<string, ToolDef> = {
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<string, unknown>,
ctx: ToolContext,
sub: SshSubsystem,
): Promise<EnsureResult | ToolResult> {
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<string, unknown>,
ctx: ToolContext,
sub: SshSubsystem,
): Promise<ToolResult> {
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<string, unknown>,
ctx: ToolContext,
sub: SshSubsystem,
): Promise<ToolResult> {
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<void>((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<string, unknown>,
ctx: ToolContext,
sub: SshSubsystem,
): Promise<ToolResult> {
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<string, unknown>,
ctx: ToolContext,
sub: SshSubsystem,
): Promise<ToolResult> {
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<RunOutcome>((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<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
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;
}