sync: update from private repo (6c4d482)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 01:01:47 +00:00
parent caa0d03900
commit 03be80f036
21 changed files with 1140 additions and 15 deletions
+2 -1
View File
@@ -193,7 +193,8 @@ function appendConsoleScreenIfAny(
if (!_activeSessionLookup || taskId === undefined || taskId === null) return prompt;
const allowsConsole =
movement.allowedTools.includes('SshConsoleSend') ||
movement.allowedTools.includes('SshConsoleSnapshot');
movement.allowedTools.includes('SshConsoleSnapshot') ||
movement.allowedTools.includes('SshConsoleRun');
if (!allowsConsole) return prompt;
const session = _activeSessionLookup(String(taskId));
if (!session) return prompt;
+1 -1
View File
@@ -149,7 +149,7 @@ export function validatePieceDef(piece: PieceDef): void {
*/
const SSH_TOOL_NAMES: ReadonlySet<string> = new Set([
'SshExec', 'SshUpload', 'SshDownload', 'SshListConnections',
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot',
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun',
]);
/**
+43
View File
@@ -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
});
});
+45
View File
@@ -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
}
+1
View File
@@ -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',
+292 -1
View File
@@ -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);
});
});
+255 -1
View File
@@ -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;
}
+1 -1
View File
@@ -83,7 +83,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// ssh.ts
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
// ssh-console.ts
'SshConsoleEnsure', 'SshConsoleSendKeys', 'SshConsoleSnapshot',
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun',
// notes.ts
'ReadNote', 'SearchNotes', 'WriteNote',
// dashboard.ts
+16
View File
@@ -164,6 +164,22 @@ describe('ConsoleSession', () => {
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
});
it('tracks last output / ai-input / human-input timestamps separately', () => {
const ch = new StubChannel();
const { session } = mkSession(ch);
const t0 = session.lastOutputAt;
// simulate output
(session as any).handleOutput(Buffer.from('hi'));
expect(session.lastOutputAt).toBeGreaterThanOrEqual(t0);
session.write(Buffer.from('a'), 'ai');
expect(session.lastAiInputAt).toBeGreaterThan(0);
const aiAt = session.lastAiInputAt;
session.write(Buffer.from('b'), 'human');
expect(session.lastHumanInputAt).toBeGreaterThan(0);
expect(session.lastAiInputAt).toBe(aiAt); // human write must not move ai timestamp
expect(typeof session.idleMs()).toBe('number');
});
it('client "close" tears the session down once', async () => {
const ch = new StubChannel();
const { session, audit, client } = mkSession(ch);
+19
View File
@@ -121,6 +121,9 @@ export class ConsoleSession {
private readonly auditRepo: SshAuditRepo;
private _lastActivityAt: number;
private _lastOutputAt: number;
private _lastAiInputAt = 0;
private _lastHumanInputAt = 0;
private _totalInputBytes = 0;
private _totalOutputBytes = 0;
@@ -136,6 +139,7 @@ export class ConsoleSession {
this.startedByUserId = args.startedByUserId;
this.startedAt = Date.now();
this._lastActivityAt = this.startedAt;
this._lastOutputAt = this.startedAt;
this.cols = args.cols;
this.rows = args.rows;
this.channel = args.channel;
@@ -181,6 +185,18 @@ export class ConsoleSession {
get lastActivityAt(): number {
return this._lastActivityAt;
}
get lastOutputAt(): number {
return this._lastOutputAt;
}
get lastAiInputAt(): number {
return this._lastAiInputAt;
}
get lastHumanInputAt(): number {
return this._lastHumanInputAt;
}
idleMs(): number {
return Date.now() - this._lastOutputAt;
}
get totalInputBytes(): number {
return this._totalInputBytes;
}
@@ -239,6 +255,8 @@ export class ConsoleSession {
// Partial input (no newline) is forwarded so the shell can echo each
// character back, matching the live terminal experience the user
// expects in either role.
if (source === 'ai') this._lastAiInputAt = Date.now();
else this._lastHumanInputAt = Date.now();
const out = source === 'ai' ? normalizeLfToCr(buf) : buf;
this._totalInputBytes += out.length;
const ok = this.channel.write(out);
@@ -338,6 +356,7 @@ export class ConsoleSession {
private handleOutput(data: Buffer): void {
this._totalOutputBytes += data.length;
this._lastActivityAt = Date.now();
this._lastOutputAt = Date.now();
this.scrollback.append(data);
this.writeToHeadlessSync(data);
for (const l of this.outputListeners) {