feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+712
View File
@@ -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;
}