This commit is contained in:
@@ -30,6 +30,7 @@ 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';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Tool definitions
|
||||
@@ -62,13 +63,17 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
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" })。',
|
||||
'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 はタスクに 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' },
|
||||
confirm_interrupt: {
|
||||
type: 'boolean',
|
||||
description: 'Ctrl-C (\\x03) を含む入力が interrupt_blocked=true で返った場合、true を付けて再送すると強制送信する。',
|
||||
},
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
@@ -91,12 +96,31 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
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: '省略時はこのタスクの active session を自動採用。' },
|
||||
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',
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -516,6 +540,29 @@ async function sendInput(
|
||||
// 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
|
||||
@@ -565,6 +612,8 @@ async function sendInput(
|
||||
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,
|
||||
@@ -693,6 +742,210 @@ async function snapshot(
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 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 connection_id: explicit arg or active session's id.
|
||||
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||
const existingSession = sub.sessionRegistry.get(localTaskId);
|
||||
if (!connectionId) {
|
||||
if (!existingSession) {
|
||||
return err(
|
||||
'SshConsoleRun: 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(
|
||||
`SshConsoleRun: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
|
||||
`Either omit connection_id (uses the active one) or pass connection_id="${existingSession.connectionId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Find-or-open session.
|
||||
const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
|
||||
if ('isError' in ensured) return ensured;
|
||||
const session = ensured.session;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -713,5 +966,6 @@ export async function executeTool(
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user