1390 lines
56 KiB
TypeScript
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;
|
|
}
|