diff --git a/kun/src/adapters/tool/builtin-bash-tool.ts b/kun/src/adapters/tool/builtin-bash-tool.ts index 3a040d6ed..44654bb9f 100644 --- a/kun/src/adapters/tool/builtin-bash-tool.ts +++ b/kun/src/adapters/tool/builtin-bash-tool.ts @@ -20,6 +20,7 @@ import { withToolBoundary, workspaceRoot } from './builtin-tool-utils.js' +import { executeRemoteCommand } from './remote-command-tool.js' const DEFAULT_BASH_YIELD_SECONDS = 10 const MAX_BASH_YIELD_SECONDS = 60 @@ -699,6 +700,17 @@ export function createBashLocalTool(options: BashLocalToolOptions = {}): LocalTo const background = args.background === true const cwd = workspaceRoot(context.workspace) try { + if (context.executionTarget) { + if (background) { + return { output: { error: 'background sessions are not supported for remote targets' }, isError: true } + } + return executeRemoteCommand({ + handle: context.executionTarget, + command, + timeoutSeconds: timeout, + context + }) + } if (background) { if (bashOps?.exec) { return { diff --git a/kun/src/adapters/tool/builtin-file-tools.ts b/kun/src/adapters/tool/builtin-file-tools.ts index 2b9a89dcb..98c8b36de 100644 --- a/kun/src/adapters/tool/builtin-file-tools.ts +++ b/kun/src/adapters/tool/builtin-file-tools.ts @@ -15,6 +15,7 @@ import type { EditLocalToolOptions, WriteLocalToolOptions } from './builtin-tool import { defaultEditLocalToolOperations, defaultWriteLocalToolOperations } from './builtin-tool-operations.js' import { parseEditInstructions, resolveWorkspacePath, withToolBoundary } from './builtin-tool-utils.js' import { assertCanWritePath } from './sandbox-policy.js' +import { remoteEdit, remoteWrite } from './remote-file-tools.js' /** * Arguments that failed JSON parsing arrive as `{ __raw: "" }` @@ -53,6 +54,7 @@ export function createWriteLocalTool(_options: WriteLocalToolOptions = {}): Loca policy: 'on-request', toolKind: 'file_change', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteWrite(context.executionTarget, args, context) const truncated = truncatedArgumentsError(args.__raw) if (truncated) return truncated const rawPath = typeof args.path === 'string' ? args.path : '' @@ -111,6 +113,7 @@ export function createEditLocalTool(_options: EditLocalToolOptions = {}): LocalT policy: 'on-request', toolKind: 'file_change', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteEdit(context.executionTarget, args, context) const truncated = truncatedArgumentsError(args.__raw) if (truncated) return truncated const rawPath = typeof args.path === 'string' ? args.path : '' diff --git a/kun/src/adapters/tool/builtin-read-tool.ts b/kun/src/adapters/tool/builtin-read-tool.ts index d3c5c8817..71cb860e5 100644 --- a/kun/src/adapters/tool/builtin-read-tool.ts +++ b/kun/src/adapters/tool/builtin-read-tool.ts @@ -10,6 +10,7 @@ import { resolveWorkspacePath, withToolBoundary } from './builtin-tool-utils.js' +import { remoteRead } from './remote-file-tools.js' export function createReadLocalTool(options: ReadLocalToolOptions = {}): LocalTool { const statOp = options.operations?.stat ?? defaultReadLocalToolOperations.stat! @@ -33,6 +34,7 @@ export function createReadLocalTool(options: ReadLocalToolOptions = {}): LocalTo }, policy: 'auto', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteRead(context.executionTarget, args, context) const rawPath = typeof args.path === 'string' ? args.path : '' if (!rawPath.trim()) { return { diff --git a/kun/src/adapters/tool/builtin-search-tools.ts b/kun/src/adapters/tool/builtin-search-tools.ts index a07f36bab..423188334 100644 --- a/kun/src/adapters/tool/builtin-search-tools.ts +++ b/kun/src/adapters/tool/builtin-search-tools.ts @@ -23,6 +23,7 @@ import { spawnCapture, withToolBoundary } from './builtin-tool-utils.js' +import { remoteFind, remoteGrep, remoteLs } from './remote-file-tools.js' export function createLsLocalTool(options: LsLocalToolOptions = {}): LocalTool { const statOp = options.operations?.stat ?? defaultLsLocalToolOperations.stat! @@ -41,6 +42,7 @@ export function createLsLocalTool(options: LsLocalToolOptions = {}): LocalTool { }, policy: 'auto', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteLs(context.executionTarget, args, context) const rawPath = typeof args.path === 'string' && args.path.trim() ? args.path : '.' const limit = normalizePositiveInteger(args.limit, options.defaultLimit ?? DEFAULT_LIST_LIMIT) const { workspaceRoot: root, absolutePath, relativePath } = await resolveWorkspacePath(rawPath, context) @@ -91,6 +93,7 @@ export function createFindLocalTool(options: FindLocalToolOptions = {}): LocalTo }, policy: 'auto', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteFind(context.executionTarget, args, context) const pattern = typeof args.pattern === 'string' ? args.pattern.trim() : '' if (!pattern) return { output: { error: 'pattern is required' }, isError: true } const rawPath = typeof args.path === 'string' && args.path.trim() ? args.path : '.' @@ -198,6 +201,7 @@ export function createGrepLocalTool(options: GrepLocalToolOptions = {}): LocalTo }, policy: 'auto', execute: async (args, context) => withToolBoundary(async () => { + if (context.executionTarget) return remoteGrep(context.executionTarget, args, context) const pattern = typeof args.pattern === 'string' ? args.pattern : '' if (!pattern.trim()) return { output: { error: 'pattern is required' }, isError: true } const literal = normalizeBoolean(args.literal) diff --git a/kun/src/adapters/tool/remote-command-tool.test.ts b/kun/src/adapters/tool/remote-command-tool.test.ts new file mode 100644 index 000000000..d6c01bd94 --- /dev/null +++ b/kun/src/adapters/tool/remote-command-tool.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { executeRemoteCommand } from './remote-command-tool.js' +import type { RemoteExecutionHandle } from '../../ports/remote-execution.js' +import type { ToolHostContext } from '../../ports/tool-host.js' + +function handle(overrides: Partial = {}): RemoteExecutionHandle { + return { + target: { kind: 'ssh', alias: 'prod', remoteDir: '/srv/api' }, + runMode: 'develop', + production: false, + status: () => 'connected', + describe: () => ({ target: { kind: 'ssh', alias: 'prod', remoteDir: '/srv/api' }, status: 'connected' }), + guardCommand: () => ({ decision: 'allow', reasons: ['ok'] }), + guardPath: () => ({ decision: 'allow', reasons: ['ok'] }), + guardFile: () => ({ decision: 'allow', reasons: ['ok'] }), + exec: vi.fn(async (command) => ({ + command, stdout: 'remote-out', stderr: '', exitCode: 0, signal: null, durationMs: 7, timedOut: false + })), + ...overrides + } +} + +function context(awaitApproval: ToolHostContext['awaitApproval'] = vi.fn(async () => 'allow' as const)): ToolHostContext { + return { + threadId: 't1', + turnId: 'turn1', + workspace: '/ws', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval + } +} + +describe('executeRemoteCommand', () => { + it('runs on the remote and tags the result with target/host/remoteDir', async () => { + const result = await executeRemoteCommand({ handle: handle(), command: 'ls', timeoutSeconds: 30, context: context() }) + expect(result.isError).toBeFalsy() + expect(result.output).toMatchObject({ target: 'ssh', host: 'prod', remoteDir: '/srv/api', stdout: 'remote-out', exitCode: 0 }) + }) + + it('blocks a denied command without executing it', async () => { + const exec = vi.fn() + const result = await executeRemoteCommand({ + handle: handle({ guardCommand: () => ({ decision: 'deny', reasons: ['not allowed in observe mode'] }), exec }), + command: 'rm -rf /', + timeoutSeconds: 30, + context: context() + }) + expect(exec).not.toHaveBeenCalled() + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ decision: 'deny' }) + }) + + it('requests approval BEFORE executing a confirm-class command', async () => { + const order: string[] = [] + const awaitApproval = vi.fn(async () => { order.push('approval'); return 'allow' as const }) + const exec = vi.fn(async (command: string) => { order.push('exec'); return { command, stdout: 'ok', stderr: '', exitCode: 0, signal: null, durationMs: 1, timedOut: false } }) + const result = await executeRemoteCommand({ + handle: handle({ guardCommand: () => ({ decision: 'confirm', reasons: ['irreversible'] }), exec }), + command: 'kubectl delete pod x', + timeoutSeconds: 30, + context: context(awaitApproval) + }) + expect(order).toEqual(['approval', 'exec']) + expect(result.output).toMatchObject({ riskConfirmed: true }) + }) + + it('does NOT execute a confirm-class command when approval is denied', async () => { + const exec = vi.fn() + const result = await executeRemoteCommand({ + handle: handle({ guardCommand: () => ({ decision: 'confirm', reasons: ['production restart'] }), exec }), + command: 'systemctl restart api', + timeoutSeconds: 30, + context: context(vi.fn(async () => 'deny' as const)) + }) + expect(exec).not.toHaveBeenCalled() + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ approved: false }) + }) + + it('marks a non-zero exit as an error', async () => { + const result = await executeRemoteCommand({ + handle: handle({ exec: vi.fn(async (command) => ({ command, stdout: '', stderr: 'boom', exitCode: 1, signal: null, durationMs: 2, timedOut: false })) }), + command: 'false', + timeoutSeconds: 30, + context: context() + }) + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ exitCode: 1, stderr: 'boom' }) + }) + + it('preserves executor truncation metadata without requiring an artifact store', async () => { + const big = 'x'.repeat(20_000) + const result = await executeRemoteCommand({ + handle: handle({ exec: vi.fn(async (command) => ({ command, stdout: big, stderr: '', exitCode: 0, signal: null, durationMs: 5, timedOut: false, truncated: true })) }), + command: 'cat huge.log', + timeoutSeconds: 30, + context: context() + }) + expect(result.output).toMatchObject({ stdout: big, truncated: true }) + }) +}) diff --git a/kun/src/adapters/tool/remote-command-tool.ts b/kun/src/adapters/tool/remote-command-tool.ts new file mode 100644 index 000000000..d4573cacf --- /dev/null +++ b/kun/src/adapters/tool/remote-command-tool.ts @@ -0,0 +1,100 @@ +/** + * Target-aware command execution (Issue #647). + * + * When a thread is bound to an SSH target, the built-in `bash` tool routes here + * instead of spawning a local shell. The remote run-mode guard can deny the + * command outright; the result ALWAYS carries the target, host, remote dir, and + * exit status so the model can never confuse remote and local execution. A + * mid-flight disconnect on a mutating command comes back as `statusUnknown` + * (never silently retried). + */ + +import type { RemoteExecutionHandle } from '../../ports/remote-execution.js' +import type { ToolHostContext } from '../../ports/tool-host.js' +import { createApprovalRequest } from '../../domain/approval.js' +import { isSshTarget } from '../../remote/remote-target.js' + +export type RemoteCommandToolResult = { + output: Record + isError?: boolean +} + +export async function executeRemoteCommand(input: { + handle: RemoteExecutionHandle + command: string + timeoutSeconds: number + context: ToolHostContext +}): Promise { + const { handle, command, context } = input + const descriptor = handle.describe() + const target = descriptor.target + const host = isSshTarget(target) ? target.alias : 'local' + const remoteDir = isSshTarget(target) ? target.remoteDir : undefined + const base = { + target: 'ssh' as const, + host, + ...(remoteDir ? { remoteDir } : {}), + command + } + + const guard = handle.guardCommand(command) + if (guard.decision === 'deny') { + return { + output: { ...base, decision: 'deny', error: `blocked by remote run mode: ${guard.reasons.join('; ')}` }, + isError: true + } + } + + // A 'confirm' decision (irreversible / high-risk / production write) MUST gate + // on a real human approval BEFORE the command runs — never execute first and + // label it confirmed afterwards. The approval card shows target + command + + // risk reasons so the user knows exactly what runs where. + if (guard.decision === 'confirm') { + const approvalId = `appr_remote_${context.turnId}_${Math.random().toString(36).slice(2, 8)}` + const approval = createApprovalRequest({ + id: approvalId, + threadId: context.threadId, + turnId: context.turnId, + toolName: 'bash', + summary: `Run on remote ${host}${remoteDir ? ` (${remoteDir})` : ''}: ${command}\nRisk: ${guard.reasons.join('; ')}` + }) + const decision = await context.awaitApproval(approval) + if (decision !== 'allow') { + return { + output: { ...base, decision: 'confirm', approved: false, error: 'remote command was not approved', riskReasons: guard.reasons }, + isError: true + } + } + } + + const result = await handle.exec(command, { + timeoutMs: Math.max(1, input.timeoutSeconds) * 1_000, + ...(context.abortSignal ? { signal: context.abortSignal } : {}) + }) + + if (result.statusUnknown) { + return { + output: { + ...base, + statusUnknown: true, + stderr: result.stderr, + note: 'connection dropped before a result was confirmed; the command was NOT auto-replayed. Use the target controls to query status or reconnect.' + }, + isError: true + } + } + + return { + output: { + ...base, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + durationMs: result.durationMs, + timedOut: result.timedOut, + ...(result.truncated ? { truncated: true } : {}), + ...(guard.decision === 'confirm' ? { riskConfirmed: true, riskReasons: guard.reasons } : {}) + }, + isError: result.timedOut || (result.exitCode !== null && result.exitCode !== 0) + } +} diff --git a/kun/src/adapters/tool/remote-file-tools.test.ts b/kun/src/adapters/tool/remote-file-tools.test.ts new file mode 100644 index 000000000..f50c14ff4 --- /dev/null +++ b/kun/src/adapters/tool/remote-file-tools.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import { remoteEdit, remoteFind, remoteGrep, remoteLs, remoteRead, remoteWrite } from './remote-file-tools.js' +import type { RemoteExecutionHandle, RemoteExecResult } from '../../ports/remote-execution.js' +import type { ToolHostContext } from '../../ports/tool-host.js' + +function execResult(overrides: Partial): RemoteExecResult { + return { command: 'c', stdout: '', stderr: '', exitCode: 0, signal: null, durationMs: 1, timedOut: false, ...overrides } +} + +function handle(opts: { + exec?: (command: string) => Promise + guardPath?: RemoteExecutionHandle['guardPath'] + guardFile?: RemoteExecutionHandle['guardFile'] +} = {}): { handle: RemoteExecutionHandle; exec: ReturnType } { + const exec = vi.fn(opts.exec ?? (async () => execResult({ stdout: 'ok' }))) + const h: RemoteExecutionHandle = { + target: { kind: 'ssh', alias: 'prod', remoteDir: '/srv/api' }, + runMode: 'develop', + production: false, + status: () => 'connected', + describe: () => ({ target: { kind: 'ssh', alias: 'prod', remoteDir: '/srv/api' }, status: 'connected' }), + guardCommand: () => ({ decision: 'allow', reasons: [] }), + guardPath: opts.guardPath ?? (() => ({ decision: 'allow', reasons: [] })), + guardFile: opts.guardFile ?? (() => ({ decision: 'allow', reasons: [] })), + exec: exec as never + } + return { handle: h, exec } +} + +function context(awaitApproval: ToolHostContext['awaitApproval'] = vi.fn(async () => 'allow' as const)): ToolHostContext { + return { threadId: 't', turnId: 'tn', workspace: '/ws', approvalPolicy: 'auto', abortSignal: new AbortController().signal, awaitApproval } +} + +describe('remote file tools', () => { + it('read reads the remote file directly (no cat|head pipe) and tags the target', async () => { + const { handle: h, exec } = handle({ exec: async () => execResult({ stdout: 'line1\nline2' }) }) + const result = await remoteRead(h, { path: 'src/a.ts' }, context()) + expect(result.output).toMatchObject({ target: 'ssh', host: 'prod', content: 'line1\nline2' }) + expect(exec.mock.calls[0][0]).toContain("head -c") + expect(exec.mock.calls[0][0]).toContain("-- 'src/a.ts'") + expect(exec.mock.calls[0][0]).not.toContain('| head') + }) + + it('read surfaces a missing-file failure instead of empty success', async () => { + const { handle: h } = handle({ exec: async () => execResult({ exitCode: 1, stderr: 'No such file or directory', stdout: '' }) }) + const result = await remoteRead(h, { path: 'nope.ts' }, context()) + expect(result.isError).toBe(true) + expect(String((result.output as Record).error)).toContain('No such file') + }) + + it('read uses sed for a line window', async () => { + const { handle: h, exec } = handle() + await remoteRead(h, { path: 'a.ts', offset: 5, limit: 10 }, context()) + expect(exec.mock.calls[0][0]).toContain("sed -n '5,14p'") + }) + + it('writes content over stdin and atomically replaces the remote file', async () => { + const { handle: h, exec } = handle({ exec: async () => execResult({ exitCode: 0 }) }) + const result = await remoteWrite(h, { path: 'out.txt', content: 'hello' }, context()) + expect(result.isError).toBeFalsy() + expect(result.output).toMatchObject({ bytes_written: 5 }) + expect(exec.mock.calls[0][0]).toContain('mktemp') + expect(exec.mock.calls[0][0]).toContain('mv -f') + expect(exec.mock.calls[0][1]).toMatchObject({ input: 'hello' }) + }) + + it('edit reads, applies replacement, and writes back', async () => { + let call = 0 + const { handle: h, exec } = handle({ + exec: async () => { + call += 1 + return call === 1 ? execResult({ stdout: 'const x = 1' }) : execResult({ exitCode: 0 }) + } + }) + const result = await remoteEdit(h, { path: 'a.ts', oldText: 'const x = 1', newText: 'const x = 2' }, context()) + expect(result.isError).toBeFalsy() + expect(result.output).toMatchObject({ replacements: 1 }) + expect(exec.mock.calls[1][0]).toContain('sha256sum') + expect(exec.mock.calls[1][0]).toContain('mv -f') + expect(exec.mock.calls[1][1]).toMatchObject({ input: 'const x = 2' }) + // The edit read must NOT cap with a pipe (would truncate large files on write-back). + expect(exec.mock.calls[0][0]).toBe("cat -- 'a.ts'") + }) + + it('refuses the edit write-back when the remote file changed since the read (atomic CAS conflict)', async () => { + let call = 0 + const { handle: h } = handle({ + exec: async () => { + call += 1 + // 1st = cat read; 2nd = CAS write-back reports a conflict (exit 65). + return call === 1 + ? execResult({ stdout: 'const x = 1' }) + : execResult({ exitCode: 65, stderr: 'REMOTE_EDIT_CONFLICT' }) + } + }) + const result = await remoteEdit(h, { path: 'a.ts', oldText: 'const x = 1', newText: 'const x = 2' }, context()) + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ conflict: true }) + expect(String((result.output as Record).error)).toContain('changed since it was read') + }) + + it('REFUSES to edit a file whose read was truncated (no silent data loss)', async () => { + const { handle: h, exec } = handle({ exec: async () => execResult({ stdout: 'partial content', truncated: true }) }) + const result = await remoteEdit(h, { path: 'big.ts', oldText: 'a', newText: 'b' }, context()) + expect(result.isError).toBe(true) + expect(String((result.output as Record).error)).toContain('too large') + // Only the read ran — no write-back of a truncated file. + expect(exec).toHaveBeenCalledTimes(1) + }) + + it('ls lists remote entries', async () => { + const { handle: h } = handle({ exec: async () => execResult({ stdout: 'a.ts\nsub/\n' }) }) + const result = await remoteLs(h, { path: '.' }, context()) + expect(result.output.names).toEqual(['a.ts', 'sub/']) + }) + + it('find returns remote matches', async () => { + const { handle: h, exec } = handle({ exec: async () => execResult({ stdout: './src/a.ts\n./src/b.ts' }) }) + const result = await remoteFind(h, { pattern: '*.ts' }, context()) + expect(result.output.matches).toEqual(['./src/a.ts', './src/b.ts']) + expect(exec.mock.calls[0][0]).toContain('find') + }) + + it('grep parses path:line:text rows', async () => { + const guardFile = vi.fn(() => ({ decision: 'allow' as const, reasons: [] })) + const { handle: h } = handle({ exec: async () => execResult({ stdout: 'src/a.ts:12:const x = 1' }), guardFile }) + const result = await remoteGrep(h, { pattern: 'const' }, context()) + expect(result.output.matches).toEqual([{ path: 'src/a.ts', line: 12, text: 'const x = 1' }]) + expect(guardFile).toHaveBeenCalledWith(expect.objectContaining({ operation: 'search', recursive: true })) + }) + + it('denies a protected-path write without executing', async () => { + const { handle: h, exec } = handle({ guardFile: () => ({ decision: 'deny', reasons: ['protected'] }) }) + const result = await remoteWrite(h, { path: '/etc/passwd', content: 'x' }, context()) + expect(exec).not.toHaveBeenCalled() + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ decision: 'deny' }) + }) + + it('requires approval before writing a confirm-class protected path', async () => { + const approve = vi.fn(async () => 'deny' as const) + const { handle: h, exec } = handle({ guardFile: () => ({ decision: 'confirm', reasons: ['protected'] }) }) + const result = await remoteWrite(h, { path: '.env', content: 'SECRET=1' }, context(approve)) + expect(approve).toHaveBeenCalled() + expect(exec).not.toHaveBeenCalled() + expect(result.output).toMatchObject({ approved: false }) + }) + + it('routes search ops through the file gate (deny blocks grep without executing)', async () => { + const { handle: h, exec } = handle({ guardFile: () => ({ decision: 'deny', reasons: ['observe mode'] }) }) + const result = await remoteGrep(h, { pattern: 'secret' }, context()) + expect(exec).not.toHaveBeenCalled() + expect(result.isError).toBe(true) + expect(result.output).toMatchObject({ operation: 'search', decision: 'deny' }) + }) + + it('requires approval before searching a protected path (find confirm)', async () => { + const approve = vi.fn(async () => 'deny' as const) + const { handle: h, exec } = handle({ guardFile: () => ({ decision: 'confirm', reasons: ['protected'] }) }) + const result = await remoteFind(h, { pattern: '*.pem', path: '/etc/ssl' }, context(approve)) + expect(approve).toHaveBeenCalled() + expect(exec).not.toHaveBeenCalled() + expect(result.output).toMatchObject({ approved: false }) + }) + + it('surfaces statusUnknown when the connection drops mid-op', async () => { + const { handle: h } = handle({ exec: async () => execResult({ statusUnknown: true, stderr: 'reset', exitCode: null }) }) + const result = await remoteRead(h, { path: 'a.ts' }, context()) + expect(result.output).toMatchObject({ statusUnknown: true }) + }) +}) diff --git a/kun/src/adapters/tool/remote-file-tools.ts b/kun/src/adapters/tool/remote-file-tools.ts new file mode 100644 index 000000000..3d2f90ca4 --- /dev/null +++ b/kun/src/adapters/tool/remote-file-tools.ts @@ -0,0 +1,328 @@ +/** + * Target-aware file tools (Issue #647, fixes the local/remote split). + * + * When a thread is bound to an SSH target, read/write/edit/ls/find/grep must run + * on the REMOTE host — not the local machine — otherwise the agent reads local + * code, edits local files, but runs commands on the server (a dangerous + * environment split). These helpers implement each file op over the system ssh + * executor using portable POSIX commands, apply the protected-path guard + * (deny → error, confirm → real approval BEFORE the op), and tag every result + * with the target/host/remote dir so the model never confuses environments. + */ + +import type { RemoteExecutionHandle, RemoteFileOperation } from '../../ports/remote-execution.js' +import { createHash } from 'node:crypto' +import { StringDecoder } from 'node:string_decoder' +import type { ToolHostContext } from '../../ports/tool-host.js' +import { createApprovalRequest } from '../../domain/approval.js' +import { shellQuoteRemote } from '../../remote/ssh-command.js' +import { isSshTarget } from '../../remote/remote-target.js' +import { + applyEditsToNormalizedContent, + detectLineEnding, + firstChangedLine, + generateDisplayDiff, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom +} from './edit-diff.js' +import { parseEditInstructions } from './builtin-tool-utils.js' + +export type RemoteFileToolResult = { output: Record; isError?: boolean } + +const REMOTE_READ_BYTE_CAP = 1_048_576 // 1 MiB safety cap on remote read output. + +function targetMeta(handle: RemoteExecutionHandle): { target: 'ssh'; host: string; remoteDir?: string } { + const target = handle.describe().target + return { + target: 'ssh', + host: isSshTarget(target) ? target.alias : 'local', + ...(isSshTarget(target) && target.remoteDir ? { remoteDir: target.remoteDir } : {}) + } +} + +/** + * Apply the unified run-mode + protected-path gate. Returns a blocking result + * when the op must not proceed (deny, or a confirm the user rejected); + * undefined means proceed. EVERY remote file op routes through here so the + * observe-mode mutation block and the protected-path policy are enforced in one + * place (including list/search, so secrets cannot be probed via grep/find). + */ +async function gateRemoteFile( + handle: RemoteExecutionHandle, + context: ToolHostContext, + operation: RemoteFileOperation, + path: string, + recursive = false +): Promise { + const guard = handle.guardFile({ operation, path, recursive }) + const meta = targetMeta(handle) + if (guard.decision === 'deny') { + return { output: { ...meta, path, operation, decision: 'deny', error: `blocked: ${guard.reasons.join('; ')}` }, isError: true } + } + if (guard.decision === 'confirm') { + const mutates = operation === 'create' || operation === 'write' || operation === 'edit' || operation === 'delete' + const approval = createApprovalRequest({ + id: `appr_remotefile_${context.turnId}_${Math.random().toString(36).slice(2, 8)}`, + threadId: context.threadId, + turnId: context.turnId, + toolName: mutates ? 'edit' : 'read', + summary: `${operation} protected remote path on ${meta.host}: ${path}\nRisk: ${guard.reasons.join('; ')}` + }) + const decision = await context.awaitApproval(approval) + if (decision !== 'allow') { + return { output: { ...meta, path, operation, decision: 'confirm', approved: false, error: 'remote file access was not approved' }, isError: true } + } + } + return undefined +} + +function statusUnknownResult(meta: Record, path: string, stderr: string): RemoteFileToolResult { + return { + output: { ...meta, path, statusUnknown: true, stderr, note: 'connection dropped before the result was confirmed; not auto-replayed' }, + isError: true + } +} + +export async function remoteRead(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const path = typeof args.path === 'string' ? args.path.trim() : '' + if (!path) return { output: { error: 'path is required' }, isError: true } + const blocked = await gateRemoteFile(handle, context, 'read', path) + if (blocked) return blocked + const meta = targetMeta(handle) + const q = shellQuoteRemote(path) + const offset = toPositiveInt(args.offset) + const limit = toPositiveInt(args.limit) + // Read the file DIRECTLY (no `cat | head` pipe): a pipe would mask a missing + // file because the pipe's exit status is `head`'s success. `head`/`sed` open + // the file themselves and exit non-zero when it is absent. + const command = offset || limit + ? `sed -n ${shellQuoteRemote(`${offset ?? 1},${(offset ?? 1) + (limit ?? 2000) - 1}p`)} -- ${q}` + : `head -c ${REMOTE_READ_BYTE_CAP + 1} -- ${q}` + const result = await handle.exec(command, { ...(context.abortSignal ? { signal: context.abortSignal } : {}) }) + if (result.statusUnknown) return statusUnknownResult(meta, path, result.stderr) + if (result.exitCode !== 0) { + return { output: { ...meta, path, error: result.stderr.trim() || `read failed (exit ${result.exitCode})` }, isError: true } + } + // Byte-cap the line-window output in JS (the executor also caps overall). + let stdout = result.stdout + let truncated = result.truncated === true + if (Buffer.byteLength(stdout, 'utf8') > REMOTE_READ_BYTE_CAP) { + const decoder = new StringDecoder('utf8') + stdout = decoder.write(Buffer.from(stdout, 'utf8').subarray(0, REMOTE_READ_BYTE_CAP)) + truncated = true + } + const content = stdout.replace(/\r\n/g, '\n') + return { + output: { + ...meta, + path, + content: truncated ? `${content}\n\n[truncated at ${REMOTE_READ_BYTE_CAP} bytes — use offset/limit for a specific range]` : content, + truncated, + ...(offset ? { start_line: offset } : {}) + } + } +} + +export async function remoteWrite(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const path = typeof args.path === 'string' ? args.path.trim() : '' + const content = typeof args.content === 'string' ? args.content : null + if (!path || content == null) return { output: { error: 'path and content are required' }, isError: true } + const blocked = await gateRemoteFile(handle, context, 'write', path) + if (blocked) return blocked + const meta = targetMeta(handle) + const q = shellQuoteRemote(path) + const command = atomicReplaceCommand(q) + const result = await handle.exec(command, { + input: content, + ...(context.abortSignal ? { signal: context.abortSignal } : {}) + }) + if (result.statusUnknown) return statusUnknownResult(meta, path, result.stderr) + if (result.exitCode !== 0) { + return { output: { ...meta, path, error: result.stderr.trim() || `write failed (exit ${result.exitCode})` }, isError: true } + } + return { output: { ...meta, path, bytes_written: Buffer.byteLength(content, 'utf8') } } +} + +export async function remoteEdit(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const path = typeof args.path === 'string' ? args.path.trim() : '' + const edits = parseEditInstructions(args) + if (!path || edits.length === 0) return { output: { error: 'path and at least one edit are required' }, isError: true } + const blocked = await gateRemoteFile(handle, context, 'edit', path) + if (blocked) return blocked + const meta = targetMeta(handle) + const q = shellQuoteRemote(path) + // Read the CURRENT content directly (no `| head` cap): edit must operate on + // the whole file, then write it back. Capping the read would silently + // truncate any file larger than the cap on write-back (data loss). Instead we + // read in full and REFUSE the edit if the executor reports truncation. + const readResult = await handle.exec(`cat -- ${q}`, { ...(context.abortSignal ? { signal: context.abortSignal } : {}) }) + if (readResult.statusUnknown) return statusUnknownResult(meta, path, readResult.stderr) + if (readResult.exitCode !== 0) { + return { output: { ...meta, path, error: readResult.stderr.trim() || `edit read failed (exit ${readResult.exitCode})` }, isError: true } + } + if (readResult.truncated) { + return { + output: { ...meta, path, error: 'file is too large to edit safely over SSH (read was truncated); refusing to avoid data loss. Edit it on the remote host or split the change.' }, + isError: true + } + } + const { bom, text: source } = stripBom(readResult.stdout) + const lineEnding = detectLineEnding(source) + const normalizedSource = normalizeToLF(source) + let applied: { baseContent: string; newContent: string } + try { + applied = applyEditsToNormalizedContent(normalizedSource, edits, path) + } catch (error) { + return { output: { ...meta, path, error: error instanceof Error ? error.message : String(error) }, isError: true } + } + const next = bom + restoreLineEndings(applied.newContent, lineEnding) + const expectedHash = createHash('sha256').update(readResult.stdout, 'utf8').digest('hex') + const writeResult = await handle.exec(atomicReplaceCommand(q, expectedHash), { + input: next, + ...(context.abortSignal ? { signal: context.abortSignal } : {}) + }) + if (writeResult.statusUnknown) return statusUnknownResult(meta, path, writeResult.stderr) + if (writeResult.exitCode === 65 || writeResult.stderr.includes('REMOTE_EDIT_CONFLICT')) { + return { + output: { ...meta, path, conflict: true, error: 'remote file changed since it was read; edit refused to avoid overwriting the out-of-band change. Re-read the file and re-apply.' }, + isError: true + } + } + if (writeResult.exitCode !== 0) { + return { output: { ...meta, path, error: writeResult.stderr.trim() || `edit write failed (exit ${writeResult.exitCode})` }, isError: true } + } + return { + output: { + ...meta, + path, + replacements: edits.length, + bytes_written: Buffer.byteLength(next, 'utf8'), + diff: generateDisplayDiff(applied.baseContent, applied.newContent), + patch: generateUnifiedPatch(path, applied.baseContent, applied.newContent), + first_changed_line: firstChangedLine(applied.baseContent, applied.newContent) + } + } +} + +/** + * Read stdin into a same-directory temporary file and atomically rename it over + * the target. For edits, verify the previously-read SHA-256 under a cooperative + * lock immediately before rename. The lock prevents Kun writers racing each + * other; the hash detects unrelated writers before replacement. + */ +function atomicReplaceCommand(quotedPath: string, expectedHash?: string): string { + const verify = expectedHash + ? `actual=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum -- ${quotedPath} | awk '{print $1}'; else shasum -a 256 -- ${quotedPath} | awk '{print $1}'; fi) && ` + + `if [ "$actual" != ${shellQuoteRemote(expectedHash)} ]; then echo REMOTE_EDIT_CONFLICT >&2; exit 65; fi && ` + : '' + return `d=$(dirname -- ${quotedPath}) && b=$(basename -- ${quotedPath}) && mkdir -p -- "$d" && ` + + `tmp=$(mktemp "$d/.${'$'}b.kun.XXXXXX") && lock=${quotedPath}.kun-lock && ` + + `cleanup(){ rm -f -- "$tmp"; rmdir -- "$lock" 2>/dev/null || true; }; trap cleanup EXIT HUP INT TERM && ` + + `cat > "$tmp" && if ! mkdir -- "$lock" 2>/dev/null; then echo REMOTE_EDIT_BUSY >&2; exit 75; fi && ` + + verify + + `if [ -e ${quotedPath} ]; then mode=$(stat -c %a -- ${quotedPath} 2>/dev/null || stat -f %Lp -- ${quotedPath}) && chmod "$mode" "$tmp"; fi && ` + + `mv -f -- "$tmp" ${quotedPath}` +} + +export async function remoteLs(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const path = typeof args.path === 'string' && args.path.trim() ? args.path.trim() : '.' + const limit = toPositiveInt(args.limit) ?? 500 + const blocked = await gateRemoteFile(handle, context, 'list', path) + if (blocked) return blocked + const meta = targetMeta(handle) + // Run `ls` DIRECTLY (no `| head` pipe): a pipe masks ls's exit status (the + // pipeline reports head's success), so a missing dir or a permission-denied + // would look like an empty listing. Cap the line count in JS instead. + const command = `ls -1Ap -- ${shellQuoteRemote(path)}` + const result = await handle.exec(command, { ...(context.abortSignal ? { signal: context.abortSignal } : {}) }) + if (result.statusUnknown) return statusUnknownResult(meta, path, result.stderr) + if (result.exitCode !== 0) { + return { output: { ...meta, path, error: result.stderr.trim() || `ls failed (exit ${result.exitCode})` }, isError: true } + } + const allNames = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean) + const names = allNames.slice(0, limit) + return { output: { ...meta, path, names, truncated: allNames.length > limit || result.truncated === true } } +} + +export async function remoteFind(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const pattern = typeof args.pattern === 'string' ? args.pattern.trim() : '' + if (!pattern) return { output: { error: 'pattern is required' }, isError: true } + const path = typeof args.path === 'string' && args.path.trim() ? args.path.trim() : '.' + const limit = toPositiveInt(args.limit) ?? 200 + const blocked = await gateRemoteFile(handle, context, 'search', path, true) + if (blocked) return blocked + const meta = targetMeta(handle) + const flag = pattern.includes('/') ? '-path' : '-name' + const findPattern = pattern.includes('/') ? `*${pattern}*` : pattern + // Run `find` DIRECTLY: no `| head` (which would mask find's exit status) and + // no `2>/dev/null` (which would silently hide a bad path / permission error). + // `find` still lists accessible files even when a subdir is unreadable and + // exits non-zero, so a non-zero exit with matches is a partial-success + // warning, while a non-zero exit with NO matches is a real error. + const command = `find ${shellQuoteRemote(path)} -type f ${flag} ${shellQuoteRemote(findPattern)}` + const result = await handle.exec(command, { ...(context.abortSignal ? { signal: context.abortSignal } : {}) }) + if (result.statusUnknown) return statusUnknownResult(meta, path, result.stderr) + const allMatches = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean) + if (result.exitCode !== 0 && allMatches.length === 0) { + return { output: { ...meta, path, pattern, error: result.stderr.trim() || `find failed (exit ${result.exitCode})` }, isError: true } + } + const matches = allMatches.slice(0, limit) + return { + output: { + ...meta, + path, + pattern, + matches, + backend: 'find', + truncated: allMatches.length > limit || result.truncated === true, + ...(result.exitCode !== 0 && result.stderr.trim() ? { warning: result.stderr.trim() } : {}) + } + } +} + +export async function remoteGrep(handle: RemoteExecutionHandle, args: Record, context: ToolHostContext): Promise { + const pattern = typeof args.pattern === 'string' ? args.pattern : '' + if (!pattern.trim()) return { output: { error: 'pattern is required' }, isError: true } + const path = typeof args.path === 'string' && args.path.trim() ? args.path.trim() : '.' + const limit = toPositiveInt(args.limit) ?? 200 + const ignoreCase = args.ignoreCase === true + const literal = args.literal === true + const blocked = await gateRemoteFile(handle, context, 'search', path, true) + if (blocked) return blocked + const meta = targetMeta(handle) + const flags = ['-rnI', ignoreCase ? '-i' : '', literal ? '-F' : '-E'].filter(Boolean).join(' ') + // Run `grep` DIRECTLY: no `| head` (masks grep's exit) and no `2>/dev/null` + // (hides real errors). grep's exit code is meaningful: 0 = matches found, + // 1 = no matches (NOT an error here), >=2 = a real error (bad path, bad + // regex, unreadable file). Cap the line count in JS. + const command = `grep ${flags} -- ${shellQuoteRemote(pattern)} ${shellQuoteRemote(path)}` + const result = await handle.exec(command, { ...(context.abortSignal ? { signal: context.abortSignal } : {}) }) + if (result.statusUnknown) return statusUnknownResult(meta, path, result.stderr) + if (result.exitCode !== null && result.exitCode >= 2) { + return { output: { ...meta, path, pattern, error: result.stderr.trim() || `grep failed (exit ${result.exitCode})` }, isError: true } + } + const allRows = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean) + const matches = allRows.slice(0, limit).map((row) => { + const parsed = row.match(/^(.*?):(\d+):(.*)$/) + return parsed ? { path: parsed[1], line: Number(parsed[2]), text: parsed[3] } : { text: row } + }) + return { + output: { + ...meta, + path, + pattern, + ignore_case: ignoreCase, + literal, + backend: 'grep', + matches, + truncated: allRows.length > limit || result.truncated === true + } + } +} + +function toPositiveInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return Math.floor(value) +} diff --git a/kun/src/adapters/tool/remote-port-forward-tool.test.ts b/kun/src/adapters/tool/remote-port-forward-tool.test.ts new file mode 100644 index 000000000..119dd4a82 --- /dev/null +++ b/kun/src/adapters/tool/remote-port-forward-tool.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRemotePortForwardRuntime } from './remote-port-forward-tool.js' +import type { ToolHostContext } from '../../ports/tool-host.js' +import type { RemoteExecutionHandle } from '../../ports/remote-execution.js' +import type { SshChildProcess } from '../../remote/ssh-executor.js' + +function fakeChild(): SshChildProcess { + return { stdout: null, stderr: null, on: () => undefined, kill: vi.fn() } as unknown as SshChildProcess +} + +function sshContext(): ToolHostContext { + const handle = { + describe: () => ({ target: { kind: 'ssh', alias: 'prod' }, status: 'connected', production: false }), + production: false + } as unknown as RemoteExecutionHandle + return { + threadId: 't', + turnId: 'tn', + workspace: '/ws', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const), + executionTarget: handle + } +} + +function localContext(): ToolHostContext { + return { + threadId: 't', + turnId: 'tn', + workspace: '/ws', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const) + } +} + +describe('remote_port_forward tool', () => { + it('opens a tunnel and returns a local preview url once it is ready', async () => { + const spawn = vi.fn(() => fakeChild()) + const runtime = createRemotePortForwardRuntime({ spawn, allocatePort: async () => 54321, probeReady: async () => true }) + const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 3000 }, sshContext()) + const out = result.output as Record + expect(out.url).toBe('http://127.0.0.1:54321') + expect(out.status).toBe('ready') + }) + + it('refuses on a local thread', async () => { + const runtime = createRemotePortForwardRuntime({ spawn: () => fakeChild(), allocatePort: async () => 5000, probeReady: async () => true }) + const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 3000 }, localContext()) + expect(result.isError).toBe(true) + }) + + it('validates the remote port', async () => { + const runtime = createRemotePortForwardRuntime({ spawn: () => fakeChild(), allocatePort: async () => 5000, probeReady: async () => true }) + const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 0 }, sshContext()) + expect(result.isError).toBe(true) + }) + + it('disposes per thread', async () => { + const child = fakeChild() + const runtime = createRemotePortForwardRuntime({ spawn: () => child, allocatePort: async () => 5000, probeReady: async () => true }) + await runtime.provider.tools[0].execute({ action: 'open', remotePort: 8080 }, sshContext()) + runtime.disposeThreadResources('t') + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + const listed = await runtime.provider.tools[0].execute({ action: 'list' }, sshContext()) + expect((listed.output as { forwards: unknown[] }).forwards).toHaveLength(0) + }) +}) diff --git a/kun/src/adapters/tool/remote-port-forward-tool.ts b/kun/src/adapters/tool/remote-port-forward-tool.ts new file mode 100644 index 000000000..14cc510ad --- /dev/null +++ b/kun/src/adapters/tool/remote-port-forward-tool.ts @@ -0,0 +1,173 @@ +import { spawn as nodeSpawn } from 'node:child_process' +import { connect as netConnect, createServer } from 'node:net' +import { LocalToolHost, type LocalTool } from './local-tool-host.js' +import { RemotePortForwardManager } from '../../remote/remote-port-forward-manager.js' +import type { SshChildProcess, SshSpawnFn } from '../../remote/ssh-executor.js' +import { isSshTarget } from '../../remote/remote-target.js' +import type { CapabilityToolProvider } from './capability-registry.js' +import { createApprovalRequest } from '../../domain/approval.js' + +const realSpawn: SshSpawnFn = (command, args) => + nodeSpawn(command, [...args], { shell: false }) as unknown as SshChildProcess + +function probeLocalPort(port: number, timeoutMs = 500): Promise { + return new Promise((resolve) => { + const socket = netConnect({ port, host: '127.0.0.1' }) + let settled = false + const done = (ok: boolean): void => { + if (settled) return + settled = true + socket.destroy() + resolve(ok) + } + socket.once('connect', () => done(true)) + socket.once('error', () => done(false)) + socket.setTimeout(timeoutMs, () => done(false)) + }) +} + +function allocateFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + server.close(() => (port ? resolve(port) : reject(new Error('could not allocate a local port')))) + }) + }) +} + +export type RemotePortForwardToolDeps = { + spawn?: SshSpawnFn + allocatePort?: () => Promise + probeReady?: (port: number) => Promise + readyTimeoutMs?: number +} + +export type RemotePortForwardRuntime = { + provider: CapabilityToolProvider + disposeThreadResources(threadId: string): void + shutdown(): void +} + +export function createRemotePortForwardRuntime(deps: RemotePortForwardToolDeps = {}): RemotePortForwardRuntime { + const readyTimeoutMs = deps.readyTimeoutMs ?? 5_000 + const probeReady = deps.probeReady ?? ((port: number) => probeLocalPort(port)) + const managers = new Map() + + const managerFor = (threadId: string): RemotePortForwardManager => { + const existing = managers.get(threadId) + if (existing) return existing + const manager = new RemotePortForwardManager({ + spawn: deps.spawn ?? realSpawn, + allocatePort: deps.allocatePort ?? allocateFreePort, + probeReady + }) + managers.set(threadId, manager) + return manager + } + + const tool = LocalToolHost.defineTool({ + name: 'remote_port_forward', + description: + 'Preview a remote service locally over an SSH tunnel (remote thread only). ' + + 'Default action is open; list and close are available for cleanup.', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['open', 'list', 'close'] }, + remotePort: { type: 'number' }, + localPort: { type: 'number' }, + host: { type: 'string' }, + label: { type: 'string' }, + id: { type: 'string' } + }, + additionalProperties: false + }, + policy: 'on-request', + shouldAdvertise: (context) => context.executionTarget?.describe().target.kind === 'ssh', + execute: async (args, context) => { + const executionTarget = context.executionTarget + const target = executionTarget?.describe().target + if (!target || !isSshTarget(target)) { + return { output: { error: 'remote_port_forward requires a thread bound to an SSH target' }, isError: true } + } + const manager = managerFor(context.threadId) + const action = typeof args.action === 'string' ? args.action : 'open' + try { + if (action === 'list') { + return { output: { action, forwards: manager.list() } } + } + if (action === 'close') { + const id = typeof args.id === 'string' ? args.id : '' + const closed = manager.close(id) + return { output: { action, id, closed }, isError: !closed } + } + + const remotePort = typeof args.remotePort === 'number' ? args.remotePort : 0 + if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { + return { output: { error: 'a valid remotePort (1-65535) is required' }, isError: true } + } + const rawLocalPort = typeof args.localPort === 'number' ? args.localPort : undefined + if (rawLocalPort !== undefined && rawLocalPort !== 0 && (rawLocalPort < 1024 || rawLocalPort > 65535)) { + return { output: { error: 'localPort must be omitted/0 or 1024-65535' }, isError: true } + } + if (executionTarget.production && context.approvalPolicy === 'auto') { + const approval = await context.awaitApproval(createApprovalRequest({ + id: 'appr_remote_port_forward_' + context.turnId, + threadId: context.threadId, + turnId: context.turnId, + toolName: 'remote_port_forward', + summary: 'Open SSH port forward for ' + target.alias + ':' + String(remotePort) + })) + if (approval !== 'allow') { + return { output: { error: 'remote port forward was not approved' }, isError: true } + } + } + const forward = await manager.open({ + alias: target.alias, + remotePort, + ...(typeof args.host === 'string' && args.host.trim() ? { remoteHost: args.host.trim() } : {}), + ...(typeof rawLocalPort === 'number' && rawLocalPort > 0 ? { localPort: rawLocalPort } : {}), + ...(readyTimeoutMs > 0 ? { waitForReadyMs: readyTimeoutMs } : {}) + }) + return { + output: { + target: 'ssh', + status: 'ready', + url: forward.url, + localPort: forward.localPort, + remotePort: forward.remotePort, + host: forward.remoteHost, + ...(typeof args.label === 'string' && args.label.trim() ? { label: args.label.trim() } : {}) + } + } + } catch (error) { + return { output: { error: error instanceof Error ? error.message : String(error) }, isError: true } + } + } + }) + + return { + provider: { + id: 'remote-port-forward', + kind: 'built-in', + enabled: true, + available: true, + tools: [tool] + }, + disposeThreadResources(threadId: string): void { + const manager = managers.get(threadId) + if (!manager) return + manager.closeAll() + managers.delete(threadId) + }, + shutdown(): void { + for (const manager of managers.values()) { + manager.closeAll() + } + managers.clear() + } + } +} diff --git a/kun/src/contracts/remote.ts b/kun/src/contracts/remote.ts new file mode 100644 index 000000000..a6a3099d1 --- /dev/null +++ b/kun/src/contracts/remote.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' +import { ThreadRemoteRunMode } from './threads.js' + +/** + * HTTP contracts for the Remote Agent Target API (Issue #647). + * + * The renderer uses these to populate the Composer "运行位置 / Run location" + * picker (SSH host list from the user's ~/.ssh/config), to test a connection + * before binding a thread, and to load shareable secret-free Remote Profiles. + */ + +export const RemoteHostSummarySchema = z.object({ + alias: z.string().min(1), + hostName: z.string().optional(), + user: z.string().optional(), + port: z.number().int().positive().optional(), + proxyJump: z.string().optional() +}) +export type RemoteHostSummary = z.infer + +export const ListRemoteHostsResponse = z.object({ + hosts: z.array(RemoteHostSummarySchema), + /** True when ~/.ssh/config existed and was readable. */ + configFound: z.boolean() +}) +export type ListRemoteHostsResponse = z.infer + +export const TestRemoteConnectionRequest = z.object({ + alias: z.string().min(1), + remoteDir: z.string().min(1).optional() +}) +export type TestRemoteConnectionRequest = z.infer + +export const RemoteConnectionTestResponse = z.object({ + ok: z.boolean(), + alias: z.string(), + remoteDir: z.string().optional(), + status: z.enum(['connected', 'connecting', 'degraded', 'disconnected', 'error']), + latencyMs: z.number().optional(), + os: z.string().optional(), + branch: z.string().optional(), + dirty: z.boolean().optional(), + repoRoot: z.string().optional(), + tools: z.record(z.string(), z.boolean()).default({}), + error: z.string().optional() +}) +export type RemoteConnectionTestResponse = z.infer + +export const RemoteProfileSummarySchema = z.object({ + name: z.string().min(1), + host: z.string().min(1), + workspace: z.string().min(1), + mode: ThreadRemoteRunMode, + production: z.boolean(), + healthCheck: z.string().optional(), + testCommand: z.string().optional(), + protectedPaths: z.array(z.string()).default([]) +}) +export type RemoteProfileSummary = z.infer + +export const ListRemoteProfilesResponse = z.object({ + profiles: z.array(RemoteProfileSummarySchema) +}) +export type ListRemoteProfilesResponse = z.infer diff --git a/kun/src/contracts/threads.ts b/kun/src/contracts/threads.ts index 4948a64d3..fa4e97fdc 100644 --- a/kun/src/contracts/threads.ts +++ b/kun/src/contracts/threads.ts @@ -13,6 +13,21 @@ export type ThreadStatus = z.infer export const ThreadMode = z.enum(['agent', 'plan']) export type ThreadMode = z.infer +export const ThreadRemoteRunMode = z.enum(['observe', 'develop', 'operations', 'deploy']) +export type ThreadRemoteRunMode = z.infer + +export const ThreadRemoteTargetSchema = z.object({ + kind: z.literal('ssh'), + alias: z.string().min(1), + host: z.string().min(1).optional(), + remoteDir: z.string().min(1).optional(), + runMode: ThreadRemoteRunMode.default('observe'), + production: z.boolean().default(false), + profileName: z.string().min(1).optional(), + protectedPaths: z.array(z.string().min(1)).default([]) +}) +export type ThreadRemoteTarget = z.infer + /** * Discriminator describing how a thread relates to its origin. * @@ -133,6 +148,7 @@ export const ThreadSchema = z.object({ status: ThreadStatus, approvalPolicy: ApprovalPolicySchema.default(DEFAULT_APPROVAL_POLICY), sandboxMode: SandboxModeSchema.default(DEFAULT_SANDBOX_MODE), + remoteTarget: ThreadRemoteTargetSchema.optional(), pinned: z.boolean().optional(), costBudgetUsd: z.number().positive().optional(), costBudgetWarningSent: z.boolean().optional(), @@ -165,6 +181,7 @@ export const ThreadSummarySchema = ThreadSchema.pick({ status: true, approvalPolicy: true, sandboxMode: true, + remoteTarget: true, pinned: true, costBudgetUsd: true, costBudgetWarningSent: true, @@ -202,6 +219,7 @@ export const CreateThreadRequest = z.object({ mode: ThreadMode.default('agent'), approvalPolicy: ApprovalPolicySchema.optional(), sandboxMode: SandboxModeSchema.optional(), + remoteTarget: ThreadRemoteTargetSchema.optional(), costBudgetUsd: z.number().positive().optional() }) export type CreateThreadRequest = z.infer @@ -287,6 +305,7 @@ export const UpdateThreadRequest = z status: ThreadStatus.optional(), approvalPolicy: ApprovalPolicySchema.optional(), sandboxMode: SandboxModeSchema.optional(), + remoteTarget: ThreadRemoteTargetSchema.nullable().optional(), pinned: z.boolean().optional(), costBudgetUsd: z.number().positive().nullable().optional(), costBudgetWarningSent: z.boolean().optional(), @@ -300,6 +319,7 @@ export const UpdateThreadRequest = z value.status !== undefined || value.approvalPolicy !== undefined || value.sandboxMode !== undefined || + value.remoteTarget !== undefined || value.pinned !== undefined || value.costBudgetUsd !== undefined || value.costBudgetWarningSent !== undefined || diff --git a/kun/src/domain/thread.ts b/kun/src/domain/thread.ts index aa359e8fa..b5902190c 100644 --- a/kun/src/domain/thread.ts +++ b/kun/src/domain/thread.ts @@ -4,6 +4,7 @@ import type { ThreadGoal, ThreadTodoList, ThreadRelation, + ThreadRemoteTarget, ThreadStatus } from '../contracts/threads.js' import { @@ -33,6 +34,7 @@ export function createThreadRecord(input: { status?: ThreadStatus approvalPolicy?: ApprovalPolicy sandboxMode?: SandboxMode + remoteTarget?: ThreadRemoteTarget pinned?: boolean costBudgetUsd?: number costBudgetWarningSent?: boolean @@ -61,6 +63,7 @@ export function createThreadRecord(input: { status: input.status ?? 'idle', approvalPolicy: input.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, sandboxMode: input.sandboxMode ?? DEFAULT_SANDBOX_MODE, + ...(input.remoteTarget ? { remoteTarget: input.remoteTarget } : {}), ...(input.pinned !== undefined ? { pinned: input.pinned } : {}), ...(input.costBudgetUsd !== undefined ? { costBudgetUsd: input.costBudgetUsd } : {}), ...(input.costBudgetWarningSent !== undefined ? { costBudgetWarningSent: input.costBudgetWarningSent } : {}), @@ -87,7 +90,7 @@ export function toThreadSummary( thread: ThreadEntity ): Pick< ThreadEntity, - 'id' | 'title' | 'titleAuto' | 'summary' | 'workspace' | 'model' | 'providerId' | 'agentId' | 'systemPrompt' | 'mode' | 'status' | 'approvalPolicy' | 'sandboxMode' | 'pinned' | 'createdAt' | 'updatedAt' + 'id' | 'title' | 'titleAuto' | 'summary' | 'workspace' | 'model' | 'providerId' | 'agentId' | 'systemPrompt' | 'mode' | 'status' | 'approvalPolicy' | 'sandboxMode' | 'remoteTarget' | 'pinned' | 'createdAt' | 'updatedAt' | 'costBudgetUsd' | 'costBudgetWarningSent' | 'relation' | 'parentThreadId' | 'forkedFromThreadId' | 'forkedFromTitle' | 'forkedAt' | 'forkedFromMessageCount' | 'forkedFromTurnCount' @@ -107,6 +110,7 @@ export function toThreadSummary( status: thread.status, approvalPolicy: thread.approvalPolicy, sandboxMode: thread.sandboxMode, + ...(thread.remoteTarget ? { remoteTarget: thread.remoteTarget } : {}), ...(thread.pinned !== undefined ? { pinned: thread.pinned } : {}), ...(thread.costBudgetUsd !== undefined ? { costBudgetUsd: thread.costBudgetUsd } : {}), ...(thread.costBudgetWarningSent !== undefined ? { costBudgetWarningSent: thread.costBudgetWarningSent } : {}), diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index 550f96f14..36c2b0216 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -677,6 +677,7 @@ export type AgentLoopOptions = { artifactStore?: ArtifactStore /** Kun runtime data root for sandbox-safe background shell output reads. */ runtimeDataDir?: string + resolveExecutionTarget?: (threadId: string) => ToolHostContext['executionTarget'] | undefined tokenEconomy?: TokenEconomyConfig contextCompaction?: ContextCompactionConfig /** Internal-LLM role model routing (smallModel slot + title/summary/codeReview overrides). */ @@ -2215,6 +2216,7 @@ export class AgentLoop { sandboxMode: NonNullable signal: AbortSignal }): ToolHostContext { + const executionTarget = this.opts.resolveExecutionTarget?.(input.threadId) return { threadId: input.threadId, turnId: input.turnId, @@ -2233,6 +2235,7 @@ export class AgentLoop { sandboxMode: input.sandboxMode, ...(this.opts.runtimeDataDir ? { runtimeDataDir: this.opts.runtimeDataDir } : {}), ...(this.opts.artifactStore ? { artifactStore: this.opts.artifactStore } : {}), + ...(executionTarget ? { executionTarget } : {}), abortSignal: input.signal, awaitApproval: async (approval) => { await this.opts.events.record({ diff --git a/kun/src/ports/remote-execution.ts b/kun/src/ports/remote-execution.ts new file mode 100644 index 000000000..58989fc02 --- /dev/null +++ b/kun/src/ports/remote-execution.ts @@ -0,0 +1,94 @@ +/** + * Remote execution port (Issue #647). + * + * The seam the agent loop / tool host uses to run the tool chain on a remote + * SSH target without knowing how SSH works. A local thread leaves + * `ToolHostContext.executionTarget` undefined (tools operate on this machine); + * an SSH thread attaches a {@link RemoteExecutionHandle} so the SAME tools run + * remotely. The model, API keys, approvals, and session records always stay + * local — only tool EXECUTION moves to the remote. + * + * This is a PORT: the concrete implementation lives in `kun/src/remote/*` + * (built on the system `ssh` binary). Keeping it here lets the loop and tests + * depend only on the interface. + */ + +import type { + RemoteConnectionStatus, + RemoteTarget, + RemoteTargetDescriptor +} from '../remote/remote-target.js' +import type { RemoteRunMode } from '../remote/remote-run-mode.js' + +export type RemoteExecOptions = { + timeoutMs?: number + signal?: AbortSignal + /** Optional stdin payload; keeps file contents and secrets out of argv. */ + input?: string | Buffer +} + +export type RemoteExecResult = { + command: string + stdout: string + stderr: string + exitCode: number | null + signal: string | null + durationMs: number + timedOut: boolean + aborted?: boolean + /** True when the connection dropped before a result was confirmed. */ + statusUnknown?: boolean + /** True when stdout/stderr was capped (output exceeded the executor's limit). */ + truncated?: boolean +} + +export type RemoteGuardOutcome = { + decision: 'allow' | 'confirm' | 'deny' + reasons: string[] +} + +/** + * File operations, used by the unified run-mode + protected-path gate. Mutation + * operations are blocked in `observe` mode; every operation (including list/ + * search/read) is checked against protected paths. + */ +export type RemoteFileOperation = 'list' | 'search' | 'read' | 'create' | 'write' | 'edit' | 'delete' + +/** + * Live handle to a connected (or reconnecting) remote target for one thread. + * Concrete impl wraps an {@link import('../remote/ssh-executor.js').SshExecutor} + * plus the connection state machine, run-mode guard, and path-access guard. + */ +export interface RemoteExecutionHandle { + readonly target: RemoteTarget + readonly runMode: RemoteRunMode + readonly production: boolean + /** Current connection status (connected/degraded/disconnected/...). */ + status(): RemoteConnectionStatus + /** A descriptor for the thread header / diagnostics. */ + describe(): RemoteTargetDescriptor + /** + * Classify a shell command against the run mode + risk model. Tools call this + * BEFORE executing so the approval card can show the decision and reasons. + */ + guardCommand(command: string): RemoteGuardOutcome + /** + * Classify a file read/write against protected paths + capability. Tools call + * this for read/edit/write before touching a remote file. + */ + guardPath(input: { capability: 'read' | 'write'; path: string }): RemoteGuardOutcome + /** + * Unified file-operation gate: blocks mutations in `observe` mode and applies + * the protected-path policy to EVERY operation (list/search/read included), + * so secrets cannot be probed via grep/find. All remote file tools route + * through this so the run-mode + path policy is enforced in one place. + */ + guardFile(input: { operation: RemoteFileOperation; path: string; recursive?: boolean }): RemoteGuardOutcome + /** + * Run a command on the remote target. Throws only on a hard spawn error; + * normal non-zero exits and timeouts come back in the result. On a mid-flight + * disconnect the result carries `statusUnknown: true` and the command is NOT + * auto-replayed if it mutates state. + */ + exec(command: string, options?: RemoteExecOptions): Promise +} diff --git a/kun/src/ports/tool-host.ts b/kun/src/ports/tool-host.ts index 808d0f4fa..ca997ab17 100644 --- a/kun/src/ports/tool-host.ts +++ b/kun/src/ports/tool-host.ts @@ -3,6 +3,7 @@ import type { ApprovalRequest } from '../domain/approval.js' import type { TurnItem } from '../contracts/items.js' import type { ModelCapabilityMetadata } from '../contracts/capabilities.js' import type { ArtifactStore } from '../artifacts/artifact-store.js' +import type { RemoteExecutionHandle } from './remote-execution.js' import type { UserInputRequest, UserInputResolution @@ -96,6 +97,8 @@ export type ToolHostContext = { runtimeDataDir?: string /** Store used to offload oversized tool results from model context. */ artifactStore?: ArtifactStore + /** Remote execution target for this turn; undefined means local execution. */ + executionTarget?: RemoteExecutionHandle abortSignal: AbortSignal /** Resolves a pending approval with the user's decision. */ awaitApproval: (approval: ApprovalRequest) => Promise<'allow' | 'deny'> diff --git a/kun/src/remote/remote-command-risk.ts b/kun/src/remote/remote-command-risk.ts new file mode 100644 index 000000000..3ae4c466f --- /dev/null +++ b/kun/src/remote/remote-command-risk.ts @@ -0,0 +1,98 @@ +/** + * Remote command risk classification (Issue #647, safety guard). + * + * Pure, dependency-free heuristics that categorize a remote shell command by + * the kind of change it makes and how reversible it is. The run-mode policy + * (remote-run-mode.ts) and the approval card consume this to decide allow / + * confirm / deny. Classification is conservative: ambiguous commands fall back + * to a safe category rather than being treated as harmless. + */ + +export type RemoteCommandCategory = + | 'read-only' + | 'project-write' + | 'dependency-install' + | 'test-run' + | 'network-write' + | 'service-control' + | 'filesystem-destructive' + | 'db-migration' + | 'network-security' + | 'container-destructive' + | 'k8s-mutation' + | 'secrets' + | 'privilege-escalation' + | 'standard' + +export type RemoteRiskLevel = 'low' | 'medium' | 'high' | 'critical' + +export type RemoteCommandClassification = { + category: RemoteCommandCategory + level: RemoteRiskLevel + /** True when the command mutates remote state (never auto-replayed on reconnect). */ + writes: boolean + /** Human-readable reason the category was assigned. */ + reason: string +} + +type Rule = { + category: RemoteCommandCategory + level: RemoteRiskLevel + writes: boolean + reason: string + test: RegExp +} + +// Ordered by precedence: the first matching rule wins, so the most dangerous +// patterns are listed first. Patterns match the raw command string. +const RULES: readonly Rule[] = [ + { category: 'privilege-escalation', level: 'critical', writes: true, reason: 'runs with elevated privileges (sudo/su/doas)', test: /(^|[;&|]\s*)(sudo|su|doas)\b/ }, + { category: 'secrets', level: 'critical', writes: true, reason: 'reads or modifies secrets / credentials / env files', test: /(\.env\b|id_rsa|id_ed25519|\/etc\/shadow|\/etc\/passwd|\bsecrets?\b|\bcredentials?\b|\.pem\b)/i }, + { category: 'network-security', level: 'critical', writes: true, reason: 'changes firewall or SSH configuration', test: /\b(iptables|nft|ufw|firewall-cmd|sshd_config|authorized_keys)\b/i }, + { category: 'db-migration', level: 'high', writes: true, reason: 'runs a database migration', test: /\b(migrat\w*|alembic|flyway|liquibase|prisma\s+migrate|knex\s+migrate|rails\s+db:migrate)\b/i }, + { category: 'k8s-mutation', level: 'high', writes: true, reason: 'mutates Kubernetes resources', test: /\bkubectl\s+(apply|delete|replace|patch|scale|drain|cordon)\b/i }, + { category: 'container-destructive', level: 'high', writes: true, reason: 'destroys container volumes or images', test: /\bdocker\b.*\b(volume\s+rm|system\s+prune|rmi|rm\s+-\w*f)\b|\bdocker-compose\s+down\b.*-v/i }, + { category: 'filesystem-destructive', level: 'high', writes: true, reason: 'irreversibly deletes or repermissions files', test: /(^|[;&|]\s*)(rm\s+-\w*[rf]|rmdir|chmod|chown|mkfs|dd\s+if=|truncate)\b/ }, + { category: 'service-control', level: 'high', writes: true, reason: 'restarts or stops a system service', test: /\b(systemctl\s+(restart|stop|disable|mask)|service\s+\S+\s+(restart|stop)|kill(all)?\b|pm2\s+(restart|stop|delete))\b/i }, + // curl/wget with a mutating method, body, or upload is a side-effecting + // network call (e.g. `curl -X DELETE`), NOT a read. Listed before the + // read-only rule so it is never auto-allowed as a harmless fetch. A pipe to a + // shell (`curl ... | sh`) is treated the same way. + { category: 'network-write', level: 'high', writes: true, reason: 'network request with a mutating method, body, upload, or pipe-to-shell', test: /\b(curl|wget)\b[^\n]*(-X\s*(POST|PUT|DELETE|PATCH)|--request\s+(POST|PUT|DELETE|PATCH)|\s-d\b|--data\b|\s-T\b|--upload-file\b|--upload\b|\|\s*(sudo\s+)?(ba)?sh\b)/i }, + { category: 'dependency-install', level: 'medium', writes: true, reason: 'installs or changes dependencies', test: /\b(npm|pnpm|yarn|bun)\s+(i|install|add|remove|ci)\b|\bpip\s+install\b|\bapt(-get)?\s+install\b|\bbrew\s+install\b/i }, + { category: 'test-run', level: 'low', writes: true, reason: 'runs tests (may touch a database or external systems, so not auto-replayed)', test: /\b(npm|pnpm|yarn|bun)\s+(test|run\s+test)\b|\bvitest\b|\bjest\b|\bpytest\b|\bgo\s+test\b/i }, + { category: 'project-write', level: 'medium', writes: true, reason: 'writes or moves project files', test: /(^|[;&|]\s*)(tee|cp|mv|mkdir|touch|sed\s+-i|git\s+(commit|reset|checkout|clean|push))\b|>\s*\S/ }, + { category: 'read-only', level: 'low', writes: false, reason: 'reads files, logs, or status', test: /^(\s*)(cat|less|tail|head|grep|rg|ls|find|pwd|uname|stat|git\s+(status|log|diff|show|rev-parse|branch)|systemctl\s+status|journalctl|docker\s+(ps|logs|inspect)|kubectl\s+(get|describe|logs)|curl|wget|echo|df|free|ps|top|env|printenv|which|command\s+-v)\b/ } +] + +export function classifyRemoteCommand(command: string): RemoteCommandClassification { + const trimmed = command.trim() + for (const rule of RULES) { + if (rule.test.test(trimmed)) { + return { category: rule.category, level: rule.level, writes: rule.writes, reason: rule.reason } + } + } + // Unknown command: assume it may mutate state (medium) so the guard errs safe. + return { + category: 'standard', + level: 'medium', + writes: true, + reason: 'unrecognized command; treated as potentially state-changing' + } +} + +/** + * Irreversible / high-blast-radius categories that must be confirmed even when + * the user granted full permissions — especially on production targets. + */ +export function isIrreversibleCategory(category: RemoteCommandCategory): boolean { + return ( + category === 'filesystem-destructive' || + category === 'db-migration' || + category === 'network-security' || + category === 'container-destructive' || + category === 'k8s-mutation' || + category === 'secrets' || + category === 'privilege-escalation' + ) +} diff --git a/kun/src/remote/remote-connect.ts b/kun/src/remote/remote-connect.ts new file mode 100644 index 000000000..4a92954bb --- /dev/null +++ b/kun/src/remote/remote-connect.ts @@ -0,0 +1,129 @@ +/** + * Remote connection test + first-entry precheck (Issue #647). + * + * Runs the read-only precheck plan (pwd/uname/git status/git root/tool detection) + * on first entry to a target and parses the raw outputs into an environment + * profile (#8) the agent can rely on without re-probing. Also powers the "Test + * connection" button. The exec function is injected so this is unit-testable + * without a live SSH host. + */ + +import { buildRemotePrecheckPlan } from './remote-target.js' +import type { RemoteTargetDescriptor } from './remote-target.js' +import type { SshExecOutcome } from './ssh-executor.js' + +export type RemoteEnvironmentProfile = { + ok: boolean + cwd?: string + os?: string + branch?: string + dirty?: boolean + repoRoot?: string + tools: Record + error?: string +} + +export type RemotePrecheckExec = (command: string) => Promise + +/** + * Run the read-only precheck and parse it into an environment profile. None of + * the commands mutate the remote, so this is safe to run automatically on first + * entry without an approval prompt. + */ +export async function runRemotePrecheck(input: { + exec: RemotePrecheckExec + remoteDir?: string +}): Promise { + const plan = buildRemotePrecheckPlan(input.remoteDir) + const tools: Record = {} + const profile: RemoteEnvironmentProfile = { ok: true, tools } + for (const step of plan) { + let outcome: SshExecOutcome + try { + outcome = await input.exec(step.command) + } catch (error) { + profile.ok = false + profile.error = error instanceof Error ? error.message : String(error) + return profile + } + if (outcome.exitCode !== 0 && step.id !== 'git-status' && step.id !== 'git-root') { + // pwd/uname/toolcheck failing means the connection or shell is unusable. + if (step.id === 'pwd' || step.id === 'uname') { + profile.ok = false + profile.error = outcome.stderr.trim() || `precheck step '${step.id}' exited ${outcome.exitCode}` + return profile + } + } + applyPrecheckStep(profile, step.id, outcome) + } + return profile +} + +function applyPrecheckStep(profile: RemoteEnvironmentProfile, id: string, outcome: SshExecOutcome): void { + const stdout = outcome.stdout.trim() + switch (id) { + case 'pwd': + if (stdout) profile.cwd = stdout + break + case 'uname': + if (stdout) profile.os = stdout + break + case 'git-status': { + if (outcome.exitCode !== 0) break + const { branch, dirty } = parseGitStatus(stdout) + if (branch) profile.branch = branch + profile.dirty = dirty + break + } + case 'git-root': + if (outcome.exitCode === 0 && stdout) profile.repoRoot = stdout + break + case 'toolcheck': + for (const line of stdout.split(/\r?\n/)) { + const match = line.match(/^(\w[\w-]*)=(yes|no)$/) + if (match) profile.tools[match[1]] = match[2] === 'yes' + } + break + } +} + +/** Parse `git status --porcelain=v1 -b` output for branch + dirty state. */ +export function parseGitStatus(output: string): { branch?: string; dirty: boolean } { + const lines = output.split(/\r?\n/).filter((line) => line.length > 0) + let branch: string | undefined + let dirty = false + for (const line of lines) { + if (line.startsWith('## ')) { + // `## main...origin/main [ahead 1]` → take the local branch up to `...`. + const rest = line.slice(3) + branch = rest.split(/\.\.\.|\s/)[0] + } else { + dirty = true + } + } + return { ...(branch ? { branch } : {}), dirty } +} + +/** Build a descriptor from a precheck profile for the thread header / UI. */ +export function descriptorFromPrecheck(input: { + alias: string + remoteDir?: string + host?: string + profile: RemoteEnvironmentProfile + latencyMs?: number +}): RemoteTargetDescriptor { + return { + target: { + kind: 'ssh', + alias: input.alias, + ...(input.host ? { host: input.host } : {}), + ...(input.remoteDir ? { remoteDir: input.remoteDir } : {}) + }, + status: input.profile.ok ? 'connected' : 'error', + ...(input.latencyMs !== undefined ? { latencyMs: input.latencyMs } : {}), + ...(input.profile.os ? { os: input.profile.os } : {}), + ...(input.profile.branch ? { branch: input.profile.branch } : {}), + ...(input.profile.dirty !== undefined ? { dirty: input.profile.dirty } : {}), + ...(input.profile.error ? { lastError: input.profile.error } : {}) + } +} diff --git a/kun/src/remote/remote-connection.test.ts b/kun/src/remote/remote-connection.test.ts new file mode 100644 index 000000000..be8b0711c --- /dev/null +++ b/kun/src/remote/remote-connection.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteConnection } from './remote-connection.js' +import type { SshExecOutcome } from './ssh-executor.js' + +function outcome(overrides: Partial): SshExecOutcome { + return { + alias: 'prod', + command: 'true', + stdout: '', + stderr: '', + exitCode: 0, + signal: null, + durationMs: 5, + timedOut: false, + ...overrides + } +} + +describe('RemoteConnection', () => { + it('coalesces concurrent probes into a single underlying check', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 12 })) + const conn = new RemoteConnection({ executor: { exec: vi.fn(), probe } }) + const [a, b] = await Promise.all([conn.probe(), conn.probe()]) + expect(probe).toHaveBeenCalledTimes(1) + expect(a.ok).toBe(true) + expect(b.ok).toBe(true) + expect(conn.status()).toBe('connected') + expect(conn.latencyMs()).toBe(12) + }) + + it('moves from connected to degraded when a probe fails', async () => { + const results = [{ ok: true, latencyMs: 1 }, { ok: false, error: 'no route to host' }] + const probe = vi.fn(async () => results.shift()!) + const conn = new RemoteConnection({ executor: { exec: vi.fn(), probe } }) + await conn.probe() + expect(conn.status()).toBe('connected') + await conn.probe() + expect(conn.status()).toBe('degraded') + expect(conn.lastError()).toBe('no route to host') + }) + + it('reports statusUnknown for a mutating command when the transport drops mid-flight', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + const exec = vi.fn(async () => outcome({ exitCode: 255, stderr: 'client_loop: connection reset by peer' })) + const conn = new RemoteConnection({ executor: { exec, probe } }) + await conn.probe() + const result = await conn.run('systemctl restart api', { writes: true }) + expect(result.statusUnknown).toBe(true) + expect(result.status).toBe('degraded') + }) + + it('does NOT mark a read-only command as statusUnknown on a drop (safe to retry)', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + const exec = vi.fn(async () => outcome({ exitCode: 255, stderr: 'Connection timed out', timedOut: true })) + const conn = new RemoteConnection({ executor: { exec, probe } }) + await conn.probe() + const result = await conn.run('cat /var/log/app.log', { writes: false }) + expect(result.statusUnknown).toBe(false) + expect(result.status).toBe('degraded') + }) + + it('returns a successful outcome and stays connected on a normal exit', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + const exec = vi.fn(async () => outcome({ stdout: 'hello', exitCode: 0 })) + const conn = new RemoteConnection({ executor: { exec, probe } }) + const result = await conn.run('echo hello', { writes: false }) + expect(result.statusUnknown).toBe(false) + expect(result.status).toBe('connected') + expect(result.outcome?.stdout).toBe('hello') + }) + + it('lazily probes before the first command when disconnected', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + const exec = vi.fn(async () => outcome({ stdout: 'ok' })) + const conn = new RemoteConnection({ executor: { exec, probe } }) + expect(conn.status()).toBe('disconnected') + await conn.run('pwd', { writes: false }) + expect(probe).toHaveBeenCalledTimes(1) + expect(exec).toHaveBeenCalledTimes(1) + }) + + it('does not run the command when the target cannot be reached', async () => { + const probe = vi.fn(async () => ({ ok: false, error: 'auth failed' })) + const exec = vi.fn() + const conn = new RemoteConnection({ executor: { exec, probe } }) + const result = await conn.run('pwd', { writes: false }) + expect(exec).not.toHaveBeenCalled() + expect(result.status).toBe('error') + expect(result.error).toBe('auth failed') + }) + + it('actually retries a read-only command after a reconnect and succeeds', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + let call = 0 + const exec = vi.fn(async () => { + call += 1 + return call === 1 + ? outcome({ exitCode: 255, stderr: 'client_loop: connection reset by peer' }) + : outcome({ stdout: 'recovered', exitCode: 0 }) + }) + const conn = new RemoteConnection({ executor: { exec, probe } }) + await conn.probe() + const result = await conn.run('cat /var/log/app.log', { writes: false }) + expect(exec).toHaveBeenCalledTimes(2) + expect(result.status).toBe('connected') + expect(result.statusUnknown).toBe(false) + expect(result.outcome?.stdout).toBe('recovered') + }) + + it('does not retry a mutating command after a drop (no replay)', async () => { + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })) + const exec = vi.fn(async () => outcome({ exitCode: 255, stderr: 'broken pipe' })) + const conn = new RemoteConnection({ executor: { exec, probe } }) + await conn.probe() + const result = await conn.run('systemctl restart api', { writes: true }) + expect(exec).toHaveBeenCalledTimes(1) + expect(result.statusUnknown).toBe(true) + }) +}) diff --git a/kun/src/remote/remote-connection.ts b/kun/src/remote/remote-connection.ts new file mode 100644 index 000000000..6b48a7a11 --- /dev/null +++ b/kun/src/remote/remote-connection.ts @@ -0,0 +1,189 @@ +/** + * Remote connection state machine (Issue #647, #9 "status unknown"). + * + * Wraps an {@link SshExecutor} with a small lifecycle: disconnected → connecting + * → connected → degraded (after a drop) → disconnected. A single-flight probe + * coalesces concurrent liveness checks. The critical reliability rule lives + * here: when a connection drops mid-command, a READ-ONLY command may be + * transparently retried after reconnect, but a MUTATING command is reported as + * `statusUnknown` and NEVER auto-replayed, because we cannot confirm whether the + * remote already executed it (a duplicate deploy/delete/email is worse than a + * surfaced "unknown"). + */ + +import type { SshExecOutcome, SshExecutor } from './ssh-executor.js' +import type { RemoteConnectionStatus } from './remote-target.js' + +export type RemoteProbeResult = { + ok: boolean + latencyMs?: number + error?: string +} + +export type RemoteRunResult = { + outcome?: SshExecOutcome + status: RemoteConnectionStatus + /** True when the connection dropped before the result was confirmed. */ + statusUnknown: boolean + /** Set when the command failed without a confirmed remote result. */ + error?: string +} + +export type RemoteConnectionDeps = { + executor: Pick + nowMs?: () => number +} + +/** SSH exit code that means the transport itself failed (auth/host/network). */ +const SSH_TRANSPORT_EXIT_CODE = 255 + +function looksLikeTransportDrop(outcome: SshExecOutcome): boolean { + if (outcome.timedOut) return true + if (outcome.exitCode === SSH_TRANSPORT_EXIT_CODE) { + const stderr = outcome.stderr.toLowerCase() + return ( + stderr.includes('connection') || + stderr.includes('broken pipe') || + stderr.includes('timed out') || + stderr.includes('route to host') || + stderr.includes('reset by peer') || + stderr.includes('connection closed') || + stderr.includes('lost connection') || + stderr === '' // bare 255 with no body is almost always a transport failure + ) + } + return false +} + +export class RemoteConnection { + private _status: RemoteConnectionStatus = 'disconnected' + private _latencyMs?: number + private _lastError?: string + private probePromise?: Promise + private readonly executor: Pick + + constructor(private readonly deps: RemoteConnectionDeps) { + this.executor = deps.executor + } + + status(): RemoteConnectionStatus { + return this._status + } + + latencyMs(): number | undefined { + return this._latencyMs + } + + lastError(): string | undefined { + return this._lastError + } + + /** + * Single-flight liveness probe. Concurrent callers share one underlying + * `ssh true`. Moves the status to connected on success; degraded/disconnected + * on failure depending on the prior state. + */ + async probe(timeoutMs = 8_000): Promise { + if (this.probePromise) return this.probePromise + const previous = this._status + if (previous === 'disconnected' || previous === 'error') { + this._status = 'connecting' + } + this.probePromise = (async () => { + try { + const result = await this.executor.probe(timeoutMs) + if (result.ok) { + this._status = 'connected' + this._latencyMs = result.latencyMs + this._lastError = undefined + } else { + this._status = previous === 'connected' ? 'degraded' : 'error' + this._lastError = result.error + } + return result + } catch (error) { + this._status = previous === 'connected' ? 'degraded' : 'error' + this._lastError = error instanceof Error ? error.message : String(error) + return { ok: false, error: this._lastError } + } finally { + this.probePromise = undefined + } + })() + return this.probePromise + } + + /** + * Run a command, classifying any transport drop. `writes` decides the + * post-drop behavior: read-only commands may be retried by the caller after a + * successful reconnect; mutating commands are surfaced as `statusUnknown`. + */ + async run( + command: string, + options: { writes: boolean; timeoutMs?: number; signal?: AbortSignal; input?: string | Buffer } + ): Promise { + // Re-establish before running when not actively connected. `degraded` is + // included: after a prior drop we must re-probe, not blindly exec. + if (this._status !== 'connected') { + await this.probe(options.timeoutMs) + const status = this.status() + if (status !== 'connected') { + return { status, statusUnknown: false, ...(this._lastError ? { error: this._lastError } : {}) } + } + } + const first = await this.execOnce(command, options) + if (!first.dropped) { + this._status = 'connected' + this._latencyMs = first.outcome?.durationMs ?? this._latencyMs + this._lastError = undefined + return { ...(first.outcome ? { outcome: first.outcome } : {}), status: 'connected', statusUnknown: false } + } + // Transport drop. A read-only command is safe to transparently retry once + // after a successful reconnect; a mutating command must NOT be replayed + // because the remote may already have run it. + this._status = 'degraded' + this._lastError = first.error + if (options.writes) { + return { ...(first.outcome ? { outcome: first.outcome } : {}), status: 'degraded', statusUnknown: true, ...(first.error ? { error: first.error } : {}) } + } + const probe = await this.probe(options.timeoutMs) + if (!probe.ok) { + return { ...(first.outcome ? { outcome: first.outcome } : {}), status: this.status(), statusUnknown: false, ...(first.error ? { error: first.error } : {}) } + } + const second = await this.execOnce(command, options) + if (!second.dropped) { + this._status = 'connected' + this._latencyMs = second.outcome?.durationMs ?? this._latencyMs + this._lastError = undefined + return { ...(second.outcome ? { outcome: second.outcome } : {}), status: 'connected', statusUnknown: false } + } + this._status = 'degraded' + this._lastError = second.error + return { ...(second.outcome ? { outcome: second.outcome } : {}), status: 'degraded', statusUnknown: false, ...(second.error ? { error: second.error } : {}) } + } + + /** One exec attempt; classifies a transport drop without mutating status. */ + private async execOnce( + command: string, + options: { timeoutMs?: number; signal?: AbortSignal; input?: string | Buffer } + ): Promise<{ outcome?: SshExecOutcome; dropped: boolean; error?: string }> { + let outcome: SshExecOutcome + try { + outcome = await this.executor.exec(command, { + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.input !== undefined ? { input: options.input } : {}) + }) + } catch (error) { + return { dropped: true, error: error instanceof Error ? error.message : String(error) } + } + if (looksLikeTransportDrop(outcome)) { + return { outcome, dropped: true, error: outcome.stderr.trim() || (outcome.timedOut ? 'command timed out' : 'connection dropped') } + } + return { outcome, dropped: false } + } + + markDisconnected(reason?: string): void { + this._status = 'disconnected' + if (reason) this._lastError = reason + } +} diff --git a/kun/src/remote/remote-execution-handle.ts b/kun/src/remote/remote-execution-handle.ts new file mode 100644 index 000000000..5110d8679 --- /dev/null +++ b/kun/src/remote/remote-execution-handle.ts @@ -0,0 +1,118 @@ +import type { RemoteExecutionHandle, RemoteExecOptions, RemoteExecResult, RemoteFileOperation, RemoteGuardOutcome } from '../ports/remote-execution.js' +import type { ThreadRemoteTarget } from '../contracts/threads.js' +import { evaluateRemoteCommand } from './remote-run-mode.js' +import { classifyRemoteCommand } from './remote-command-risk.js' +import { evaluateRemotePathAccess, type RemoteProfile } from './remote-profile.js' +import { SshExecutor, type SshSpawnFn } from './ssh-executor.js' +import { RemoteConnection } from './remote-connection.js' +import type { RemoteConnectionStatus, RemoteTarget, RemoteTargetDescriptor } from './remote-target.js' + +export type SshRemoteExecutionHandleOptions = { + binding: ThreadRemoteTarget + spawn?: SshSpawnFn +} + +export class SshRemoteExecutionHandle implements RemoteExecutionHandle { + readonly target: RemoteTarget + readonly runMode: ThreadRemoteTarget['runMode'] + readonly production: boolean + private readonly executor: SshExecutor + private readonly connection: RemoteConnection + private readonly profile: Pick + + constructor(options: SshRemoteExecutionHandleOptions) { + const binding = options.binding + this.target = { + kind: 'ssh', + alias: binding.alias, + ...(binding.host ? { host: binding.host } : {}), + ...(binding.remoteDir ? { remoteDir: binding.remoteDir } : {}) + } + this.runMode = binding.runMode + this.production = binding.production + this.profile = { protectedPaths: binding.protectedPaths } + this.executor = new SshExecutor({ + alias: binding.alias, + ...(binding.remoteDir ? { remoteDir: binding.remoteDir } : {}), + ...(options.spawn ? { spawn: options.spawn } : {}) + }) + this.connection = new RemoteConnection({ executor: this.executor }) + } + + status(): RemoteConnectionStatus { + return this.connection.status() + } + + describe(): RemoteTargetDescriptor { + return { + target: this.target, + status: this.connection.status(), + ...(this.connection.latencyMs() !== undefined ? { latencyMs: this.connection.latencyMs() } : {}), + ...(this.connection.lastError() ? { lastError: this.connection.lastError() } : {}) + } + } + + guardCommand(command: string): RemoteGuardOutcome { + const evaluation = evaluateRemoteCommand({ command, mode: this.runMode, production: this.production }) + return { decision: evaluation.decision, reasons: evaluation.reasons } + } + + guardPath(input: { capability: 'read' | 'write'; path: string }): RemoteGuardOutcome { + const decision = evaluateRemotePathAccess({ + capability: input.capability, + path: input.path, + profile: this.profile, + production: this.production + }) + return { decision: decision.decision, reasons: [decision.reason] } + } + + guardFile(input: { operation: RemoteFileOperation; path: string; recursive?: boolean }): RemoteGuardOutcome { + const mutates = input.operation === 'create' || input.operation === 'write' || input.operation === 'edit' || input.operation === 'delete' + if (mutates && this.runMode === 'observe') { + return { decision: 'deny', reasons: ['observe mode allows read-only remote file operations only'] } + } + const decision = evaluateRemotePathAccess({ + capability: mutates ? 'write' : 'read', + path: input.path, + profile: this.profile, + production: this.production, + recursive: input.recursive + }) + return { decision: decision.decision, reasons: [decision.reason] } + } + + async exec(command: string, options: RemoteExecOptions = {}): Promise { + const classification = classifyRemoteCommand(command) + const result = await this.connection.run(command, { + writes: classification.writes, + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.input !== undefined ? { input: options.input } : {}) + }) + if (!result.outcome) { + return { + command, + stdout: '', + stderr: result.error ?? '', + exitCode: null, + signal: null, + durationMs: 0, + timedOut: false, + statusUnknown: result.statusUnknown + } + } + return { + command, + stdout: result.outcome.stdout, + stderr: result.outcome.stderr, + exitCode: result.outcome.exitCode, + signal: result.outcome.signal, + durationMs: result.outcome.durationMs, + timedOut: result.outcome.timedOut, + ...(result.outcome.aborted ? { aborted: true } : {}), + ...(result.statusUnknown ? { statusUnknown: true } : {}), + ...(result.outcome.truncated ? { truncated: true } : {}) + } + } +} diff --git a/kun/src/remote/remote-hosts-service.test.ts b/kun/src/remote/remote-hosts-service.test.ts new file mode 100644 index 000000000..b4e596c4b --- /dev/null +++ b/kun/src/remote/remote-hosts-service.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRemoteHostsService } from './remote-hosts-service.js' +import type { SshChildProcess } from './ssh-executor.js' + +const SSH_CONFIG = ` +Host prod-api + HostName 10.0.0.5 + User deploy + Port 2222 + +Host staging-* + User ci +` + +function scriptedChild(stdout: string, exitCode = 0): SshChildProcess { + return { + stdout: { on: (_e: string, l: (c: Buffer) => void) => setTimeout(() => l(Buffer.from(stdout)), 0) }, + stderr: { on: () => undefined }, + on(event: string, listener: (...a: unknown[]) => void) { + if (event === 'close') setTimeout(() => listener(exitCode, null), 1) + }, + kill: vi.fn() + } as unknown as SshChildProcess +} + +describe('remote-hosts-service', () => { + it('lists concrete ssh aliases (no wildcards)', async () => { + const service = createRemoteHostsService({ readSshConfig: async () => SSH_CONFIG }) + const result = await service.listHosts() + expect(result.configFound).toBe(true) + expect(result.hosts.map((h) => h.alias)).toEqual(['prod-api']) + expect(result.hosts[0]).toMatchObject({ hostName: '10.0.0.5', user: 'deploy', port: 2222 }) + }) + + it('reports configFound:false when there is no config', async () => { + const service = createRemoteHostsService({ readSshConfig: async () => null }) + const result = await service.listHosts() + expect(result).toEqual({ hosts: [], configFound: false }) + }) + + it('tests a connection by probing then running the precheck', async () => { + const responses = ['', '/srv/api', 'Linux prod', '## main...origin/main', '/srv/api', 'rg=yes\ngit=yes'] + let i = 0 + const spawn = vi.fn(() => scriptedChild(responses[i++] ?? '')) + const service = createRemoteHostsService({ readSshConfig: async () => SSH_CONFIG, spawn }) + const result = await service.testConnection({ alias: 'prod-api', remoteDir: '/srv/api' }) + expect(result.ok).toBe(true) + expect(result.status).toBe('connected') + expect(result.os).toBe('Linux prod') + expect(result.branch).toBe('main') + }) + + it('returns an error status when the probe fails', async () => { + const spawn = vi.fn(() => scriptedChild('', 255)) + const service = createRemoteHostsService({ readSshConfig: async () => SSH_CONFIG, spawn }) + const result = await service.testConnection({ alias: 'prod-api' }) + expect(result.ok).toBe(false) + expect(result.status).toBe('error') + }) + + it('lists valid profiles and skips secret-bearing ones', async () => { + const service = createRemoteHostsService({ + profiles: [ + { name: 'Prod', host: 'prod-api', workspace: '/srv/api', mode: 'operations', production: true, protectedPaths: ['.env'] }, + { name: 'Bad', host: 'h', workspace: '/a', password: 'hunter2' } + ] + }) + const profiles = await service.listProfiles() + expect(profiles).toHaveLength(1) + expect(profiles[0]).toMatchObject({ name: 'Prod', mode: 'operations', production: true }) + }) +}) diff --git a/kun/src/remote/remote-hosts-service.ts b/kun/src/remote/remote-hosts-service.ts new file mode 100644 index 000000000..b05940ced --- /dev/null +++ b/kun/src/remote/remote-hosts-service.ts @@ -0,0 +1,148 @@ +/** + * Remote hosts service (Issue #647) — the adapter behind the remote HTTP API. + * + * Reads the user's ~/.ssh/config to list selectable host aliases (never asking + * for IP/port/key again), runs the read-only precheck to test a connection, and + * exposes shareable secret-free Remote Profiles. The filesystem read and the + * SSH executor are injected so this is unit-testable without a real config file + * or SSH host. + */ + +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { parseSshConfig } from './ssh-config.js' +import { SshExecutor, type SshSpawnFn } from './ssh-executor.js' +import { runRemotePrecheck } from './remote-connect.js' +import { parseRemoteProfile } from './remote-profile.js' +import type { + ListRemoteHostsResponse, + RemoteConnectionTestResponse, + RemoteProfileSummary +} from '../contracts/remote.js' + +export type RemoteHostsServiceDeps = { + /** Reads the ssh config text; defaults to reading ~/.ssh/config. */ + readSshConfig?: () => Promise + /** Injectable spawn for the connection test; defaults to system ssh. */ + spawn?: SshSpawnFn + /** Raw profile definitions (e.g. from project/team config). */ + profiles?: unknown[] + /** Reads user-owned profiles; defaults to ~/.kun/remote-profiles.json. */ + readProfiles?: () => Promise + connectTimeoutSec?: number +} + +export type RemoteHostsService = { + listHosts(): Promise + testConnection(input: { alias: string; remoteDir?: string }): Promise + listProfiles(): Promise + resolveProfile(alias: string): Promise +} + +async function defaultReadSshConfig(): Promise { + try { + return await readFile(join(homedir(), '.ssh', 'config'), 'utf8') + } catch { + return null + } +} + +async function defaultReadProfiles(): Promise { + try { + const parsed = JSON.parse(await readFile(join(homedir(), '.kun', 'remote-profiles.json'), 'utf8')) as unknown + return Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === 'object' && 'profiles' in parsed && Array.isArray((parsed as { profiles?: unknown }).profiles) + ? (parsed as { profiles: unknown[] }).profiles + : [] + } catch { + return [] + } +} + +export function createRemoteHostsService(deps: RemoteHostsServiceDeps = {}): RemoteHostsService { + const readSshConfig = deps.readSshConfig ?? defaultReadSshConfig + const readProfiles = deps.readProfiles ?? defaultReadProfiles + const parsedProfiles = async (): Promise => { + const summaries: RemoteProfileSummary[] = [] + for (const entry of deps.profiles ?? await readProfiles()) { + try { + const profile = parseRemoteProfile(entry) + summaries.push({ + name: profile.name, + host: profile.host, + workspace: profile.workspace, + mode: profile.mode, + production: profile.production, + ...(profile.healthCheck ? { healthCheck: profile.healthCheck } : {}), + ...(profile.testCommand ? { testCommand: profile.testCommand } : {}), + protectedPaths: profile.protectedPaths + }) + } catch { + // Skip malformed/secret-bearing profiles rather than leaking them. + } + } + return summaries + } + + return { + async listHosts() { + const text = await readSshConfig() + if (text === null) return { hosts: [], configFound: false } + const hosts = parseSshConfig(text).map((host) => ({ + alias: host.alias, + ...(host.hostName ? { hostName: host.hostName } : {}), + ...(host.user ? { user: host.user } : {}), + ...(host.port ? { port: host.port } : {}), + ...(host.proxyJump ? { proxyJump: host.proxyJump } : {}) + })) + return { hosts, configFound: true } + }, + + async testConnection(input) { + const executor = new SshExecutor({ + alias: input.alias, + ...(input.remoteDir ? { remoteDir: input.remoteDir } : {}), + ...(deps.spawn ? { spawn: deps.spawn } : {}), + ...(deps.connectTimeoutSec ? { connectTimeoutSec: deps.connectTimeoutSec } : {}) + }) + const probe = await executor.probe() + if (!probe.ok) { + return { + ok: false, + alias: input.alias, + ...(input.remoteDir ? { remoteDir: input.remoteDir } : {}), + status: 'error', + tools: {}, + ...(probe.error ? { error: probe.error } : {}) + } + } + const profile = await runRemotePrecheck({ + exec: (command) => executor.exec(command), + ...(input.remoteDir ? { remoteDir: input.remoteDir } : {}) + }) + return { + ok: profile.ok, + alias: input.alias, + ...(input.remoteDir ? { remoteDir: input.remoteDir } : {}), + status: profile.ok ? 'connected' : 'error', + ...(probe.latencyMs !== undefined ? { latencyMs: probe.latencyMs } : {}), + ...(profile.os ? { os: profile.os } : {}), + ...(profile.branch ? { branch: profile.branch } : {}), + ...(profile.dirty !== undefined ? { dirty: profile.dirty } : {}), + ...(profile.repoRoot ? { repoRoot: profile.repoRoot } : {}), + tools: profile.tools, + ...(profile.error ? { error: profile.error } : {}) + } + }, + + async listProfiles() { + return await parsedProfiles() + }, + + async resolveProfile(alias) { + return (await parsedProfiles()).find((profile) => profile.host === alias) + } + } +} diff --git a/kun/src/remote/remote-port-forward-manager.test.ts b/kun/src/remote/remote-port-forward-manager.test.ts new file mode 100644 index 000000000..488ea11f6 --- /dev/null +++ b/kun/src/remote/remote-port-forward-manager.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemotePortForwardManager } from './remote-port-forward-manager.js' +import type { SshChildProcess } from './ssh-executor.js' + +function fakeChild() { + const handlers: Record void)[]> = {} + const child = { + stdout: null, + stderr: null, + on(event: string, listener: (...a: unknown[]) => void) { + ;(handlers[event] ??= []).push(listener as never) + }, + kill: vi.fn() + } as unknown as SshChildProcess + return { child, emit: (e: string, ...a: unknown[]) => handlers[e]?.forEach((h) => h(...a)) } +} + +describe('RemotePortForwardManager', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('opens a tunnel on an allocated local port and returns a preview url', async () => { + const { child } = fakeChild() + const spawn = vi.fn(() => child) + const manager = new RemotePortForwardManager({ spawn, allocatePort: async () => 54321 }) + const forward = await manager.open({ alias: 'prod', remotePort: 3000 }) + expect(forward.localPort).toBe(54321) + expect(forward.url).toBe('http://127.0.0.1:54321') + expect(spawn).toHaveBeenCalledWith('ssh', expect.arrayContaining(['-N', '-T', '-L'])) + expect(manager.list()).toHaveLength(1) + }) + + it('tears down and throws when the tunnel never becomes ready', async () => { + const { child } = fakeChild() + let now = 0 + const manager = new RemotePortForwardManager({ + spawn: () => child, + allocatePort: async () => 5000, + probeReady: async () => false, + delay: async () => { now += 100 }, + nowMs: () => now + }) + await expect(manager.open({ alias: 'prod', remotePort: 3000, waitForReadyMs: 200 })).rejects.toThrow(/did not become ready/) + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(manager.list()).toHaveLength(0) + }) + + it('drops a tunnel from the registry when its child exits on its own', async () => { + const { child, emit } = fakeChild() + const manager = new RemotePortForwardManager({ spawn: () => child, allocatePort: async () => 5000 }) + await manager.open({ alias: 'prod', remotePort: 8080 }) + expect(manager.list()).toHaveLength(1) + emit('close', 0, null) + expect(manager.list()).toHaveLength(0) + }) + + it('escalates from SIGTERM to SIGKILL when a tunnel does not exit', async () => { + vi.useFakeTimers() + const { child } = fakeChild() + const manager = new RemotePortForwardManager({ spawn: () => child, allocatePort: async () => 5000 }) + const forward = await manager.open({ alias: 'prod', remotePort: 8080 }) + manager.close(forward.id) + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + await vi.advanceTimersByTimeAsync(2_000) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/kun/src/remote/remote-port-forward-manager.ts b/kun/src/remote/remote-port-forward-manager.ts new file mode 100644 index 000000000..8fc3c9100 --- /dev/null +++ b/kun/src/remote/remote-port-forward-manager.ts @@ -0,0 +1,128 @@ +import { buildSshPortForwardArgv, type SshPortForwardOptions } from './ssh-port-forward.js' +import type { SshChildProcess, SshSpawnFn } from './ssh-executor.js' + +export type PortForward = { + id: string + localPort: number + remotePort: number + remoteHost: string + alias: string + url: string +} + +export type AllocatePortFn = () => Promise + +export type RemotePortForwardManagerDeps = { + spawn: SshSpawnFn + allocatePort: AllocatePortFn + nowMs?: () => number + probeReady?: (port: number) => Promise + delay?: (ms: number) => Promise +} + +type ActiveForward = { + forward: PortForward + child: SshChildProcess + hardKillTimer?: ReturnType +} + +const DEFAULT_READY_PROBE_INTERVAL_MS = 150 + +function defaultDelay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (timer && typeof timer === 'object' && 'unref' in timer) { + ;(timer as { unref: () => void }).unref() + } + }) +} + +export class RemotePortForwardManager { + private readonly forwards = new Map() + private counter = 0 + + constructor(private readonly deps: RemotePortForwardManagerDeps) {} + + async open(options: Omit & { localPort?: number; waitForReadyMs?: number }): Promise { + const localPort = options.localPort ?? (await this.deps.allocatePort()) + const argv = buildSshPortForwardArgv({ ...options, localPort }) + const child = this.deps.spawn('ssh', argv) + const id = 'pf_' + String(++this.counter) + const remoteHost = options.remoteHost ?? '127.0.0.1' + const forward: PortForward = { + id, + localPort, + remotePort: options.remotePort, + remoteHost, + alias: options.alias, + url: 'http://127.0.0.1:' + String(localPort) + } + const active: ActiveForward = { forward, child } + child.on('close', () => { + if (active.hardKillTimer) clearTimeout(active.hardKillTimer) + this.forwards.delete(id) + }) + child.on('error', () => { + if (active.hardKillTimer) clearTimeout(active.hardKillTimer) + this.forwards.delete(id) + }) + this.forwards.set(id, active) + + if (options.waitForReadyMs && options.waitForReadyMs > 0 && this.deps.probeReady) { + await this.waitForReady(id, localPort, options.waitForReadyMs) + } + return forward + } + + list(): PortForward[] { + return [...this.forwards.values()].map((entry) => entry.forward) + } + + close(id: string): boolean { + const entry = this.forwards.get(id) + if (!entry) return false + this.terminate(entry) + this.forwards.delete(id) + return true + } + + closeAll(): void { + for (const entry of this.forwards.values()) { + this.terminate(entry) + } + this.forwards.clear() + } + + private async waitForReady(id: string, port: number, timeoutMs: number): Promise { + const now = this.deps.nowMs ?? (() => Date.now()) + const delay = this.deps.delay ?? defaultDelay + const probe = this.deps.probeReady! + const deadline = now() + timeoutMs + for (;;) { + if (!this.forwards.has(id)) { + throw new Error('SSH tunnel exited before it became ready') + } + if (await probe(port)) return + if (now() >= deadline) { + this.close(id) + throw new Error('SSH tunnel did not become ready within ' + String(timeoutMs) + 'ms') + } + await delay(DEFAULT_READY_PROBE_INTERVAL_MS) + } + } + + private terminate(entry: ActiveForward): void { + entry.child.kill('SIGTERM') + if (entry.hardKillTimer) return + entry.hardKillTimer = setTimeout(() => { + try { + entry.child.kill('SIGKILL') + } catch { + // already exited + } + }, 2_000) + if (typeof entry.hardKillTimer === 'object' && 'unref' in entry.hardKillTimer) { + ;(entry.hardKillTimer as { unref: () => void }).unref() + } + } +} diff --git a/kun/src/remote/remote-profile.test.ts b/kun/src/remote/remote-profile.test.ts new file mode 100644 index 000000000..4a18fe798 --- /dev/null +++ b/kun/src/remote/remote-profile.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { evaluateRemotePathAccess, isProtectedPath, normalizeRemotePath, parseRemoteProfile } from './remote-profile.js' + +describe('remote-profile', () => { + it('parses a valid secret-free profile with defaults', () => { + const profile = parseRemoteProfile({ + name: 'Production API', + host: 'prod-api', + workspace: '/srv/api', + mode: 'operations', + production: true, + healthCheck: 'http://127.0.0.1:8080/health', + testCommand: 'npm test', + protectedPaths: ['.env', '/etc'] + }) + expect(profile).toMatchObject({ name: 'Production API', host: 'prod-api', mode: 'operations', production: true }) + }) + + it('defaults mode to observe and production to false', () => { + const profile = parseRemoteProfile({ name: 'Dev', host: 'dev', workspace: '/app' }) + expect(profile.mode).toBe('observe') + expect(profile.production).toBe(false) + expect(profile.protectedPaths).toEqual([]) + }) + + it('rejects profiles that carry secrets', () => { + expect(() => parseRemoteProfile({ name: 'x', host: 'h', workspace: '/a', password: 'hunter2' })).toThrow(/secret/i) + expect(() => parseRemoteProfile({ name: 'x', host: 'h', workspace: '/a', privateKey: '...' })).toThrow(/secret/i) + expect(() => parseRemoteProfile({ name: 'x', host: 'h', workspace: '/a', api_key: 'k' })).toThrow(/secret/i) + }) + + it('rejects unknown keys via strict schema', () => { + expect(() => parseRemoteProfile({ name: 'x', host: 'h', workspace: '/a', extra: 1 })).toThrow() + }) + + it('matches protected paths by exact and prefix', () => { + const profile = { protectedPaths: ['.env', '/etc'] } + expect(isProtectedPath(profile, '/etc/shadow')).toBe(true) + expect(isProtectedPath(profile, '/srv/app/.env')).toBe(true) + expect(isProtectedPath(profile, '/srv/app/src/index.ts')).toBe(false) + }) + + it('normalizes paths and blocks `..` traversal bypass', () => { + expect(normalizeRemotePath('/srv/app/../../etc/shadow')).toBe('/etc/shadow') + const profile = { protectedPaths: ['/etc'] } + // A traversal that resolves into /etc must be caught. + expect(isProtectedPath(profile, '/srv/app/../../etc/shadow')).toBe(true) + // A path that only superficially contains "etc" is not protected. + expect(isProtectedPath(profile, '/srv/etcd/data')).toBe(false) + }) + + it('evaluates capability + path access (target + capability + path)', () => { + const profile = { protectedPaths: ['.env', '/etc'] } + expect(evaluateRemotePathAccess({ capability: 'read', path: '/srv/app/src/x.ts', profile }).decision).toBe('allow') + expect(evaluateRemotePathAccess({ capability: 'read', path: '/srv/app/.env', profile }).decision).toBe('confirm') + expect(evaluateRemotePathAccess({ capability: 'write', path: '/etc/hosts', profile }).decision).toBe('confirm') + // Writing a protected path on production is denied outright. + expect(evaluateRemotePathAccess({ capability: 'write', path: '/etc/hosts', profile, production: true }).decision).toBe('deny') + }) +}) diff --git a/kun/src/remote/remote-profile.ts b/kun/src/remote/remote-profile.ts new file mode 100644 index 000000000..fac66a0c1 --- /dev/null +++ b/kun/src/remote/remote-profile.ts @@ -0,0 +1,129 @@ +/** + * Remote Profile (Issue #647, #10). + * + * A shareable, SECRET-FREE description of a remote target: which host alias, + * which workspace, the default run mode, how to health-check and test, and + * which paths are protected. Accounts, private keys, passphrases, and tokens + * are intentionally NOT part of this schema — they always stay with the system + * ssh / ssh-agent / OS keychain. Profiles can live in project config and be + * shared with a team. + */ + +import { z } from 'zod' +import { posix } from 'node:path' + +export const RemoteRunModeSchema = z.enum(['observe', 'develop', 'operations', 'deploy']) + +export const RemoteProfileSchema = z + .object({ + /** Display name, e.g. "Production API". */ + name: z.string().min(1), + /** ~/.ssh/config host alias — never an IP/credential. */ + host: z.string().min(1), + /** Remote working directory ("project root"). */ + workspace: z.string().min(1), + /** Default task run mode for threads created against this profile. */ + mode: RemoteRunModeSchema.default('observe'), + /** True to mark this as a production target (escalates confirmations). */ + production: z.boolean().default(false), + /** Optional health-check URL the verification step probes. */ + healthCheck: z.string().min(1).optional(), + /** Optional command used by the auto-acceptance step. */ + testCommand: z.string().min(1).optional(), + /** Paths the agent must never read or modify without explicit confirmation. */ + protectedPaths: z.array(z.string().min(1)).default([]) + }) + // Reject unknown keys so a secret accidentally added to a shared profile is a + // loud validation error, not a silently persisted credential. + .strict() +export type RemoteProfile = z.infer + +/** Keys that must never appear in a profile (defense-in-depth against leaks). */ +const FORBIDDEN_SECRET_KEYS = ['password', 'passphrase', 'privatekey', 'private_key', 'token', 'secret', 'apikey', 'api_key'] + +/** + * Validate a profile AND assert it carries no secret-like fields. Returns the + * parsed profile or throws a descriptive error. + */ +export function parseRemoteProfile(input: unknown): RemoteProfile { + if (input && typeof input === 'object') { + for (const key of Object.keys(input as Record)) { + if (FORBIDDEN_SECRET_KEYS.includes(key.toLowerCase().replace(/[-\s]/g, '_').replace(/_/g, ''))) { + throw new Error(`remote profile must not contain secrets (offending key: "${key}")`) + } + } + } + return RemoteProfileSchema.parse(input) +} + +/** + * Normalize a remote POSIX path: collapse `//`, resolve `.`/`..`, strip a + * trailing slash. This is what makes protected-path matching robust against + * `..` traversal bypasses (e.g. `/srv/app/../../etc/shadow`). + */ +export function normalizeRemotePath(remotePath: string): string { + const normalized = posix.normalize(remotePath.replace(/\\/g, '/')) + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized +} + +/** Whether a remote path is protected by the profile (normalized exact or prefix match). */ +export function isProtectedPath(profile: Pick, remotePath: string): boolean { + const target = normalizeRemotePath(remotePath) + return profile.protectedPaths.some((protectedPath) => { + const base = normalizeRemotePath(protectedPath) + if (target === base) return true + // Absolute protected path → prefix match on a path boundary. + if (base.startsWith('/')) return target.startsWith(`${base}/`) + // Relative protected name (e.g. ".env") → match any path segment. + return target.split('/').includes(base) || target.endsWith(`/${base}`) + }) +} + +/** Whether a recursive operation rooted at `remotePath` can enter a protected path. */ +export function containsProtectedPath(profile: Pick, remotePath: string): boolean { + const root = normalizeRemotePath(remotePath) + return profile.protectedPaths.some((protectedPath) => { + const base = normalizeRemotePath(protectedPath) + if (!base.startsWith('/')) return true + return base === root || base.startsWith(`${root === '/' ? '' : root}/`) || root.startsWith(`${base}/`) + }) +} + +export type RemotePathCapability = 'read' | 'write' + +export type RemotePathAccessDecision = { + decision: 'allow' | 'confirm' | 'deny' + protected: boolean + reason: string +} + +/** + * Capability + path access check for file tools (read/edit/write), independent + * of command-string classification — permissions are target + capability + + * path, not just command. A protected path always requires confirmation; on a + * production target a WRITE to a protected path is denied outright. + */ +export function evaluateRemotePathAccess(input: { + capability: RemotePathCapability + path: string + profile: Pick + production?: boolean + recursive?: boolean +}): RemotePathAccessDecision { + const protectedPath = input.recursive + ? containsProtectedPath(input.profile, input.path) + : isProtectedPath(input.profile, input.path) + if (!protectedPath) { + return { decision: 'allow', protected: false, reason: 'path is not protected' } + } + if (input.capability === 'write' && input.production) { + return { decision: 'deny', protected: true, reason: 'writing a protected path on a production target is not allowed' } + } + return { + decision: 'confirm', + protected: true, + reason: input.recursive + ? `${input.capability} may traverse protected paths and requires confirmation` + : `${input.capability} of a protected path requires confirmation` + } +} diff --git a/kun/src/remote/remote-run-mode.ts b/kun/src/remote/remote-run-mode.ts new file mode 100644 index 000000000..aace15e27 --- /dev/null +++ b/kun/src/remote/remote-run-mode.ts @@ -0,0 +1,91 @@ +/** + * Task-level run modes for a remote target (Issue #647). + * + * A remote thread picks a run mode that auto-scopes what the agent may do, so + * "remote" never means "full shell on the server". The mode + the command risk + * classification together yield an allow / confirm / deny decision. Production + * targets escalate: irreversible operations are never silent even with full + * permissions. + */ + +import { + classifyRemoteCommand, + isIrreversibleCategory, + type RemoteCommandCategory, + type RemoteCommandClassification, + type RemoteRiskLevel +} from './remote-command-risk.js' + +export type RemoteRunMode = 'observe' | 'develop' | 'operations' | 'deploy' + +export type RemoteGuardDecision = 'allow' | 'confirm' | 'deny' + +export type RemoteCommandEvaluation = { + decision: RemoteGuardDecision + mode: RemoteRunMode + category: RemoteCommandCategory + level: RemoteRiskLevel + writes: boolean + reasons: string[] +} + +/** Categories each mode permits WITHOUT a deny. Confirmation is layered on top. */ +const MODE_ALLOWED: Record> = { + // Observe: strictly read-only investigation. + observe: new Set(['read-only']), + // Develop: project work + tests + dependency installs, but not service/infra. + develop: new Set(['read-only', 'test-run', 'project-write', 'dependency-install', 'network-write', 'standard']), + // Operations: process/container/service management on top of develop. + operations: new Set([ + 'read-only', 'test-run', 'project-write', 'dependency-install', 'network-write', 'standard', + 'service-control', 'container-destructive', 'k8s-mutation' + ]), + // Deploy: everything operations can do; deploy-class actions still confirm. + deploy: new Set([ + 'read-only', 'test-run', 'project-write', 'dependency-install', 'network-write', 'standard', + 'service-control', 'container-destructive', 'k8s-mutation', 'db-migration', 'network-security' + ]) +} + +export function evaluateRemoteCommand(input: { + command: string + mode: RemoteRunMode + /** True when the target is a production host — escalates confirmation. */ + production?: boolean + classification?: RemoteCommandClassification +}): RemoteCommandEvaluation { + const classification = input.classification ?? classifyRemoteCommand(input.command) + const reasons: string[] = [classification.reason] + const allowed = MODE_ALLOWED[input.mode] + + let decision: RemoteGuardDecision + if (!allowed.has(classification.category)) { + decision = 'deny' + reasons.push(`'${classification.category}' is not permitted in '${input.mode}' mode`) + } else if (isIrreversibleCategory(classification.category)) { + // Permitted but irreversible → always confirm, never silent. + decision = 'confirm' + reasons.push('irreversible / high-blast-radius operation requires confirmation') + } else if (classification.level === 'high' || classification.level === 'critical') { + decision = 'confirm' + reasons.push(`risk level '${classification.level}' requires confirmation`) + } else { + decision = 'allow' + } + + // Production escalation: anything that writes must be confirmed, and a deny + // stays a deny. Secrets/privilege escalation are never auto-allowed. + if (input.production && decision === 'allow' && classification.writes) { + decision = 'confirm' + reasons.push('production target: state-changing commands require confirmation') + } + + return { + decision, + mode: input.mode, + category: classification.category, + level: classification.level, + writes: classification.writes, + reasons + } +} diff --git a/kun/src/remote/remote-target-registry.test.ts b/kun/src/remote/remote-target-registry.test.ts new file mode 100644 index 000000000..bbfe93317 --- /dev/null +++ b/kun/src/remote/remote-target-registry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteTargetRegistry } from './remote-target-registry.js' +import type { ThreadRemoteTarget } from '../contracts/threads.js' +import type { SshChildProcess } from './ssh-executor.js' + +const binding: ThreadRemoteTarget = { + kind: 'ssh', + alias: 'prod', + remoteDir: '/srv/api', + runMode: 'develop', + production: false, + protectedPaths: [] +} + +function child(): SshChildProcess { + return { + stdout: { on: () => undefined }, + stderr: { on: () => undefined }, + stdin: { end: () => undefined }, + on: (event: string, listener: (...args: unknown[]) => void) => { if (event === 'close') setTimeout(() => listener(0, null), 0) }, + kill: vi.fn() + } as unknown as SshChildProcess +} + +describe('RemoteTargetRegistry', () => { + it('primes a remote thread and resolves a handle synchronously', async () => { + const registry = new RemoteTargetRegistry({ + loadBinding: async () => binding, + handleOptions: { spawn: () => child() } + }) + expect(registry.resolve('t1')).toBeUndefined() + await registry.prime('t1') + const handle = registry.resolve('t1') + expect(handle?.runMode).toBe('develop') + expect(handle?.target.kind).toBe('ssh') + }) + + it('is a no-op for a local thread', async () => { + const registry = new RemoteTargetRegistry({ loadBinding: async () => null }) + await registry.prime('local') + expect(registry.resolve('local')).toBeUndefined() + }) +}) diff --git a/kun/src/remote/remote-target-registry.ts b/kun/src/remote/remote-target-registry.ts new file mode 100644 index 000000000..a5c5ae92f --- /dev/null +++ b/kun/src/remote/remote-target-registry.ts @@ -0,0 +1,29 @@ +import type { ThreadRemoteTarget } from '../contracts/threads.js' +import type { RemoteExecutionHandle } from '../ports/remote-execution.js' +import { SshRemoteExecutionHandle, type SshRemoteExecutionHandleOptions } from './remote-execution-handle.js' + +export type RemoteTargetRegistryDeps = { + loadBinding: (threadId: string) => Promise + handleOptions?: Omit +} + +export class RemoteTargetRegistry { + private readonly handles = new Map() + + constructor(private readonly deps: RemoteTargetRegistryDeps) {} + + async prime(threadId: string): Promise { + if (this.handles.has(threadId)) return + const binding = await this.deps.loadBinding(threadId) + if (!binding) return + this.handles.set(threadId, new SshRemoteExecutionHandle({ binding, ...this.deps.handleOptions })) + } + + resolve(threadId: string): RemoteExecutionHandle | undefined { + return this.handles.get(threadId) + } + + evict(threadId: string): void { + this.handles.delete(threadId) + } +} diff --git a/kun/src/remote/remote-target.test.ts b/kun/src/remote/remote-target.test.ts new file mode 100644 index 000000000..41efa1162 --- /dev/null +++ b/kun/src/remote/remote-target.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { buildRemotePrecheckPlan, describeRemoteTarget, isSshTarget } from './remote-target.js' +import { supportsControlMaster, isAutoReplaySafeOnReconnect } from './ssh-control.js' + +describe('remote-target', () => { + it('identifies ssh targets', () => { + expect(isSshTarget({ kind: 'local' })).toBe(false) + expect(isSshTarget({ kind: 'ssh', alias: 'prod' })).toBe(true) + }) + + it('describes a target as alias · dir · branch', () => { + expect(describeRemoteTarget({ + target: { kind: 'ssh', alias: 'prod', remoteDir: '/srv/api' }, + status: 'connected', + branch: 'main' + })).toBe('prod · /srv/api · main') + expect(describeRemoteTarget({ target: { kind: 'local' }, status: 'connected' })).toBe('local') + }) + + it('builds a read-only precheck plan scoped to the remote dir', () => { + const plan = buildRemotePrecheckPlan('/srv/api') + expect(plan.every((step) => step.writes === false)).toBe(true) + expect(plan.map((step) => step.id)).toEqual(['pwd', 'uname', 'git-status', 'git-root', 'toolcheck']) + expect(plan.find((step) => step.id === 'git-status')?.command).toContain("git -C '/srv/api'") + }) +}) + +describe('ssh-control', () => { + it('disables ControlMaster on Windows only', () => { + expect(supportsControlMaster('win32')).toBe(false) + expect(supportsControlMaster('linux')).toBe(true) + expect(supportsControlMaster('darwin')).toBe(true) + }) + + it('only auto-replays read-only commands on reconnect', () => { + expect(isAutoReplaySafeOnReconnect({ writes: false })).toBe(true) + expect(isAutoReplaySafeOnReconnect({ writes: true })).toBe(false) + }) +}) diff --git a/kun/src/remote/remote-target.ts b/kun/src/remote/remote-target.ts new file mode 100644 index 000000000..1591500eb --- /dev/null +++ b/kun/src/remote/remote-target.ts @@ -0,0 +1,100 @@ +/** + * Remote Agent Target model (Issue #647). + * + * A thread binds to exactly ONE primary execution target. Local threads run the + * tool chain on this machine; SSH threads run the SAME tools on a remote host + * via the system `ssh` binary. The model, API keys, approvals, and session + * records always stay local — only tool EXECUTION moves to the remote. + */ + +export type RemoteTarget = + | { kind: 'local' } + | { + kind: 'ssh' + /** The ~/.ssh/config alias the user selected. */ + alias: string + /** Resolved hostname (display/diagnostics only), when known. */ + host?: string + /** Remote working directory the agent operates in (the "project root"). */ + remoteDir?: string + } + +export type RemoteConnectionStatus = + | 'connected' + | 'connecting' + | 'degraded' + | 'disconnected' + | 'error' + +export type RemoteTargetDescriptor = { + target: RemoteTarget + status: RemoteConnectionStatus + /** Round-trip latency in ms for the last probe, when measured. */ + latencyMs?: number + /** Remote OS string from `uname`, when known. */ + os?: string + /** Current git branch on the remote working dir, when in a repo. */ + branch?: string + /** True when the remote working dir has uncommitted changes. */ + dirty?: boolean + lastError?: string +} + +export function isSshTarget(target: RemoteTarget): target is Extract { + return target.kind === 'ssh' +} + +/** A short, stable label for the thread header: `alias · dir · branch`. */ +export function describeRemoteTarget(descriptor: RemoteTargetDescriptor): string { + const { target } = descriptor + if (target.kind === 'local') return 'local' + const parts = [target.alias] + if (target.remoteDir) parts.push(target.remoteDir) + if (descriptor.branch) parts.push(descriptor.branch) + return parts.join(' · ') +} + +export type RemotePrecheckStep = { + id: string + description: string + /** Remote shell command — always read-only. */ + command: string + /** Always false here; the precheck must never mutate the remote. */ + writes: false +} + +/** + * Read-only commands the agent runs once on first entry to a remote target so + * it understands the environment before doing anything: working dir, OS, git + * state, available tooling, and the repository root. None of these mutate the + * remote, so they are safe to run automatically without an approval prompt. + */ +export function buildRemotePrecheckPlan(remoteDir?: string): RemotePrecheckStep[] { + const gitDir = remoteDir ? `git -C ${shellDir(remoteDir)} ` : 'git ' + return [ + { id: 'pwd', description: 'Confirm the working directory', command: 'pwd', writes: false }, + { id: 'uname', description: 'Identify the remote OS', command: 'uname -a', writes: false }, + { + id: 'git-status', + description: 'Check git branch and dirty state', + command: `${gitDir}status --porcelain=v1 -b`, + writes: false + }, + { + id: 'git-root', + description: 'Locate the repository root', + command: `${gitDir}rev-parse --show-toplevel`, + writes: false + }, + { + id: 'toolcheck', + description: 'Detect available tooling (rg/git/node/python/docker)', + command: 'for t in rg git node python docker; do command -v "$t" >/dev/null 2>&1 && echo "$t=yes" || echo "$t=no"; done', + writes: false + } + ] +} + +function shellDir(dir: string): string { + return `'${dir.replace(/'/g, `'\\''`)}'` +} diff --git a/kun/src/remote/ssh-command.test.ts b/kun/src/remote/ssh-command.test.ts new file mode 100644 index 000000000..18a310256 --- /dev/null +++ b/kun/src/remote/ssh-command.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { buildSshBaseArgs, buildSshExecArgv, buildSshResolveArgv, assertSafeSshAlias, shellQuoteRemote } from './ssh-command.js' + +describe('ssh-command', () => { + it('quotes remote shell values safely', () => { + expect(shellQuoteRemote('/srv/my app')).toBe("'/srv/my app'") + expect(shellQuoteRemote("it's")).toBe("'it'\\''s'") + }) + + it('builds base args with batch mode, timeout, and control master', () => { + expect(buildSshBaseArgs({ alias: 'h', batchMode: true, connectTimeoutSec: 8 })).toEqual([ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=8' + ]) + const withControl = buildSshBaseArgs({ alias: 'h', controlPath: '/tmp/cm', controlPersistSec: 120 }) + expect(withControl).toEqual([ + '-o', 'ControlMaster=auto', + '-o', 'ControlPath=/tmp/cm', + '-o', 'ControlPersist=120' + ]) + }) + + it('runs a command inside a quoted remote directory', () => { + const argv = buildSshExecArgv({ alias: 'prod', remoteDir: '/srv/my app', command: 'git status' }) + expect(argv).toEqual(['prod', "cd -- '/srv/my app' && git status"]) + }) + + it('passes the command directly when no remote dir is given', () => { + expect(buildSshExecArgv({ alias: 'prod', command: 'uname -a' })).toEqual(['prod', 'uname -a']) + }) + + it('does not enable control master unless a control path is provided', () => { + expect(buildSshBaseArgs({ alias: 'h' })).toEqual([]) + }) + + it('rejects unsafe host aliases (option-like or shell-hostile)', () => { + expect(() => assertSafeSshAlias('-oProxyCommand=evil')).toThrow(/unsafe/) + expect(() => assertSafeSshAlias('a b')).toThrow(/unsafe/) + expect(() => assertSafeSshAlias('')).toThrow(/empty/) + expect(assertSafeSshAlias('production-api')).toBe('production-api') + expect(() => buildSshExecArgv({ alias: '-x', command: 'ls' })).toThrow(/unsafe/) + }) + + it('builds the ssh -G resolver argv (authoritative config resolution)', () => { + expect(buildSshResolveArgv('prod')).toEqual(['-G', 'prod']) + expect(() => buildSshResolveArgv('-evil')).toThrow(/unsafe/) + }) +}) diff --git a/kun/src/remote/ssh-command.ts b/kun/src/remote/ssh-command.ts new file mode 100644 index 000000000..91a9c5d6e --- /dev/null +++ b/kun/src/remote/ssh-command.ts @@ -0,0 +1,88 @@ +/** + * Build argv for the system `ssh` binary (Issue #647). + * + * Always invoked as `spawn('ssh', argv, { shell: false })` so there is no local + * shell to inject into. The remote command runs in the remote login shell, so + * any path WE control (the working directory) is single-quote escaped for that + * remote shell; the command body itself is intentionally a shell command (the + * agent's tool), gated by the approval layer, not by quoting here. + */ + +export type SshConnectionOptions = { + alias: string + /** ControlMaster socket path for connection reuse; omit to disable multiplexing. */ + controlPath?: string + /** Seconds before a connection attempt is abandoned. */ + connectTimeoutSec?: number + /** + * Refuse interactive prompts (used for the read-only precheck and any + * non-interactive probe) so a missing key/host never hangs the agent. + */ + batchMode?: boolean + /** Persist the multiplexed master this long after the last client (seconds). */ + controlPersistSec?: number +} + +/** Single-quote escape a string for a POSIX remote shell. */ +export function shellQuoteRemote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** + * Reject host aliases that the system `ssh` binary could misread as an option + * or that contain shell-hostile characters. A leading `-` would be parsed as an + * ssh flag; whitespace/control chars indicate a malformed or injected alias. + * Throws on an unsafe alias so it can never reach `spawn`. + */ +export function assertSafeSshAlias(alias: string): string { + const value = alias.trim() + if (!value) throw new Error('SSH host alias must not be empty') + if (value.startsWith('-')) throw new Error(`unsafe SSH host alias (must not start with '-'): ${alias}`) + // eslint-disable-next-line no-control-regex -- intentionally rejecting control chars in an alias + if (/[\s\u0000-\u001f"'`\\]/.test(value)) throw new Error(`unsafe SSH host alias (illegal characters): ${alias}`) + return value +} + +/** + * argv for `ssh -G `, the AUTHORITATIVE config resolver. The hand-written + * parser in ssh-config.ts is for listing aliases only; for a concrete + * connection the executor should run `ssh -G` so Include/Match/Host wildcards + * and all OpenSSH precedence rules are honored by ssh itself. + */ +export function buildSshResolveArgv(alias: string): string[] { + return ['-G', assertSafeSshAlias(alias)] +} + +/** + * Base ssh options shared by exec and probe invocations. Host fingerprint + * verification is left to the system known_hosts (StrictHostKeyChecking is NOT + * disabled). Connection reuse is opt-in via `controlPath`. + */ +export function buildSshBaseArgs(options: SshConnectionOptions): string[] { + const args: string[] = [] + if (options.batchMode) args.push('-o', 'BatchMode=yes') + if (typeof options.connectTimeoutSec === 'number' && options.connectTimeoutSec > 0) { + args.push('-o', `ConnectTimeout=${Math.floor(options.connectTimeoutSec)}`) + } + if (options.controlPath) { + args.push('-o', 'ControlMaster=auto') + args.push('-o', `ControlPath=${options.controlPath}`) + const persist = options.controlPersistSec && options.controlPersistSec > 0 ? Math.floor(options.controlPersistSec) : 60 + args.push('-o', `ControlPersist=${persist}`) + } + return args +} + +/** + * Full argv for executing a command on the remote host, optionally inside a + * working directory. The directory is escaped for the remote shell; `cd -- ...` + * stops a leading-dash directory from being read as an option. + */ +export function buildSshExecArgv( + options: SshConnectionOptions & { remoteDir?: string; command: string } +): string[] { + const remoteCommand = options.remoteDir + ? `cd -- ${shellQuoteRemote(options.remoteDir)} && ${options.command}` + : options.command + return [...buildSshBaseArgs(options), assertSafeSshAlias(options.alias), remoteCommand] +} diff --git a/kun/src/remote/ssh-config.test.ts b/kun/src/remote/ssh-config.test.ts new file mode 100644 index 000000000..6380e24fd --- /dev/null +++ b/kun/src/remote/ssh-config.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { listSshHostAliases, parseSshConfig, resolveSshHost } from './ssh-config.js' + +const CONFIG = ` +# personal hosts +Host production-api + HostName 10.0.0.5 + User deploy + Port 2222 + IdentityFile ~/.ssh/prod + ProxyJump bastion + +Host staging-* + User stage + +Host bastion + HostName bastion.example.com + +Host * + ServerAliveInterval 30 +` + +describe('ssh-config', () => { + it('lists only concrete aliases (skips wildcards)', () => { + expect(listSshHostAliases(CONFIG)).toEqual(['production-api', 'bastion']) + }) + + it('resolves a concrete host with all settings', () => { + expect(resolveSshHost(CONFIG, 'production-api')).toEqual({ + alias: 'production-api', + hostName: '10.0.0.5', + user: 'deploy', + port: 2222, + identityFile: '~/.ssh/prod', + proxyJump: 'bastion' + }) + }) + + it('returns null for an unknown alias', () => { + expect(resolveSshHost(CONFIG, 'nope')).toBeNull() + }) + + it('parses every concrete host', () => { + expect(parseSshConfig(CONFIG).map((h) => h.alias)).toEqual(['production-api', 'bastion']) + }) + + it('supports Key=value syntax and is case-insensitive', () => { + const host = resolveSshHost('Host h\n HostName=example.com\n USER=root', 'h') + expect(host).toMatchObject({ hostName: 'example.com', user: 'root' }) + }) +}) diff --git a/kun/src/remote/ssh-config.ts b/kun/src/remote/ssh-config.ts new file mode 100644 index 000000000..979ae7c65 --- /dev/null +++ b/kun/src/remote/ssh-config.ts @@ -0,0 +1,127 @@ +/** + * OpenSSH client-config parsing for the Remote Agent Target (Issue #647). + * + * Kun reuses the user's existing `~/.ssh/config` so a remote target is just a + * host alias — no re-entering IP / port / key. This module is pure (operates on + * config text) so it is unit-testable without touching the filesystem or the + * network. Connection itself always goes through the system `ssh` binary + * (ProxyJump, certificates, FIDO keys, MFA, ssh-agent, known_hosts all handled + * by OpenSSH); Kun never stores passwords, private keys, or passphrases. + */ + +export type SshConfigHost = { + /** The alias the user types (the concrete `Host` pattern, never a wildcard). */ + alias: string + hostName?: string + user?: string + port?: number + identityFile?: string + proxyJump?: string +} + +type RawHostBlock = { + patterns: string[] + settings: Map +} + +function isWildcard(pattern: string): boolean { + return pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!') +} + +function parseBlocks(text: string): RawHostBlock[] { + const blocks: RawHostBlock[] = [] + let current: RawHostBlock | null = null + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + // `Key value` or `Key=value`; keys are case-insensitive. + const match = line.match(/^(\S+?)[=\s]+(.+)$/) + if (!match) continue + const key = match[1].toLowerCase() + const value = match[2].trim().replace(/^["']|["']$/g, '') + if (key === 'host') { + current = { patterns: value.split(/\s+/).filter(Boolean), settings: new Map() } + blocks.push(current) + continue + } + if (key === 'match') { + // `Match` blocks use conditional logic we don't evaluate; start a block + // with no concrete patterns so its settings never bind to a plain alias. + current = { patterns: [], settings: new Map() } + blocks.push(current) + continue + } + if (!current) continue + // First value wins in OpenSSH; keep the earliest occurrence per block. + if (!current.settings.has(key)) current.settings.set(key, value) + } + return blocks +} + +function patternMatches(pattern: string, alias: string): boolean { + if (pattern === alias) return true + if (!isWildcard(pattern) || pattern.startsWith('!')) return false + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + try { + return new RegExp(`^${escaped}$`).test(alias) + } catch { + return false + } +} + +function blockSetting(blocks: readonly RawHostBlock[], alias: string, key: string): string | undefined { + for (const block of blocks) { + if (block.patterns.some((pattern) => patternMatches(pattern, alias)) && block.settings.has(key)) { + return block.settings.get(key) + } + } + return undefined +} + +function toHost(alias: string, blocks: readonly RawHostBlock[]): SshConfigHost { + const port = blockSetting(blocks, alias, 'port') + const parsedPort = port ? Number.parseInt(port, 10) : Number.NaN + return { + alias, + ...(blockSetting(blocks, alias, 'hostname') ? { hostName: blockSetting(blocks, alias, 'hostname') } : {}), + ...(blockSetting(blocks, alias, 'user') ? { user: blockSetting(blocks, alias, 'user') } : {}), + ...(Number.isInteger(parsedPort) && parsedPort > 0 ? { port: parsedPort } : {}), + ...(blockSetting(blocks, alias, 'identityfile') ? { identityFile: blockSetting(blocks, alias, 'identityfile') } : {}), + ...(blockSetting(blocks, alias, 'proxyjump') ? { proxyJump: blockSetting(blocks, alias, 'proxyjump') } : {}) + } +} + +/** + * Concrete host aliases the user can pick (wildcard/`Match`/negated patterns are + * excluded — you cannot connect to a pattern). Order follows the config file. + */ +export function listSshHostAliases(text: string): string[] { + const aliases: string[] = [] + const seen = new Set() + for (const block of parseBlocks(text)) { + for (const pattern of block.patterns) { + if (isWildcard(pattern) || seen.has(pattern)) continue + seen.add(pattern) + aliases.push(pattern) + } + } + return aliases +} + +/** Parse every concrete host alias into a merged {@link SshConfigHost}. */ +export function parseSshConfig(text: string): SshConfigHost[] { + const blocks = parseBlocks(text) + return listSshHostAliases(text).map((alias) => toHost(alias, blocks)) +} + +/** + * Resolve one alias to its effective settings (later wildcard blocks fill gaps, + * matching OpenSSH "first value wins" precedence). Returns null when the alias + * is not present as a concrete Host entry. + */ +export function resolveSshHost(text: string, alias: string): SshConfigHost | null { + const blocks = parseBlocks(text) + const hasConcrete = blocks.some((block) => block.patterns.includes(alias)) + if (!hasConcrete) return null + return toHost(alias, blocks) +} diff --git a/kun/src/remote/ssh-control.ts b/kun/src/remote/ssh-control.ts new file mode 100644 index 000000000..232344766 --- /dev/null +++ b/kun/src/remote/ssh-control.ts @@ -0,0 +1,23 @@ +/** + * ControlMaster (connection multiplexing) capability detection (Issue #647). + * + * Reusing one SSH connection for many tool calls is a big latency win, but + * OpenSSH ControlMaster relies on a Unix-domain control socket that the bundled + * Windows OpenSSH client does not support. Callers must detect support BEFORE + * adding `-o ControlPath=...`, otherwise every ssh invocation errors on + * Windows. Linux/macOS support it. + */ + +export function supportsControlMaster(platform: NodeJS.Platform = process.platform): boolean { + return platform !== 'win32' +} + +/** + * After a dropped connection a target enters `degraded`. Read-only commands may + * transparently reconnect; mutating commands (writes, deploys, restarts) must + * NOT be auto-replayed because we cannot confirm whether the remote already ran + * them. This classifies whether a command is safe to auto-retry on reconnect. + */ +export function isAutoReplaySafeOnReconnect(input: { writes: boolean }): boolean { + return input.writes === false +} diff --git a/kun/src/remote/ssh-executor.test.ts b/kun/src/remote/ssh-executor.test.ts new file mode 100644 index 000000000..02ff60f7c --- /dev/null +++ b/kun/src/remote/ssh-executor.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { SshExecutor, type SshChildProcess, type SshSpawnFn } from './ssh-executor.js' + +/** A scriptable fake `ssh` child process for deterministic executor tests. */ +class FakeChild extends EventEmitter implements SshChildProcess { + stdout = new EventEmitter() as unknown as SshChildProcess['stdout'] + stderr = new EventEmitter() as unknown as SshChildProcess['stderr'] + stdin?: SshChildProcess['stdin'] + killed: NodeJS.Signals | undefined + kill(signal?: NodeJS.Signals): void { + this.killed = signal ?? 'SIGTERM' + } +} + +function fakeSpawn(script: (child: FakeChild, command: string, args: readonly string[]) => void): { + spawn: SshSpawnFn + calls: { command: string; args: readonly string[] }[] +} { + const calls: { command: string; args: readonly string[] }[] = [] + const spawn: SshSpawnFn = (command, args) => { + calls.push({ command, args }) + const child = new FakeChild() + queueMicrotask(() => script(child, command, args)) + return child + } + return { spawn, calls } +} + +describe('SshExecutor', () => { + it('runs ssh with shell:false argv and returns a structured outcome', async () => { + const { spawn, calls } = fakeSpawn((child) => { + ;(child.stdout as unknown as EventEmitter).emit('data', 'on branch main\n') + child.emit('close', 0, null) + }) + const exec = new SshExecutor({ alias: 'prod', remoteDir: '/srv/api', spawn, nowMs: () => 0 }) + const outcome = await exec.exec('git status') + + expect(calls[0].command).toBe('ssh') + expect(calls[0].args).toContain('-o') + expect(calls[0].args).toContain('BatchMode=yes') + expect(calls[0].args.at(-2)).toBe('prod') + expect(calls[0].args.at(-1)).toBe("cd -- '/srv/api' && git status") + expect(outcome).toMatchObject({ + alias: 'prod', + remoteDir: '/srv/api', + command: 'git status', + stdout: 'on branch main\n', + exitCode: 0, + timedOut: false + }) + }) + + it('caps stdout to maxOutputBytes to avoid unbounded memory growth', async () => { + const { spawn } = fakeSpawn((child) => { + ;(child.stdout as unknown as EventEmitter).emit('data', 'x'.repeat(50)) + ;(child.stdout as unknown as EventEmitter).emit('data', 'y'.repeat(50)) + child.emit('close', 0, null) + }) + const outcome = await new SshExecutor({ alias: 'prod', spawn, maxOutputBytes: 64 }).exec('cat big.log') + expect(outcome.stdout.length).toBe(64) + expect(outcome.truncated).toBe(true) + }) + + it('caps by UTF-8 bytes without cutting a multibyte character', async () => { + const { spawn } = fakeSpawn((child) => { + ;(child.stdout as unknown as EventEmitter).emit('data', Buffer.from('中文测试', 'utf8')) + child.emit('close', 0, null) + }) + const outcome = await new SshExecutor({ alias: 'prod', spawn, maxOutputBytes: 6 }).exec('cat') + expect(outcome.stdout).toBe('中文') + expect(Buffer.byteLength(outcome.stdout, 'utf8')).toBe(6) + expect(outcome.stdout).not.toContain('�') + }) + + it('captures a non-zero exit code and stderr', async () => { + const { spawn } = fakeSpawn((child) => { + ;(child.stderr as unknown as EventEmitter).emit('data', 'fatal: not a repo') + child.emit('close', 128, null) + }) + const outcome = await new SshExecutor({ alias: 'prod', spawn }).exec('git status') + expect(outcome.exitCode).toBe(128) + expect(outcome.stderr).toBe('fatal: not a repo') + }) + + it('sends an input payload through stdin instead of argv', async () => { + let input: string | Buffer | undefined + const calls: { command: string; args: readonly string[] }[] = [] + const spawn: SshSpawnFn = (command, args) => { + calls.push({ command, args }) + const child = new FakeChild() + child.stdin = { end: (data?: string | Buffer) => { input = data } } + queueMicrotask(() => child.emit('close', 0, null)) + return child + } + await new SshExecutor({ alias: 'prod', spawn }).exec('cat > file', { input: 'secret-content' }) + expect(input).toBe('secret-content') + expect(calls[0].args.join(' ')).not.toContain('secret-content') + }) + + it('kills the process and reports timedOut on timeout', async () => { + let killed = false + const { spawn } = fakeSpawn((child) => { + // Never emit close until killed. + const original = child.kill.bind(child) + child.kill = (signal) => { killed = true; original(signal); child.emit('close', null, 'SIGTERM') } + }) + const outcome = await new SshExecutor({ alias: 'prod', spawn }).exec('sleep 100', { timeoutMs: 10 }) + expect(killed).toBe(true) + expect(outcome.timedOut).toBe(true) + }) + + it('escalates to SIGKILL when the process ignores SIGTERM after the grace period', async () => { + vi.useFakeTimers() + try { + const signals: (NodeJS.Signals | undefined)[] = [] + const { spawn } = fakeSpawn((child) => { + // Ignore SIGTERM entirely; record signals and only close on SIGKILL. + child.kill = (signal) => { + signals.push(signal ?? 'SIGTERM') + if (signal === 'SIGKILL') child.emit('close', null, 'SIGKILL') + } + }) + const exec = new SshExecutor({ alias: 'prod', spawn, killGraceMs: 1_000 }) + const promise = exec.exec('sleep 100', { timeoutMs: 10 }) + // Let the queued spawn script run and the timeout fire. + await vi.advanceTimersByTimeAsync(20) + expect(signals).toEqual(['SIGTERM']) + // After the grace period, the executor escalates to SIGKILL. + await vi.advanceTimersByTimeAsync(1_000) + expect(signals).toContain('SIGKILL') + const outcome = await promise + expect(outcome.timedOut).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('distinguishes caller abort from timeout', async () => { + const controller = new AbortController() + const { spawn } = fakeSpawn((child) => { + child.kill = (signal) => child.emit('close', null, signal ?? 'SIGTERM') + controller.abort() + }) + const outcome = await new SshExecutor({ alias: 'prod', spawn }).exec('sleep 100', { signal: controller.signal }) + expect(outcome.aborted).toBe(true) + expect(outcome.timedOut).toBe(false) + }) + + it('rejects on spawn error', async () => { + const { spawn } = fakeSpawn((child) => child.emit('error', new Error('ssh: command not found'))) + await expect(new SshExecutor({ alias: 'prod', spawn }).exec('ls')).rejects.toThrow(/command not found/) + }) + + it('rejects construction with an unsafe alias', () => { + expect(() => new SshExecutor({ alias: '-oProxyCommand=evil', spawn: fakeSpawn(() => undefined).spawn })).toThrow(/unsafe/) + }) + + it('probe reports ok + latency on exit 0', async () => { + let t = 0 + const { spawn } = fakeSpawn((child) => child.emit('close', 0, null)) + const exec = new SshExecutor({ alias: 'prod', spawn, nowMs: () => (t += 5) }) + const probe = await exec.probe() + expect(probe.ok).toBe(true) + expect(typeof probe.latencyMs).toBe('number') + }) +}) diff --git a/kun/src/remote/ssh-executor.ts b/kun/src/remote/ssh-executor.ts new file mode 100644 index 000000000..6f9d5dd3e --- /dev/null +++ b/kun/src/remote/ssh-executor.ts @@ -0,0 +1,198 @@ +/** + * SSH command executor (Issue #647) — the real `spawn('ssh', ...)` runner. + * + * Bridges the pure argv/quoting layer to an actual child process. Always + * `shell: false` (no local shell injection); the host alias is validated; the + * remote working directory is quoted for the remote shell. Results are + * structured and always carry the target, remote dir, and exit status so the + * model never confuses remote and local execution. The `spawn` implementation + * is injectable so this is unit-testable without a real SSH connection. + */ + +import { spawn as nodeSpawn } from 'node:child_process' +import { StringDecoder } from 'node:string_decoder' +import { assertSafeSshAlias, buildSshExecArgv, type SshConnectionOptions } from './ssh-command.js' + +export type SshExecOutcome = { + alias: string + remoteDir?: string + command: string + stdout: string + stderr: string + exitCode: number | null + signal: string | null + durationMs: number + timedOut: boolean + /** True when the caller's AbortSignal terminated the process. */ + aborted?: boolean + /** True when stdout/stderr was capped to avoid unbounded memory growth. */ + truncated?: boolean +} + +export interface SshChildStream { + on(event: 'data', listener: (chunk: Buffer | string) => void): void +} + +export interface SshChildProcess { + stdout: SshChildStream | null + stderr: SshChildStream | null + stdin?: { end(data?: string | Buffer): void } + on(event: 'error', listener: (error: Error) => void): void + on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): void + kill(signal?: NodeJS.Signals): void +} + +export type SshSpawnFn = (command: string, args: readonly string[]) => SshChildProcess + +export type SshExecutorOptions = Omit & { + alias: string + remoteDir?: string + /** Injectable spawn for tests; defaults to `child_process.spawn('ssh', …, {shell:false})`. */ + spawn?: SshSpawnFn + nowMs?: () => number + /** Max bytes retained per stream (stdout/stderr) before capping. Default 8 MiB. */ + maxOutputBytes?: number + /** Grace period after SIGTERM before escalating to SIGKILL. Default 2000ms. */ + killGraceMs?: number +} + +const DEFAULT_SPAWN: SshSpawnFn = (command, args) => nodeSpawn(command, [...args], { shell: false }) as unknown as SshChildProcess +const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1_024 * 1_024 +const DEFAULT_KILL_GRACE_MS = 2_000 + +export class SshExecutor { + private readonly spawn: SshSpawnFn + private readonly nowMs: () => number + + constructor(private readonly options: SshExecutorOptions) { + assertSafeSshAlias(options.alias) + this.spawn = options.spawn ?? DEFAULT_SPAWN + this.nowMs = options.nowMs ?? (() => Date.now()) + } + + /** + * Run a command on the remote target. `batchMode` is forced on so a missing + * key or unknown host fails fast instead of hanging on a prompt. An optional + * `timeoutMs` kills the process and reports `timedOut: true`. + */ + async exec(command: string, options: { timeoutMs?: number; signal?: AbortSignal; input?: string | Buffer } = {}): Promise { + const argv = buildSshExecArgv({ + alias: this.options.alias, + ...(this.options.remoteDir ? { remoteDir: this.options.remoteDir } : {}), + command, + batchMode: true, + ...(this.options.controlPath ? { controlPath: this.options.controlPath } : {}), + ...(this.options.connectTimeoutSec ? { connectTimeoutSec: this.options.connectTimeoutSec } : {}), + ...(this.options.controlPersistSec ? { controlPersistSec: this.options.controlPersistSec } : {}) + }) + const start = this.nowMs() + const child = this.spawn('ssh', argv) + const maxBytes = this.options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let stdoutBytes = 0 + let stderrBytes = 0 + let truncated = false + let timedOut = false + let aborted = false + const appendCapped = (chunks: Buffer[], currentBytes: number, chunk: Buffer | string): number => { + if (currentBytes >= maxBytes) { + truncated = true + return currentBytes + } + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + const remaining = maxBytes - currentBytes + if (bytes.length > remaining) { + truncated = true + chunks.push(bytes.subarray(0, remaining)) + return maxBytes + } + chunks.push(bytes) + return currentBytes + bytes.length + } + child.stdout?.on('data', (chunk) => { stdoutBytes = appendCapped(stdoutChunks, stdoutBytes, chunk) }) + child.stderr?.on('data', (chunk) => { stderrBytes = appendCapped(stderrChunks, stderrBytes, chunk) }) + child.stdin?.end(options.input) + + return await new Promise((resolve, reject) => { + let settled = false + let hardKillTimer: ReturnType | undefined + // A remote `ssh` that ignores SIGTERM (stuck in uninterruptible I/O, a + // wedged PTY, …) must not linger forever. Escalate to SIGKILL after a + // grace period so the timeout / abort actually frees the process. + const killGraceMs = this.options.killGraceMs ?? DEFAULT_KILL_GRACE_MS + const terminate = (): void => { + child.kill('SIGTERM') + if (!hardKillTimer) { + hardKillTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* already gone */ } }, killGraceMs) + if (hardKillTimer && typeof hardKillTimer === 'object' && 'unref' in hardKillTimer) { + ;(hardKillTimer as { unref: () => void }).unref() + } + } + } + const finish = (outcome: SshExecOutcome): void => { + if (settled) return + settled = true + cleanup() + resolve(outcome) + } + const timer = options.timeoutMs && options.timeoutMs > 0 + ? setTimeout(() => { timedOut = true; terminate() }, options.timeoutMs) + : undefined + const onAbort = (): void => { aborted = true; terminate() } + const cleanup = (): void => { + if (timer) clearTimeout(timer) + if (hardKillTimer) clearTimeout(hardKillTimer) + options.signal?.removeEventListener('abort', onAbort) + } + if (options.signal) { + if (options.signal.aborted) onAbort() + else options.signal.addEventListener('abort', onAbort, { once: true }) + } + child.on('error', (error) => { + if (settled) return + settled = true + cleanup() + reject(error) + }) + child.on('close', (code, signal) => { + const decode = (chunks: Buffer[]): string => { + const decoder = new StringDecoder('utf8') + // Intentionally do not call end(): when a capped stream ends in the + // middle of a multibyte sequence, the incomplete suffix is dropped + // instead of emitting U+FFFD and corrupting the visible text. + return decoder.write(Buffer.concat(chunks)) + } + finish({ + alias: this.options.alias, + ...(this.options.remoteDir ? { remoteDir: this.options.remoteDir } : {}), + command, + stdout: decode(stdoutChunks), + stderr: decode(stderrChunks), + exitCode: code, + signal: signal ?? null, + durationMs: this.nowMs() - start, + timedOut, + ...(aborted ? { aborted: true } : {}), + ...(truncated ? { truncated: true } : {}) + }) + }) + }) + } + + /** + * Lightweight read-only liveness probe: runs `true` on the remote and reports + * latency. Used by the connection state machine to move connected/degraded. + */ + async probe(timeoutMs = 8_000): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + try { + const outcome = await this.exec('true', { timeoutMs }) + if (outcome.timedOut) return { ok: false, error: 'probe timed out' } + return outcome.exitCode === 0 + ? { ok: true, latencyMs: outcome.durationMs } + : { ok: false, error: outcome.stderr.trim() || `probe exited ${outcome.exitCode}` } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/kun/src/remote/ssh-port-forward.test.ts b/kun/src/remote/ssh-port-forward.test.ts new file mode 100644 index 000000000..a6b33dd01 --- /dev/null +++ b/kun/src/remote/ssh-port-forward.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPortForwardArgv } from './ssh-port-forward.js' + +describe('ssh-port-forward', () => { + it('builds a loopback-bound -L tunnel that runs no remote command', () => { + expect(buildSshPortForwardArgv({ alias: 'prod', localPort: 5173, remotePort: 3000 })).toEqual([ + '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ExitOnForwardFailure=yes', + '-N', '-T', '-L', '127.0.0.1:5173:127.0.0.1:3000', 'prod' + ]) + }) + + it('rejects invalid ports', () => { + expect(() => buildSshPortForwardArgv({ alias: 'p', localPort: 0, remotePort: 0 })).toThrow(/invalid port/) + expect(() => buildSshPortForwardArgv({ alias: 'p', localPort: 70000, remotePort: 80 })).toThrow(/invalid port/) + }) +}) diff --git a/kun/src/remote/ssh-port-forward.ts b/kun/src/remote/ssh-port-forward.ts new file mode 100644 index 000000000..52b1235d6 --- /dev/null +++ b/kun/src/remote/ssh-port-forward.ts @@ -0,0 +1,46 @@ +import { isIP } from 'node:net' +import { assertSafeSshAlias, buildSshBaseArgs, type SshConnectionOptions } from './ssh-command.js' + +export type SshPortForwardOptions = SshConnectionOptions & { + localPort: number + remotePort: number + remoteHost?: string + localBindHost?: string +} + +export function buildSshPortForwardArgv(options: SshPortForwardOptions): string[] { + const localBindHost = options.localBindHost ?? '127.0.0.1' + const remoteHost = options.remoteHost ?? '127.0.0.1' + if (!isValidPort(options.localPort) || !isValidPort(options.remotePort)) { + throw new Error('invalid port for SSH forward (1-65535 required)') + } + const forwardSpec = formatForwardHost(localBindHost) + ':' + options.localPort + ':' + formatForwardHost(remoteHost) + ':' + options.remotePort + return [ + ...buildSshBaseArgs({ + ...options, + batchMode: options.batchMode ?? true, + connectTimeoutSec: options.connectTimeoutSec ?? 10 + }), + '-o', 'ExitOnForwardFailure=yes', + '-N', + '-T', + '-L', forwardSpec, + assertSafeSshAlias(options.alias) + ] +} + +function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= 1 && port <= 65535 +} + +function formatForwardHost(host: string): string { + const value = host.trim() + if (!value) throw new Error('SSH forward host must not be empty') + const version = isIP(value) + if (version === 4) return value + if (version === 6) return '[' + value + ']' + if (!/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$/.test(value)) { + throw new Error('invalid SSH forward host: ' + host) + } + return value +} diff --git a/kun/src/server/routes/index.ts b/kun/src/server/routes/index.ts index ccb8611ef..2d5bd7c82 100644 --- a/kun/src/server/routes/index.ts +++ b/kun/src/server/routes/index.ts @@ -57,6 +57,7 @@ import { backgroundShellStop } from './background-shells.js' import { authorizeMcpOAuth, clearMcpOAuth, mcpOAuthDiagnostics } from './mcp-oauth.js' +import { listRemoteHosts, listRemoteProfiles, testRemoteConnection } from './remote.js' import { isAuthorized, bearerToken } from '../auth.js' import { ERRORS } from './runtime-error.js' import type { ServerRuntime } from './server-runtime.js' @@ -125,6 +126,18 @@ export function buildRouter(runtime: ServerRuntime): Router { if (!authorize(request, runtime)) return ERRORS.unauthorized() return listSkills(runtime) }) + router.add('GET', '/v1/remote/hosts', async (request) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + return listRemoteHosts(runtime) + }) + router.add('POST', '/v1/remote/test', async (request) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + return testRemoteConnection(runtime, request) + }) + router.add('GET', '/v1/remote/profiles', async (request) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + return listRemoteProfiles(runtime) + }) router.add('POST', '/v1/attachments', async (request) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() return uploadAttachment(runtime.attachmentStore, request) @@ -205,11 +218,19 @@ export function buildRouter(runtime: ServerRuntime): Router { }) router.add('PATCH', '/v1/threads/:id', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() - return updateThread(runtime.threadService, ctx.params.id, request) + const patchPromise = request.clone().json().catch(() => null) as Promise<{ remoteTarget?: unknown; status?: string } | null> + const response = await updateThread(runtime.threadService, ctx.params.id, request) + const patch = await patchPromise + if (response.status < 400 && (patch?.remoteTarget === null || patch?.status === 'archived' || patch?.status === 'deleted')) { + runtime.disposeThreadResources?.(ctx.params.id) + } + return response }) router.add('DELETE', '/v1/threads/:id', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() - return deleteThread(runtime.threadService, ctx.params.id) + const response = await deleteThread(runtime.threadService, ctx.params.id) + if (response.status < 400) runtime.disposeThreadResources?.(ctx.params.id) + return response }) router.add('POST', '/v1/threads/:id/fork', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() diff --git a/kun/src/server/routes/remote.ts b/kun/src/server/routes/remote.ts new file mode 100644 index 000000000..f201a7573 --- /dev/null +++ b/kun/src/server/routes/remote.ts @@ -0,0 +1,30 @@ +import { jsonResponse, type JsonResponse } from '../response.js' +import { ERRORS } from './runtime-error.js' +import { TestRemoteConnectionRequest } from '../../contracts/remote.js' +import type { ServerRuntime } from './server-runtime.js' + +/** GET /v1/remote/hosts — selectable SSH aliases from ~/.ssh/config. */ +export async function listRemoteHosts(runtime: ServerRuntime): Promise { + if (!runtime.remote) return ERRORS.unavailable('remote targets are not available') + return jsonResponse(await runtime.remote.listHosts()) +} + +/** POST /v1/remote/test — read-only precheck against an alias + remote dir. */ +export async function testRemoteConnection(runtime: ServerRuntime, request: Request): Promise { + if (!runtime.remote) return ERRORS.unavailable('remote targets are not available') + let body: unknown + try { + body = await request.json() + } catch { + return ERRORS.validation('invalid JSON body') + } + const parsed = TestRemoteConnectionRequest.safeParse(body) + if (!parsed.success) return ERRORS.validation(parsed.error.message) + return jsonResponse(await runtime.remote.testConnection(parsed.data)) +} + +/** GET /v1/remote/profiles — shareable, secret-free Remote Profiles. */ +export async function listRemoteProfiles(runtime: ServerRuntime): Promise { + if (!runtime.remote) return ERRORS.unavailable('remote targets are not available') + return jsonResponse({ profiles: await runtime.remote.listProfiles() }) +} diff --git a/kun/src/server/routes/server-runtime.ts b/kun/src/server/routes/server-runtime.ts index 22a8643f0..4b6cb0f6f 100644 --- a/kun/src/server/routes/server-runtime.ts +++ b/kun/src/server/routes/server-runtime.ts @@ -33,6 +33,7 @@ import type { MemoryStore } from '../../memory/memory-store.js' import type { ReviewTarget } from '../../contracts/review.js' import type { DelegationRuntime } from '../../delegation/delegation-runtime.js' import type { BackgroundShellRuntime } from '../../services/background-shell-runtime.js' +import type { RemoteHostsService } from '../../remote/remote-hosts-service.js' import type { ModelClient } from '../../ports/model-client.js' import type { RolesConfig } from '../../config/kun-config.js' import type { ImmutablePrefix } from '../../cache/immutable-prefix.js' @@ -79,6 +80,7 @@ export type ServerRuntime = { */ delegationRuntime?: DelegationRuntime backgroundShellRuntime?: BackgroundShellRuntime + remote?: RemoteHostsService /** * Default ModelClient + model id for one-shot completions outside the * agent loop (e.g. AI-generated subagent profiles). Optional so test @@ -104,6 +106,7 @@ export type ServerRuntime = { * of goals resumed. Optional so embedders without the agent loop can omit it. */ resumeInterruptedGoals?(threadIds: readonly string[]): Promise + disposeThreadResources?(threadId: string): void runReview?(input: { threadId: string turnId: string diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 984ed8c02..c13353222 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -26,6 +26,9 @@ import { buildDelegationToolProviders } from '../adapters/tool/delegation-tool-p import { buildWebToolProviders } from '../adapters/tool/web-tool-provider.js' import { buildImageGenToolProviders } from '../adapters/tool/image-gen-tool-provider.js' import { buildComputerUseToolProviders } from '../adapters/tool/computer-use-tool-provider.js' +import { createRemotePortForwardRuntime } from '../adapters/tool/remote-port-forward-tool.js' +import { createRemoteHostsService } from '../remote/remote-hosts-service.js' +import { RemoteTargetRegistry } from '../remote/remote-target-registry.js' import { buildMusicGenToolProviders, buildSpeechGenToolProviders, @@ -172,6 +175,10 @@ export async function createKunServeRuntime( }) const threadService = new ThreadService({ threadStore, sessionStore, events, ids, nowIso }) const artifactStore = new FileArtifactStore(join(options.dataDir, 'artifacts'), nowIso) + const remote = createRemoteHostsService() + const remoteTargetRegistry = new RemoteTargetRegistry({ + loadBinding: async (threadId) => (await threadStore.get(threadId))?.remoteTarget + }) const modelProfiles = modelContextProfilesFromConfig({ contextCompaction: options.contextCompaction, models: options.models @@ -268,6 +275,7 @@ export async function createKunServeRuntime( const backgroundShellTool = createBackgroundShellTool({ listBackgroundSessions: (threadId) => backgroundShellRuntime.listSessions(threadId) }) + const remotePortForwardRuntime = createRemotePortForwardRuntime() const withBackgroundShellTools = (tools: LocalTool[]): LocalTool[] => { const mapped = tools.map((tool) => tool.name === 'bash' @@ -452,6 +460,7 @@ export async function createKunServeRuntime( // Host control is available to the top-level agent only, never to // delegated subagents (which use childRegistry/baseToolProviders). ...computerUseProviders.providers, + remotePortForwardRuntime.provider, { id: 'goal', kind: 'gui' as const, @@ -556,6 +565,7 @@ export async function createKunServeRuntime( ...(attachmentStore ? { attachmentStore } : {}), artifactStore, ...(memoryStore ? { memoryStore } : {}), + resolveExecutionTarget: (threadId) => remoteTargetRegistry.resolve(threadId), runtimeDataDir: options.dataDir, onPlanWritten: async ({ threadId, planId, relativePath, markdown }) => { await threadService.syncTodosFromPlan(threadId, { @@ -587,13 +597,19 @@ export async function createKunServeRuntime( ...(memoryStore ? { memoryStore } : {}), ...(delegationRuntime ? { delegationRuntime } : {}), backgroundShellRuntime, + remote, modelClient, defaultModel: options.model, ...(options.roles ? { roles: options.roles } : {}), immutablePrefix: prefix, - runTurn(threadId, turnId) { + async runTurn(threadId, turnId) { + await remoteTargetRegistry.prime(threadId) return loop.runTurn(threadId, turnId) }, + disposeThreadResources(threadId) { + remoteTargetRegistry.evict(threadId) + remotePortForwardRuntime.disposeThreadResources(threadId) + }, resumeInterruptedGoals(threadIds) { return loop.resumeInterruptedGoals(threadIds) }, @@ -655,6 +671,7 @@ export async function createKunServeRuntime( shutdown: async () => { try { loop.shutdownGoalResume() + remotePortForwardRuntime.shutdown() await mcpProviders.close() } finally { await stores.shutdown?.() diff --git a/kun/src/services/thread-service.ts b/kun/src/services/thread-service.ts index 245282936..21f488079 100644 --- a/kun/src/services/thread-service.ts +++ b/kun/src/services/thread-service.ts @@ -11,6 +11,7 @@ import type { ThreadMode, ThreadRecord, ThreadRelation, + ThreadRemoteTarget, ThreadStatus, ThreadTodoItem, ThreadTodoList, @@ -136,6 +137,7 @@ export class ThreadService { mode: request.mode, approvalPolicy: request.approvalPolicy, sandboxMode: request.sandboxMode, + ...(request.remoteTarget ? { remoteTarget: request.remoteTarget } : {}), ...(request.costBudgetUsd !== undefined ? { costBudgetUsd: request.costBudgetUsd } : {}), ...(options.relation ? { relation: options.relation } : {}), ...(options.parentThreadId ? { parentThreadId: options.parentThreadId } : {}), @@ -158,6 +160,7 @@ export class ThreadService { status?: ThreadStatus approvalPolicy?: ApprovalPolicy sandboxMode?: SandboxMode + remoteTarget?: ThreadRemoteTarget | null pinned?: boolean costBudgetUsd?: number | null costBudgetWarningSent?: boolean @@ -165,8 +168,10 @@ export class ThreadService { }): Promise { const current = await this.threadStore.get(threadId) if (!current) throw new Error(`thread not found: ${threadId}`) - const { costBudgetUsd, costBudgetWarningSent, ...standardPatch } = patch + const { costBudgetUsd, costBudgetWarningSent, remoteTarget, ...standardPatch } = patch const merged: ThreadRecord = { ...current, ...standardPatch } + if (remoteTarget === null) delete (merged as { remoteTarget?: ThreadRemoteTarget }).remoteTarget + else if (remoteTarget !== undefined) merged.remoteTarget = remoteTarget if (costBudgetUsd === null) { delete (merged as { costBudgetUsd?: number }).costBudgetUsd delete (merged as { costBudgetWarningSent?: boolean }).costBudgetWarningSent diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts index 0fcc07f1d..fc8616d46 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -45,6 +45,28 @@ describe('contracts', () => { expect(parsed.mode).toBe('agent') }) + it('accepts a secret-free SSH remote target on thread creation', () => { + const parsed = CreateThreadRequest.parse({ + title: 'remote', + workspace: '/tmp/ws', + model: 'deepseek-chat', + remoteTarget: { + kind: 'ssh', + alias: 'prod', + remoteDir: '/srv/app', + runMode: 'develop', + production: true, + protectedPaths: ['.env', '/etc'] + } + }) + expect(parsed.remoteTarget).toMatchObject({ + kind: 'ssh', + alias: 'prod', + runMode: 'develop', + production: true + }) + }) + it('accepts thread goal contracts and events', () => { const goal = ThreadGoalSchema.parse({ threadId: 'thr_1', diff --git a/kun/tests/thread-service.test.ts b/kun/tests/thread-service.test.ts index 428ade5f3..5782fb1a3 100644 --- a/kun/tests/thread-service.test.ts +++ b/kun/tests/thread-service.test.ts @@ -131,6 +131,30 @@ async function seedParentWithTurns( } } +describe('ThreadService remote target binding', () => { + it('persists and clears a secret-free SSH binding', async () => { + const { service } = buildService() + const thread = await service.create({ + title: 'Remote', + workspace: '/tmp/project', + model: DEFAULT_KUN_MODEL, + mode: 'agent', + remoteTarget: { + kind: 'ssh', + alias: 'prod', + remoteDir: '/srv/app', + runMode: 'observe', + production: false, + protectedPaths: ['.env'] + } + }) + expect(thread.remoteTarget).toMatchObject({ alias: 'prod', remoteDir: '/srv/app' }) + + const cleared = await service.update(thread.id, { remoteTarget: null }) + expect(cleared.remoteTarget).toBeUndefined() + }) +}) + describe('ThreadService.fork with side relation', () => { it('sets parentThreadId and side relation on the new thread', async () => { const { service, threadStore } = buildService() diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index c9fbd0326..3ac905754 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -11,6 +11,8 @@ import { KUN_MEMORY_TEMPLATE, KUN_MCP_OAUTH_SERVER_TEMPLATE, KUN_MCP_OAUTH_TEMPLATE, + KUN_REMOTE_HOSTS_TEMPLATE, + KUN_REMOTE_TEST_TEMPLATE, KUN_RUNTIME_INFO_TEMPLATE, KUN_RUNTIME_TOOLS_TEMPLATE, KUN_SESSION_RESUME_TEMPLATE, @@ -154,6 +156,8 @@ const ENDPOINTS: readonly EndpointTemplate[] = [ compileEndpoint(KUN_HEALTH_TEMPLATE, ['GET']), compileEndpoint(KUN_RUNTIME_INFO_TEMPLATE, ['GET']), compileEndpoint(KUN_RUNTIME_TOOLS_TEMPLATE, ['GET']), + compileEndpoint(KUN_REMOTE_HOSTS_TEMPLATE, ['GET']), + compileEndpoint(KUN_REMOTE_TEST_TEMPLATE, ['POST']), compileEndpoint(KUN_SKILLS_TEMPLATE, ['GET']), compileEndpoint(KUN_ATTACHMENTS_TEMPLATE, ['POST']), compileEndpoint(KUN_ATTACHMENT_DIAGNOSTICS_TEMPLATE, ['GET']), diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index 4dafc3a54..1ce269138 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -1,4 +1,5 @@ import { GUI_PLAN_CREATE_PLAN_TOOL_NAME } from '@shared/gui-plan' +import type { NormalizedRemoteTarget } from './remote-target' export type CoreThreadStatus = 'idle' | 'running' | 'archived' | 'deleted' export type CoreTurnStatus = 'queued' | 'running' | 'completed' | 'failed' | 'aborted' @@ -39,6 +40,7 @@ export type CoreThreadSummaryJson = { forkedFromTurnCount?: number goal?: CoreThreadGoalJson | null todos?: CoreThreadTodoListJson | null + remoteTarget?: NormalizedRemoteTarget createdAt: string updatedAt: string } diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index aa8628341..fd02ac16c 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -70,6 +70,7 @@ export function threadFromCore(thread: CoreThreadSummaryJson): NormalizedThread forkedAt: thread.forkedAt, forkedFromMessageCount: thread.forkedFromMessageCount, forkedFromTurnCount: thread.forkedFromTurnCount, + ...(thread.remoteTarget ? { remoteTarget: thread.remoteTarget } : {}), goal: thread.goal ? goalFromCore(thread.goal) : null, todos: thread.todos ? todosFromCore(thread.todos) : null } diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index 6815ce277..da7c97c7c 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -81,6 +81,118 @@ describe('KunRuntimeProvider', () => { expect(caps.approvals).toBe(true) }) + it('posts remote target metadata when creating a thread', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 201, + body: JSON.stringify({ + id: 'thr_remote', + title: 'Remote', + workspace: '/tmp/workspace', + model: defaultKunRuntimeSettings().model, + mode: 'agent', + status: 'idle', + remoteTarget: { + kind: 'ssh', + alias: 'prod-box', + remoteDir: '/srv/app', + runMode: 'develop', + production: true, + protectedPaths: ['.env'] + }, + createdAt: 't0', + updatedAt: 't1' + }) + })) + installDsGui({ runtimeRequest }) + const provider = new KunRuntimeProvider() + + const thread = await provider.createThread({ + workspace: '/tmp/workspace', + title: 'Remote', + mode: 'agent', + remoteTarget: { + kind: 'ssh', + alias: 'prod-box', + remoteDir: '/srv/app', + runMode: 'develop', + production: true, + protectedPaths: ['.env'] + } + }) + + expect(runtimeRequest).toHaveBeenCalledWith( + '/v1/threads', + 'POST', + JSON.stringify({ + workspace: '/tmp/workspace', + title: 'Remote', + model: defaultKunRuntimeSettings().model, + mode: 'agent', + approvalPolicy: 'auto', + sandboxMode: 'danger-full-access', + remoteTarget: { + kind: 'ssh', + alias: 'prod-box', + remoteDir: '/srv/app', + runMode: 'develop', + production: true, + protectedPaths: ['.env'] + } + }) + ) + expect(thread.remoteTarget?.alias).toBe('prod-box') + }) + + it('lists remote SSH hosts through the runtime', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + configFound: true, + hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }] + }) + })) + installDsGui({ runtimeRequest }) + const provider = new KunRuntimeProvider() + + await expect(provider.listRemoteHosts()).resolves.toEqual({ + configFound: true, + hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }] + }) + expect(runtimeRequest).toHaveBeenCalledWith('/v1/remote/hosts', 'GET') + }) + + it('tests remote SSH connections through the runtime', async () => { + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + ok: true, + alias: 'dev', + remoteDir: '/srv/app', + status: 'connected', + latencyMs: 42, + os: 'linux', + branch: 'main', + dirty: false, + repoRoot: '/srv/app', + tools: { bash: true, read: true } + }) + })) + installDsGui({ runtimeRequest }) + const provider = new KunRuntimeProvider() + + const result = await provider.testRemoteConnection({ alias: 'dev', remoteDir: '/srv/app' }) + + expect(runtimeRequest).toHaveBeenCalledWith( + '/v1/remote/test', + 'POST', + JSON.stringify({ alias: 'dev', remoteDir: '/srv/app' }) + ) + expect(result).toMatchObject({ ok: true, status: 'connected', latencyMs: 42 }) + }) + it('reports invalid runtime JSON responses with a stable error message', async () => { installDsGui({ runtimeRequest: vi.fn(async () => ({ diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 49b5803af..9374470c0 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -15,6 +15,8 @@ import { KUN_MEMORY_DIAGNOSTICS_PATH, KUN_MEMORY_PATH, KUN_MCP_OAUTH_PATH, + KUN_REMOTE_HOSTS_PATH, + KUN_REMOTE_TEST_PATH, KUN_RUNTIME_INFO_PATH, KUN_RUNTIME_TOOLS_PATH, KUN_SKILLS_PATH, @@ -77,6 +79,11 @@ import { threadFromCore } from './kun-mapper' import { rendererRuntimeClient } from './runtime-client' +import type { + NormalizedRemoteTarget, + RemoteConnectionTestResult, + RemoteHostsResult +} from './remote-target' function createSseStreamId(): string { return globalThis.crypto?.randomUUID?.() ?? `sse-${Date.now()}-${Math.random().toString(16).slice(2)}` @@ -167,6 +174,7 @@ export class KunRuntimeProvider implements AgentProvider { providerId?: string model?: string systemPrompt?: string + remoteTarget?: NormalizedRemoteTarget }): Promise { const settings = await rendererRuntimeClient.getSettings() const runtime = getKunRuntimeSettings(settings) @@ -183,7 +191,8 @@ export class KunRuntimeProvider implements AgentProvider { sandboxMode: runtime.sandboxMode, ...(input.providerId?.trim() ? { providerId: input.providerId.trim() } : {}), ...(input.agentId?.trim() ? { agentId: input.agentId.trim() } : {}), - ...(input.systemPrompt?.trim() ? { systemPrompt: input.systemPrompt.trim() } : {}) + ...(input.systemPrompt?.trim() ? { systemPrompt: input.systemPrompt.trim() } : {}), + ...(input.remoteTarget ? { remoteTarget: input.remoteTarget } : {}) }) ) if (!response.ok) { @@ -195,6 +204,39 @@ export class KunRuntimeProvider implements AgentProvider { )) } + async listRemoteHosts(): Promise { + const response = await rendererRuntimeClient.runtimeRequest(KUN_REMOTE_HOSTS_PATH, 'GET') + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to list remote hosts')) + } + return readRuntimeJson( + response.body, + 'runtime returned an invalid remote hosts response' + ) + } + + async testRemoteConnection(input: { + alias: string + remoteDir?: string + }): Promise { + const body = { + alias: input.alias.trim(), + ...(input.remoteDir?.trim() ? { remoteDir: input.remoteDir.trim() } : {}) + } + const response = await rendererRuntimeClient.runtimeRequest( + KUN_REMOTE_TEST_PATH, + 'POST', + JSON.stringify(body) + ) + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to test remote connection')) + } + return readRuntimeJson( + response.body, + 'runtime returned an invalid remote connection test response' + ) + } + async getThreadDetail(threadId: string): Promise<{ blocks: ChatBlock[] latestSeq: number diff --git a/src/renderer/src/agent/remote-target.ts b/src/renderer/src/agent/remote-target.ts new file mode 100644 index 000000000..ab7f40c7c --- /dev/null +++ b/src/renderer/src/agent/remote-target.ts @@ -0,0 +1,69 @@ +/** + * Renderer-side, secret-free SSH remote target input. + * + * The UI only sends an SSH alias, remote working directory, run mode, and + * safety metadata. Credentials remain in the user's ssh config / ssh-agent. + */ + +export type RemoteRunMode = 'observe' | 'develop' | 'operations' | 'deploy' + +export type RemoteTargetInput = { + alias: string + host?: string + remoteDir?: string + runMode?: RemoteRunMode + production?: boolean + profileName?: string + protectedPaths?: string[] +} + +export type NormalizedRemoteTarget = { + kind: 'ssh' + alias: string + host?: string + remoteDir?: string + runMode: RemoteRunMode + production: boolean + profileName?: string + protectedPaths: string[] +} + +export function normalizeRemoteTarget(input: RemoteTargetInput): NormalizedRemoteTarget { + return { + kind: 'ssh', + alias: input.alias.trim(), + ...(input.host?.trim() ? { host: input.host.trim() } : {}), + ...(input.remoteDir?.trim() ? { remoteDir: input.remoteDir.trim() } : {}), + runMode: input.runMode ?? 'observe', + production: input.production ?? false, + ...(input.profileName?.trim() ? { profileName: input.profileName.trim() } : {}), + protectedPaths: (input.protectedPaths ?? []).map((path) => path.trim()).filter(Boolean) + } +} + +export type RemoteHostSummary = { + alias: string + hostName?: string + user?: string + port?: number + proxyJump?: string +} + +export type RemoteHostsResult = { + hosts: RemoteHostSummary[] + configFound: boolean +} + +export type RemoteConnectionTestResult = { + ok: boolean + alias: string + remoteDir?: string + status: 'connected' | 'connecting' | 'degraded' | 'disconnected' | 'error' + latencyMs?: number + os?: string + branch?: string + dirty?: boolean + repoRoot?: string + tools: Record + error?: string +} diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index f43b10bf2..259779fd4 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -9,6 +9,11 @@ import type { CoreRuntimeSkillJson, CoreRuntimeToolDiagnosticsJson } from './kun-contract' +import type { + NormalizedRemoteTarget, + RemoteConnectionTestResult, + RemoteHostsResult +} from './remote-target' import type { ApprovalPolicy, SandboxMode } from '@shared/app-settings' export type ToolItemKind = 'tool_call' | 'command_execution' | 'file_change' @@ -140,6 +145,7 @@ export type NormalizedThread = { forkedFromTurnCount?: number goal?: ThreadGoal | null todos?: ThreadTodoList | null + remoteTarget?: NormalizedRemoteTarget } export type ThreadGoalStatus = @@ -457,7 +463,7 @@ export interface AgentProvider { } connect(): Promise listThreads(options?: ThreadListOptions): Promise - createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string }): Promise + createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string; remoteTarget?: NormalizedRemoteTarget }): Promise getThreadDetail(threadId: string): Promise<{ blocks: ChatBlock[] latestSeq: number @@ -505,6 +511,8 @@ export interface AgentProvider { clearMcpOAuthCredentials?(serverId?: string): Promise authorizeMcpOAuthCredentials?(serverId: string): Promise listSkills?(): Promise + listRemoteHosts?(): Promise + testRemoteConnection?(input: { alias: string; remoteDir?: string }): Promise uploadAttachment?(input: { name: string mimeType?: string diff --git a/src/renderer/src/components/RemoteTargetPicker.tsx b/src/renderer/src/components/RemoteTargetPicker.tsx new file mode 100644 index 000000000..37ac25b43 --- /dev/null +++ b/src/renderer/src/components/RemoteTargetPicker.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, type ReactElement } from 'react' +import type { + RemoteConnectionTestResult, + RemoteHostsResult, + RemoteRunMode, + RemoteTargetInput +} from '../agent/remote-target' + +const RUN_MODE_OPTIONS: Array<{ value: RemoteRunMode; label: string }> = [ + { value: 'observe', label: 'Observe · 只读' }, + { value: 'develop', label: 'Develop · 开发' }, + { value: 'operations', label: 'Operations · 运维' }, + { value: 'deploy', label: 'Deploy · 部署' } +] + +const fieldClass = + 'rounded-lg border border-ds-border-muted bg-ds-card px-2.5 py-1.5 text-xs text-ds-primary outline-none transition focus:border-ds-accent focus:ring-2 focus:ring-ds-accent/20 disabled:opacity-60' +const labelClass = 'text-[11px] font-medium uppercase tracking-[0.12em] text-ds-muted' + +export type RemoteTargetPickerProps = { + value: RemoteTargetInput | null + onChange: (value: RemoteTargetInput | null) => void + listHosts: () => Promise + testConnection: (input: { alias: string; remoteDir?: string }) => Promise + disabled?: boolean +} + +export function RemoteTargetPicker({ + value, + onChange, + listHosts, + testConnection, + disabled = false +}: RemoteTargetPickerProps): ReactElement { + const isRemote = value !== null + const [hosts, setHosts] = useState(null) + const [loadingHosts, setLoadingHosts] = useState(false) + const [hostsError, setHostsError] = useState(null) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + useEffect(() => { + if (!isRemote || hosts || loadingHosts) return + let cancelled = false + setLoadingHosts(true) + setHostsError(null) + listHosts() + .then((result) => { + if (!cancelled) setHosts(result) + }) + .catch((error: unknown) => { + if (!cancelled) setHostsError(error instanceof Error ? error.message : String(error)) + }) + .finally(() => { + if (!cancelled) setLoadingHosts(false) + }) + return () => { + cancelled = true + } + }, [hosts, isRemote, listHosts, loadingHosts]) + + const patchValue = (patch: Partial): void => { + onChange({ alias: '', runMode: 'observe', production: false, protectedPaths: [], ...value, ...patch }) + setTestResult(null) + } + + const setRemoteEnabled = (enabled: boolean): void => { + setTestResult(null) + if (!enabled) { + onChange(null) + return + } + onChange({ alias: '', runMode: 'observe', production: false, protectedPaths: [] }) + } + + const runTest = async (): Promise => { + const alias = value?.alias.trim() ?? '' + if (!alias) return + setTesting(true) + setTestResult(null) + try { + setTestResult(await testConnection({ + alias, + ...(value?.remoteDir?.trim() ? { remoteDir: value.remoteDir.trim() } : {}) + })) + } catch (error) { + setTestResult({ + ok: false, + alias, + status: 'error', + tools: {}, + error: error instanceof Error ? error.message : String(error) + }) + } finally { + setTesting(false) + } + } + + return ( +
+
+ Run location / 运行位置 + + + {isRemote ? ( + Applies to the next new thread. + ) : null} +
+ + {isRemote ? ( +
+ + + + + + + + +