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

This commit is contained in:
oss-sync
2026-06-08 03:58:10 +00:00
parent 0f75bdfbab
commit 38bd874366
14 changed files with 1500 additions and 303 deletions
+334 -176
View File
@@ -32,6 +32,319 @@ 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;
/** Piece allowed_ssh_connections (['*'] for user-initiated). */
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;
}
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'
| 'connection_change_requires_force'
| '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;
}
/**
* 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).
*
* 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 { sub } = deps;
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).',
};
}
// If a session already exists for this task, branch on whether it's the
// same connection. Same → reuse. Different → reject by default (so a
// single LLM connection_id slip can't kill the user's live shell), opt
// into the swap with force_replace=true.
const existing = sub.sessionRegistry.get(localTaskId);
if (existing) {
if (existing.connectionId === connectionId) {
return {
ok: true,
alreadyActive: true,
connectionId: existing.connectionId,
cols: existing.cols,
rows: existing.rows,
session: existing,
};
}
const forceReplace = params.forceReplace === true;
if (!forceReplace) {
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
return {
ok: false,
error: 'connection_change_requires_force',
connectionId: existing.connectionId,
message:
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
};
}
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
}
// 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,
} 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);
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
} 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).
const evict = sub.sessionRegistry.enforceCap(connectionId);
for (const e of evict) {
sub.sessionRegistry.closeForTask(e.localTaskId, '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 };
}
// ──────────────────────────────────────────────────────────────────────
// Tool definitions
// ──────────────────────────────────────────────────────────────────────
@@ -167,6 +480,11 @@ interface EnsureResult {
* 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>,
@@ -174,190 +492,30 @@ async function ensureSessionInternal(
sub: SshSubsystem,
): Promise<EnsureResult | ToolResult> {
const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
if (!connectionId) {
return err('SshConsoleEnsure: connection_id is required.');
}
const localTaskId = ctx.taskId ?? '';
if (!localTaskId) {
return err('SshConsoleEnsure: this tool requires a local task context (ctx.taskId).');
}
const cols = typeof input.cols === 'number' ? input.cols : undefined;
const rows = typeof input.rows === 'number' ? input.rows : undefined;
// If a session already exists for this task, branch on whether it's the
// same connection. Same → reuse. Different → reject by default (so a
// single LLM connection_id slip can't kill the user's live shell), opt
// into the swap with force_replace=true.
const existing = sub.sessionRegistry.get(localTaskId);
if (existing) {
if (existing.connectionId === connectionId) {
return { opened: false, session: existing };
}
const forceReplace = input.force_replace === true;
if (!forceReplace) {
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
return err(
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
);
}
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
}
// Full 12-step preflight (same path as SshExec).
const pre = preflight({
toolName: 'SshExec',
connectionId,
ctx,
sub,
auditAction: 'ssh.console.open',
});
if (!pre.ok) return pre.error;
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: ctx.jobId ?? undefined,
detail: { reason: 'host_key_not_verified' },
},
'denied',
);
return err(
`SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
);
}
const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.floor(input.cols) : sub.config.console.defaultCols;
const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.floor(input.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);
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
} catch (e) {
if (pemBuf) clearBuffer(pemBuf);
if (passBuf) clearBuffer(passBuf);
sub.auditRepo.beginAndComplete(
{
action: 'ssh.console.open',
connectionId,
ownerId: connection.ownerId,
actingUserId,
pieceName,
jobId: ctx.jobId ?? undefined,
detail: { reason: 'decrypt_failed', msg: (e as Error).message },
},
'failed',
);
return err('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,
},
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,
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: ctx.jobId ?? undefined,
detail: { reason: 'open_shell_failed', msg: (e as Error).message },
},
'failed',
);
return err(`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,
forceReplace: input.force_replace === true,
initiator: 'agent',
jobId: ctx.jobId ?? undefined,
detail: { cols, rows, host_fingerprint: hostFingerprint },
},
'success',
);
// Enforce the per-connection session cap (evict oldest).
const evict = sub.sessionRegistry.enforceCap(connectionId);
for (const e of evict) {
sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
);
if (!result.ok || !result.session) {
return err(result.message ?? 'SshConsoleEnsure: failed to open session.');
}
return { opened: true, session };
return { opened: !result.alreadyActive, session: result.session };
}
async function ensureTool(