feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,712 @@
|
||||
/**
|
||||
* 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).
|
||||
* If a session already exists for a different connection on this task,
|
||||
* close it with reason 'connection_change' and open a fresh one. Apply
|
||||
* per-connection session cap (evict oldest with 'session_cap_evict').
|
||||
*
|
||||
* - 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 './core.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';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Tool definitions
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
SshConsoleEnsure: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SshConsoleEnsure',
|
||||
description:
|
||||
'SSH console セッションを確保します(無ければ open、有れば再利用)。既存セッションが別 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 にあるとき、true なら旧セッションを閉じて新規 open。default=false (mismatch ならエラー返却)。',
|
||||
},
|
||||
},
|
||||
required: ['connection_id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SshConsoleSend: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SshConsoleSend',
|
||||
description:
|
||||
'console に入力を送る。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id はタスクに active session があれば省略可。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' },
|
||||
input: { type: 'string' },
|
||||
wait_ms: { type: 'number' },
|
||||
},
|
||||
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: '省略時はこのタスクの active session を自動採用。' },
|
||||
kind: { type: 'string', enum: ['screen', 'scrollback'] },
|
||||
max_bytes: { type: 'number' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONSOLE_TOOL_NAMES = new Set([
|
||||
'SshConsoleEnsure',
|
||||
'SshConsoleSend',
|
||||
'SshConsoleSnapshot',
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
*/
|
||||
async function ensureSessionInternal(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
sub: SshSubsystem,
|
||||
): Promise<EnsureResult | ToolResult> {
|
||||
const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||
if (!connectionId) {
|
||||
return err('SshConsoleEnsure: connection_id is required.');
|
||||
}
|
||||
const localTaskId = ctx.taskId ?? '';
|
||||
if (!localTaskId) {
|
||||
return err('SshConsoleEnsure: this tool requires a local task context (ctx.taskId).');
|
||||
}
|
||||
|
||||
// If a session already exists for this task, branch on whether it's the
|
||||
// same connection. Same → reuse. Different → reject by default (so a
|
||||
// single LLM connection_id slip can't kill the user's live shell), opt
|
||||
// into the swap with force_replace=true.
|
||||
const existing = sub.sessionRegistry.get(localTaskId);
|
||||
if (existing) {
|
||||
if (existing.connectionId === connectionId) {
|
||||
return { opened: false, session: existing };
|
||||
}
|
||||
const forceReplace = input.force_replace === true;
|
||||
if (!forceReplace) {
|
||||
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
|
||||
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
|
||||
return err(
|
||||
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
|
||||
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
|
||||
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
|
||||
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
|
||||
);
|
||||
}
|
||||
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
|
||||
}
|
||||
|
||||
// Full 12-step preflight (same path as SshExec).
|
||||
const pre = preflight({
|
||||
toolName: 'SshExec',
|
||||
connectionId,
|
||||
ctx,
|
||||
sub,
|
||||
auditAction: 'ssh.console.open',
|
||||
});
|
||||
if (!pre.ok) return pre.error;
|
||||
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: ctx.jobId ?? undefined,
|
||||
detail: { reason: 'host_key_not_verified' },
|
||||
},
|
||||
'denied',
|
||||
);
|
||||
return err(
|
||||
`SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.floor(input.cols) : sub.config.console.defaultCols;
|
||||
const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.floor(input.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);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||
} catch (e) {
|
||||
if (pemBuf) clearBuffer(pemBuf);
|
||||
if (passBuf) clearBuffer(passBuf);
|
||||
sub.auditRepo.beginAndComplete(
|
||||
{
|
||||
action: 'ssh.console.open',
|
||||
connectionId,
|
||||
ownerId: connection.ownerId,
|
||||
actingUserId,
|
||||
pieceName,
|
||||
jobId: ctx.jobId ?? undefined,
|
||||
detail: { reason: 'decrypt_failed', msg: (e as Error).message },
|
||||
},
|
||||
'failed',
|
||||
);
|
||||
return err('SshConsoleEnsure: failed to decrypt stored key material.');
|
||||
}
|
||||
|
||||
// Open the channel. On failure clear the PEM and bail.
|
||||
let channel: import('ssh2').ClientChannel;
|
||||
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;
|
||||
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: ctx.jobId ?? undefined,
|
||||
detail: { reason: 'open_shell_failed', msg: (e as Error).message },
|
||||
},
|
||||
'failed',
|
||||
);
|
||||
return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// Build the session and register it. From here on the channel + PEM
|
||||
// belong to the session; we don't clear them on the happy path.
|
||||
const session = new ConsoleSession({
|
||||
localTaskId,
|
||||
connectionId,
|
||||
ownerId: connection.ownerId,
|
||||
startedByUserId: actingUserId,
|
||||
cols,
|
||||
rows,
|
||||
scrollbackCap: sub.config.console.scrollbackBytes,
|
||||
channel,
|
||||
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: ctx.jobId ?? undefined,
|
||||
detail: { cols, rows, host_fingerprint: hostFingerprint },
|
||||
},
|
||||
'success',
|
||||
);
|
||||
|
||||
// Enforce the per-connection session cap (evict oldest).
|
||||
const evict = sub.sessionRegistry.enforceCap(connectionId);
|
||||
for (const e of evict) {
|
||||
sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
|
||||
logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return { opened: true, 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 connection_id: explicit arg or active session's id.
|
||||
// - omitted + active session → use active session's connection_id (recommended)
|
||||
// - omitted + no active session → instruct the agent to discover via SshListConnections
|
||||
// - explicit + matches active → fine
|
||||
// - explicit + mismatches active → reject with surface of the actual id (don't auto-swap)
|
||||
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||
const existingSession = sub.sessionRegistry.get(localTaskId);
|
||||
if (!connectionId) {
|
||||
if (!existingSession) {
|
||||
return err(
|
||||
'SshConsoleSend: 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.',
|
||||
);
|
||||
}
|
||||
connectionId = existingSession.connectionId;
|
||||
} else if (existingSession && existingSession.connectionId !== connectionId) {
|
||||
return err(
|
||||
`SshConsoleSend: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
|
||||
`Either omit connection_id (uses the active one), pass connection_id="${existingSession.connectionId}", ` +
|
||||
`or call SshConsoleEnsure({connection_id: "${connectionId}", force_replace: true}) to swap intentionally.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Find-or-open the session. We already resolved/validated connectionId
|
||||
// above (either matches the existing session or there is none), so
|
||||
// ensureSessionInternal will always reuse the live session or open a
|
||||
// brand-new one without ever hitting its mismatch-reject path.
|
||||
const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
|
||||
if ('isError' in ensured) return ensured;
|
||||
const session = ensured.session;
|
||||
|
||||
// Re-run preflight to get the canonical connection for deny-list eval +
|
||||
// audit logging. (ensureSessionInternal already did the same call but
|
||||
// discarded the result; we want the latest snapshot in case admin
|
||||
// policy changed between Ensure and Send.)
|
||||
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;
|
||||
|
||||
// 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)
|
||||
|
||||
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,
|
||||
...(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.
|
||||
const session = sub.sessionRegistry.get(localTaskId);
|
||||
if (!session) {
|
||||
return err(
|
||||
'SshConsoleSnapshot: no live session for this task. Call SshConsoleEnsure({connection_id}) first.',
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve connection_id: explicit arg or the active session's id.
|
||||
// Mismatch (explicit id ≠ active session id) → reject with surface of
|
||||
// the actual id so the LLM can self-correct.
|
||||
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||
if (!connectionId) {
|
||||
connectionId = session.connectionId;
|
||||
} else if (session.connectionId !== connectionId) {
|
||||
return err(
|
||||
`SshConsoleSnapshot: this task has an active session on connection ${session.connectionId}, not ${connectionId}. ` +
|
||||
`Either omit connection_id (uses the active one) or pass connection_id="${session.connectionId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 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);
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user