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
+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;
}