This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';
|
||||
|
||||
describe('console-run-lib', () => {
|
||||
it('makeNonce is long, hex, and unique', () => {
|
||||
const a = makeNonce(), b = makeNonce();
|
||||
expect(a).toMatch(/^[0-9a-f]{24,}$/);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
it('makeMarkerCommand wraps the command with a nonce echo of $?', () => {
|
||||
const { text, marker } = makeMarkerCommand('ls -la', 'deadbeef');
|
||||
expect(text).toBe('ls -la; echo "__MAESTRO_DONE_deadbeef:$?__"\n');
|
||||
expect(marker).toBe('__MAESTRO_DONE_deadbeef:');
|
||||
});
|
||||
it('parseMarker finds the exit code from output text', () => {
|
||||
expect(parseMarker('foo\n__MAESTRO_DONE_deadbeef:0__\n', 'deadbeef')).toEqual({ found: true, exitCode: 0 });
|
||||
expect(parseMarker('foo\n__MAESTRO_DONE_deadbeef:130__\n', 'deadbeef')).toEqual({ found: true, exitCode: 130 });
|
||||
expect(parseMarker('still running...', 'deadbeef')).toEqual({ found: false, exitCode: null });
|
||||
// a different nonce in the stream must NOT match (anti-spoof)
|
||||
expect(parseMarker('__MAESTRO_DONE_otheronce:0__', 'deadbeef')).toEqual({ found: false, exitCode: null });
|
||||
});
|
||||
it('extractOutput strips the echoed command line and the marker line', () => {
|
||||
const raw = 'ls -la; echo "__MAESTRO_DONE_dead:$?__"\nfile1\nfile2\n__MAESTRO_DONE_dead:0__\n';
|
||||
const out = extractOutput(raw, 'ls -la', 'dead');
|
||||
expect(out).toContain('file1');
|
||||
expect(out).toContain('file2');
|
||||
expect(out).not.toContain('__MAESTRO_DONE_');
|
||||
});
|
||||
it('detectWaitingForInput spots common prompts in the screen tail', () => {
|
||||
expect(detectWaitingForInput('Password: ')).toBe(true);
|
||||
expect(detectWaitingForInput('Continue? [y/N] ')).toBe(true);
|
||||
expect(detectWaitingForInput('Are you sure (yes/no)? ')).toBe(true);
|
||||
expect(detectWaitingForInput('just some output')).toBe(false);
|
||||
});
|
||||
it('shouldGuardInterrupt: guards when alive, allows when idle or confirmed', () => {
|
||||
const now = 1_000_000;
|
||||
const alive = { idleMs: 200, msSinceAiInput: 300, sessionAgeMs: 1000 };
|
||||
const idle = { idleMs: 60_000, msSinceAiInput: 60_000, sessionAgeMs: 60_000 };
|
||||
expect(shouldGuardInterrupt(alive, false)).toBe(true); // block
|
||||
expect(shouldGuardInterrupt(alive, true)).toBe(false); // confirmed -> allow
|
||||
expect(shouldGuardInterrupt(idle, false)).toBe(false); // idle -> allow
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const MARKER_PREFIX = '__MAESTRO_DONE_';
|
||||
|
||||
export function makeNonce(): string {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
export function makeMarkerCommand(command: string, nonce: string): { text: string; marker: string } {
|
||||
const marker = `${MARKER_PREFIX}${nonce}:`;
|
||||
// `$?` after `command;` reflects the command's exit code.
|
||||
return { text: `${command}; echo "${marker}$?__"\n`, marker };
|
||||
}
|
||||
|
||||
export function parseMarker(text: string, nonce: string): { found: boolean; exitCode: number | null } {
|
||||
const re = new RegExp(`${MARKER_PREFIX}${nonce}:(\\d+)__`);
|
||||
const m = re.exec(text);
|
||||
if (!m) return { found: false, exitCode: null };
|
||||
return { found: true, exitCode: Number(m[1]) };
|
||||
}
|
||||
|
||||
// Best-effort: drop the echoed command line and the marker line(s). PTY echo /
|
||||
// prompt noise may remain — perfect formatting is a non-goal.
|
||||
export function extractOutput(raw: string, command: string, nonce: string): string {
|
||||
const markerRe = new RegExp(`${MARKER_PREFIX}${nonce}:.*?__`);
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter((line) => !markerRe.test(line))
|
||||
.filter((line) => line.trim() !== `${command}; echo "${MARKER_PREFIX}${nonce}:$?__"`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const WAITING_RE = /(password:|\[y\/n\]|\(yes\/no\)\??|continue\?|press .*key|passphrase:)\s*$/i;
|
||||
export function detectWaitingForInput(screenTail: string): boolean {
|
||||
const lastLines = screenTail.split('\n').slice(-3).join('\n').trim();
|
||||
return WAITING_RE.test(lastLines);
|
||||
}
|
||||
|
||||
export interface InterruptLiveness { idleMs: number; msSinceAiInput: number; sessionAgeMs: number; }
|
||||
const GUARD_IDLE_MS = 3000, GUARD_RECENT_INPUT_MS = 5000, GUARD_SESSION_AGE_MS = 5000;
|
||||
export function shouldGuardInterrupt(l: InterruptLiveness, confirmInterrupt: boolean): boolean {
|
||||
if (confirmInterrupt) return false;
|
||||
const alive = l.idleMs < GUARD_IDLE_MS || l.msSinceAiInput < GUARD_RECENT_INPUT_MS || l.sessionAgeMs < GUARD_SESSION_AGE_MS;
|
||||
return alive; // true => block the interrupt
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
sshconsoleensure: 'ssh-console-tools',
|
||||
sshconsolesend: 'ssh-console-tools',
|
||||
sshconsolesnapshot: 'ssh-console-tools',
|
||||
sshconsolerun: 'ssh-console-tools',
|
||||
// slide.ts をまとめる
|
||||
settheme: 'slide',
|
||||
addslide: 'slide',
|
||||
|
||||
@@ -273,12 +273,16 @@ describe('SshConsoleSend', () => {
|
||||
it('writes input to session and returns screen snapshot', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const writes: Buffer[] = [];
|
||||
const now = Date.now();
|
||||
const fakeSession = {
|
||||
localTaskId: 'task-1',
|
||||
connectionId: 'conn-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
isClosed: false,
|
||||
idleMs: () => 10_000,
|
||||
lastAiInputAt: now - 10_000,
|
||||
startedAt: now - 20_000,
|
||||
write: (b: Buffer) => writes.push(b),
|
||||
snapshotScreen: () => ({
|
||||
cols: 80,
|
||||
@@ -306,9 +310,13 @@ describe('SshConsoleSend', () => {
|
||||
it('auto-appends \\n when input is printable without line terminator', async () => {
|
||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||
const now = Date.now();
|
||||
const fakeSession = {
|
||||
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
||||
isClosed: false,
|
||||
idleMs: () => 10_000,
|
||||
lastAiInputAt: now - 10_000,
|
||||
startedAt: now - 20_000,
|
||||
write: vi.fn(),
|
||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
|
||||
totalOutputBytes: 0,
|
||||
@@ -334,9 +342,15 @@ describe('SshConsoleSend', () => {
|
||||
it('does NOT auto-append newline for control bytes (Ctrl-C / Ctrl-D / Esc / Tab)', async () => {
|
||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||
const now = Date.now();
|
||||
const fakeSession = {
|
||||
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
||||
isClosed: false, write: vi.fn(),
|
||||
isClosed: false,
|
||||
// Large idleMs so \x03 passes the guard (session appears idle, no block)
|
||||
idleMs: () => 10_000,
|
||||
lastAiInputAt: now - 10_000,
|
||||
startedAt: now - 20_000,
|
||||
write: vi.fn(),
|
||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
|
||||
totalOutputBytes: 0,
|
||||
};
|
||||
@@ -411,11 +425,15 @@ describe('SshConsoleSend', () => {
|
||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||
const writes: Buffer[] = [];
|
||||
const now = Date.now();
|
||||
registry.get.mockReturnValue({
|
||||
localTaskId: 'task-1',
|
||||
connectionId: 'conn-1',
|
||||
cols: 80, rows: 24,
|
||||
isClosed: false,
|
||||
idleMs: () => 10_000,
|
||||
lastAiInputAt: now - 10_000,
|
||||
startedAt: now - 20_000,
|
||||
write: (b: Buffer) => writes.push(b),
|
||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
|
||||
totalOutputBytes: 0,
|
||||
@@ -456,6 +474,136 @@ describe('SshConsoleSend', () => {
|
||||
expect(res?.isError).toBe(true);
|
||||
expect(res?.output).toContain('SshListConnections');
|
||||
});
|
||||
|
||||
// ── Task 4: interrupt guard tests ──────────────────────────────────
|
||||
|
||||
/** Build a fake session for Send that includes liveness getters needed by Task 4. */
|
||||
function mkSendSession(overrides: {
|
||||
idleMs?: number;
|
||||
lastAiInputAt?: number;
|
||||
startedAt?: number;
|
||||
screenText?: string;
|
||||
} = {}) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
localTaskId: 'task-1',
|
||||
connectionId: 'conn-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
isClosed: false,
|
||||
totalOutputBytes: 0,
|
||||
idleMs: vi.fn().mockReturnValue(overrides.idleMs ?? 500),
|
||||
lastAiInputAt: overrides.lastAiInputAt ?? now - 1000,
|
||||
startedAt: overrides.startedAt ?? now - 2000,
|
||||
write: vi.fn(),
|
||||
snapshotScreen: () => ({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
text: overrides.screenText ?? 'prompt$ ',
|
||||
cursor: { x: 0, y: 0 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('blocks \\x03 when session is recently active and confirm_interrupt is absent', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const now = Date.now();
|
||||
// Small idleMs (recent output) → guard fires
|
||||
const fakeSession = mkSendSession({
|
||||
idleMs: 500, // < GUARD_IDLE_MS (3000) → session alive
|
||||
lastAiInputAt: now - 1000, // < GUARD_RECENT_INPUT_MS (5000)
|
||||
startedAt: now - 2000, // < GUARD_SESSION_AGE_MS (5000)
|
||||
});
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const res = await executeTool(
|
||||
'SshConsoleSend',
|
||||
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.ok).toBe(false);
|
||||
expect(data.interrupt_blocked).toBe(true);
|
||||
expect(data.require_confirm).toBe(true);
|
||||
expect(data.reason).toMatch(/running/i);
|
||||
expect(data.idle_ms).toBeDefined();
|
||||
expect(data.hint).toContain('confirm_interrupt');
|
||||
// session.write must NOT have been called
|
||||
expect(fakeSession.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows \\x03 when confirm_interrupt=true even if session is recently active', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const now = Date.now();
|
||||
const fakeSession = mkSendSession({
|
||||
idleMs: 500,
|
||||
lastAiInputAt: now - 1000,
|
||||
startedAt: now - 2000,
|
||||
});
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const res = await executeTool(
|
||||
'SshConsoleSend',
|
||||
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0, confirm_interrupt: true },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.interrupt_blocked).toBeUndefined();
|
||||
// session.write MUST have been called with the \x03 byte
|
||||
expect(fakeSession.write).toHaveBeenCalledTimes(1);
|
||||
const writtenBuf = fakeSession.write.mock.calls[0][0] as Buffer;
|
||||
expect(writtenBuf.includes(0x03)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows \\x03 without confirm when session is clearly idle (large idleMs + old timestamps)', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const now = Date.now();
|
||||
const fakeSession = mkSendSession({
|
||||
idleMs: 10_000, // > GUARD_IDLE_MS (3000)
|
||||
lastAiInputAt: now - 9_000, // > GUARD_RECENT_INPUT_MS (5000)
|
||||
startedAt: now - 20_000, // > GUARD_SESSION_AGE_MS (5000)
|
||||
});
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const res = await executeTool(
|
||||
'SshConsoleSend',
|
||||
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.interrupt_blocked).toBeUndefined();
|
||||
expect(fakeSession.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normal printable Send result includes idle_ms field', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const fakeSession = mkSendSession({ idleMs: 42 });
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const res = await executeTool(
|
||||
'SshConsoleSend',
|
||||
{ connection_id: 'conn-1', input: 'ls\n', wait_ms: 0 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(typeof data.idle_ms).toBe('number');
|
||||
expect(data.maybe_waiting_for_input).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SshConsoleSnapshot', () => {
|
||||
@@ -554,3 +702,146 @@ describe('SshConsoleSnapshot', () => {
|
||||
expect(res?.output).toContain('conn-ACTIVE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SshConsoleRun', () => {
|
||||
beforeEach(() => setSshSubsystem(null));
|
||||
|
||||
/** Build a fake session that supports onOutput, write (spy), scrollbackBytes, snapshotScreen,
|
||||
* lastHumanInputAt, lastAiInputAt, idleMs, startedAt — matching the ConsoleSession public surface. */
|
||||
function mkFakeSession() {
|
||||
const outputListeners: Set<(chunk: Buffer) => void> = new Set();
|
||||
let _scrollback = Buffer.alloc(0);
|
||||
let _lastHumanInputAt = 0;
|
||||
let _lastAiInputAt = 0;
|
||||
let _startedAt = Date.now();
|
||||
|
||||
const session = {
|
||||
localTaskId: 'task-1',
|
||||
connectionId: 'conn-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
isClosed: false,
|
||||
startedAt: _startedAt,
|
||||
get lastHumanInputAt() { return _lastHumanInputAt; },
|
||||
get lastAiInputAt() { return _lastAiInputAt; },
|
||||
idleMs: vi.fn().mockReturnValue(50),
|
||||
totalOutputBytes: 0,
|
||||
/** Spy-able write: when source='human', bump humanInputAt so the run loop detects it. */
|
||||
write: vi.fn((buf: Buffer, source: 'ai' | 'human') => {
|
||||
if (source === 'human') _lastHumanInputAt = Date.now();
|
||||
else _lastAiInputAt = Date.now();
|
||||
_scrollback = Buffer.concat([_scrollback, buf]);
|
||||
}),
|
||||
onOutput: (listener: (chunk: Buffer) => void) => {
|
||||
outputListeners.add(listener);
|
||||
return () => { outputListeners.delete(listener); };
|
||||
},
|
||||
scrollbackBytes: () => _scrollback,
|
||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
|
||||
/** Test helper: push a chunk to all output listeners + append to scrollback. */
|
||||
_pushOutput(chunk: Buffer) {
|
||||
_scrollback = Buffer.concat([_scrollback, chunk]);
|
||||
for (const l of outputListeners) l(chunk);
|
||||
},
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
it('SshConsoleRun is registered in TOOL_DEFS', () => {
|
||||
expect(TOOL_DEFS.SshConsoleRun).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolves done+exit_code=0 when marker is pushed to session output', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const fakeSession = mkFakeSession();
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
// Start the run — it will block waiting for output.
|
||||
const runPromise = executeTool(
|
||||
'SshConsoleRun',
|
||||
{ connection_id: 'conn-1', command: 'echo hello', timeout_ms: 5000 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
// Let the run() function start and subscribe to onOutput.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// Extract the nonce from the command text written to session.write (the markerCommand).
|
||||
// write spy: first call is the markerCommand text (after deny-check etc.)
|
||||
// Find the write call for source='ai' that contains the marker.
|
||||
let nonce: string | undefined;
|
||||
for (const call of fakeSession.write.mock.calls) {
|
||||
const text = (call[0] as Buffer).toString('utf8');
|
||||
const m = /__MAESTRO_DONE_([0-9a-f]+):/.exec(text);
|
||||
if (m) { nonce = m[1]; break; }
|
||||
}
|
||||
expect(nonce).toBeDefined();
|
||||
|
||||
// Push some output then the marker line.
|
||||
fakeSession._pushOutput(Buffer.from('hello\n'));
|
||||
fakeSession._pushOutput(Buffer.from(`__MAESTRO_DONE_${nonce!}:0__\n`));
|
||||
|
||||
const res = await runPromise;
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.done).toBe(true);
|
||||
expect(data.exit_code).toBe(0);
|
||||
expect(data.marker_found).toBe(true);
|
||||
expect(data.output).toContain('hello');
|
||||
});
|
||||
|
||||
it('returns timed_out=true on timeout and does NOT write Ctrl-C (\\x03)', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const fakeSession = mkFakeSession();
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const res = await executeTool(
|
||||
'SshConsoleRun',
|
||||
{ connection_id: 'conn-1', command: 'sleep 99', timeout_ms: 50 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.done).toBe(false);
|
||||
expect(data.timed_out).toBe(true);
|
||||
|
||||
// Ctrl-C must NEVER be sent on timeout.
|
||||
const allWritten = fakeSession.write.mock.calls
|
||||
.map((c) => (c[0] as Buffer).toString('binary'))
|
||||
.join('');
|
||||
expect(allWritten).not.toContain('\x03');
|
||||
});
|
||||
|
||||
it('returns interrupted_by=human when human writes during the wait', async () => {
|
||||
const { sub, registry } = mkStubSubsystem();
|
||||
const fakeSession = mkFakeSession();
|
||||
registry.get.mockReturnValue(fakeSession);
|
||||
setSshSubsystem(sub);
|
||||
|
||||
const runPromise = executeTool(
|
||||
'SshConsoleRun',
|
||||
{ connection_id: 'conn-1', command: 'sleep 10', timeout_ms: 5000 },
|
||||
mkCtx(),
|
||||
);
|
||||
|
||||
// Let the run loop subscribe and start the interval.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// Simulate a human keystroke — this updates lastHumanInputAt on the fake session.
|
||||
fakeSession.write(Buffer.from('q'), 'human');
|
||||
|
||||
// Wait for the interval (100ms) to fire and detect the human input.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const res = await runPromise;
|
||||
expect(res?.isError).toBe(false);
|
||||
const data = JSON.parse(res!.output);
|
||||
expect(data.done).toBe(false);
|
||||
expect(data.interrupted_by).toBe('human');
|
||||
expect(data.timed_out).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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