diff --git a/kun/src/adapters/tool/builtin-bash-tool.ts b/kun/src/adapters/tool/builtin-bash-tool.ts index 44654bb9f..3a040d6ed 100644 --- a/kun/src/adapters/tool/builtin-bash-tool.ts +++ b/kun/src/adapters/tool/builtin-bash-tool.ts @@ -20,7 +20,6 @@ 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 @@ -700,17 +699,6 @@ 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 98c8b36de..2b9a89dcb 100644 --- a/kun/src/adapters/tool/builtin-file-tools.ts +++ b/kun/src/adapters/tool/builtin-file-tools.ts @@ -15,7 +15,6 @@ 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: "" }` @@ -54,7 +53,6 @@ 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 : '' @@ -113,7 +111,6 @@ 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 71cb860e5..d3c5c8817 100644 --- a/kun/src/adapters/tool/builtin-read-tool.ts +++ b/kun/src/adapters/tool/builtin-read-tool.ts @@ -10,7 +10,6 @@ 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! @@ -34,7 +33,6 @@ 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 423188334..a07f36bab 100644 --- a/kun/src/adapters/tool/builtin-search-tools.ts +++ b/kun/src/adapters/tool/builtin-search-tools.ts @@ -23,7 +23,6 @@ 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! @@ -42,7 +41,6 @@ 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) @@ -93,7 +91,6 @@ 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 : '.' @@ -201,7 +198,6 @@ 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 deleted file mode 100644 index d6c01bd94..000000000 --- a/kun/src/adapters/tool/remote-command-tool.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index d4573cacf..000000000 --- a/kun/src/adapters/tool/remote-command-tool.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 deleted file mode 100644 index f50c14ff4..000000000 --- a/kun/src/adapters/tool/remote-file-tools.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 3d2f90ca4..000000000 --- a/kun/src/adapters/tool/remote-file-tools.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * 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 deleted file mode 100644 index 119dd4a82..000000000 --- a/kun/src/adapters/tool/remote-port-forward-tool.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 14cc510ad..000000000 --- a/kun/src/adapters/tool/remote-port-forward-tool.ts +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index a6a3099d1..000000000 --- a/kun/src/contracts/remote.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 fa4e97fdc..4948a64d3 100644 --- a/kun/src/contracts/threads.ts +++ b/kun/src/contracts/threads.ts @@ -13,21 +13,6 @@ 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. * @@ -148,7 +133,6 @@ 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(), @@ -181,7 +165,6 @@ export const ThreadSummarySchema = ThreadSchema.pick({ status: true, approvalPolicy: true, sandboxMode: true, - remoteTarget: true, pinned: true, costBudgetUsd: true, costBudgetWarningSent: true, @@ -219,7 +202,6 @@ 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 @@ -305,7 +287,6 @@ 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(), @@ -319,7 +300,6 @@ 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 b5902190c..aa359e8fa 100644 --- a/kun/src/domain/thread.ts +++ b/kun/src/domain/thread.ts @@ -4,7 +4,6 @@ import type { ThreadGoal, ThreadTodoList, ThreadRelation, - ThreadRemoteTarget, ThreadStatus } from '../contracts/threads.js' import { @@ -34,7 +33,6 @@ export function createThreadRecord(input: { status?: ThreadStatus approvalPolicy?: ApprovalPolicy sandboxMode?: SandboxMode - remoteTarget?: ThreadRemoteTarget pinned?: boolean costBudgetUsd?: number costBudgetWarningSent?: boolean @@ -63,7 +61,6 @@ 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 } : {}), @@ -90,7 +87,7 @@ export function toThreadSummary( thread: ThreadEntity ): Pick< ThreadEntity, - 'id' | 'title' | 'titleAuto' | 'summary' | 'workspace' | 'model' | 'providerId' | 'agentId' | 'systemPrompt' | 'mode' | 'status' | 'approvalPolicy' | 'sandboxMode' | 'remoteTarget' | 'pinned' | 'createdAt' | 'updatedAt' + 'id' | 'title' | 'titleAuto' | 'summary' | 'workspace' | 'model' | 'providerId' | 'agentId' | 'systemPrompt' | 'mode' | 'status' | 'approvalPolicy' | 'sandboxMode' | 'pinned' | 'createdAt' | 'updatedAt' | 'costBudgetUsd' | 'costBudgetWarningSent' | 'relation' | 'parentThreadId' | 'forkedFromThreadId' | 'forkedFromTitle' | 'forkedAt' | 'forkedFromMessageCount' | 'forkedFromTurnCount' @@ -110,7 +107,6 @@ 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 36c2b0216..550f96f14 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -677,7 +677,6 @@ 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). */ @@ -2216,7 +2215,6 @@ export class AgentLoop { sandboxMode: NonNullable signal: AbortSignal }): ToolHostContext { - const executionTarget = this.opts.resolveExecutionTarget?.(input.threadId) return { threadId: input.threadId, turnId: input.turnId, @@ -2235,7 +2233,6 @@ 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 deleted file mode 100644 index 58989fc02..000000000 --- a/kun/src/ports/remote-execution.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 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 ca997ab17..808d0f4fa 100644 --- a/kun/src/ports/tool-host.ts +++ b/kun/src/ports/tool-host.ts @@ -3,7 +3,6 @@ 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 @@ -97,8 +96,6 @@ 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 deleted file mode 100644 index 3ae4c466f..000000000 --- a/kun/src/remote/remote-command-risk.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4a92954bb..000000000 --- a/kun/src/remote/remote-connect.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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 deleted file mode 100644 index be8b0711c..000000000 --- a/kun/src/remote/remote-connection.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 6b48a7a11..000000000 --- a/kun/src/remote/remote-connection.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5110d8679..000000000 --- a/kun/src/remote/remote-execution-handle.ts +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index b4e596c4b..000000000 --- a/kun/src/remote/remote-hosts-service.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index b05940ced..000000000 --- a/kun/src/remote/remote-hosts-service.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * 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 deleted file mode 100644 index 488ea11f6..000000000 --- a/kun/src/remote/remote-port-forward-manager.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 8fc3c9100..000000000 --- a/kun/src/remote/remote-port-forward-manager.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 4a18fe798..000000000 --- a/kun/src/remote/remote-profile.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index fac66a0c1..000000000 --- a/kun/src/remote/remote-profile.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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 deleted file mode 100644 index aace15e27..000000000 --- a/kun/src/remote/remote-run-mode.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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 deleted file mode 100644 index bbfe93317..000000000 --- a/kun/src/remote/remote-target-registry.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a5c5ae92f..000000000 --- a/kun/src/remote/remote-target-registry.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 41efa1162..000000000 --- a/kun/src/remote/remote-target.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 1591500eb..000000000 --- a/kun/src/remote/remote-target.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 deleted file mode 100644 index 18a310256..000000000 --- a/kun/src/remote/ssh-command.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 91a9c5d6e..000000000 --- a/kun/src/remote/ssh-command.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6380e24fd..000000000 --- a/kun/src/remote/ssh-config.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 979ae7c65..000000000 --- a/kun/src/remote/ssh-config.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 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 deleted file mode 100644 index 232344766..000000000 --- a/kun/src/remote/ssh-control.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 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 deleted file mode 100644 index 02ff60f7c..000000000 --- a/kun/src/remote/ssh-executor.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index 6f9d5dd3e..000000000 --- a/kun/src/remote/ssh-executor.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * 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 deleted file mode 100644 index a6b33dd01..000000000 --- a/kun/src/remote/ssh-port-forward.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 52b1235d6..000000000 --- a/kun/src/remote/ssh-port-forward.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 2d5bd7c82..ccb8611ef 100644 --- a/kun/src/server/routes/index.ts +++ b/kun/src/server/routes/index.ts @@ -57,7 +57,6 @@ 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' @@ -126,18 +125,6 @@ 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) @@ -218,19 +205,11 @@ export function buildRouter(runtime: ServerRuntime): Router { }) router.add('PATCH', '/v1/threads/:id', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() - 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 + return updateThread(runtime.threadService, ctx.params.id, request) }) router.add('DELETE', '/v1/threads/:id', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() - const response = await deleteThread(runtime.threadService, ctx.params.id) - if (response.status < 400) runtime.disposeThreadResources?.(ctx.params.id) - return response + return deleteThread(runtime.threadService, ctx.params.id) }) 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 deleted file mode 100644 index f201a7573..000000000 --- a/kun/src/server/routes/remote.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 4b6cb0f6f..22a8643f0 100644 --- a/kun/src/server/routes/server-runtime.ts +++ b/kun/src/server/routes/server-runtime.ts @@ -33,7 +33,6 @@ 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' @@ -80,7 +79,6 @@ 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 @@ -106,7 +104,6 @@ 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 c13353222..984ed8c02 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -26,9 +26,6 @@ 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, @@ -175,10 +172,6 @@ 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 @@ -275,7 +268,6 @@ 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' @@ -460,7 +452,6 @@ 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, @@ -565,7 +556,6 @@ 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, { @@ -597,19 +587,13 @@ export async function createKunServeRuntime( ...(memoryStore ? { memoryStore } : {}), ...(delegationRuntime ? { delegationRuntime } : {}), backgroundShellRuntime, - remote, modelClient, defaultModel: options.model, ...(options.roles ? { roles: options.roles } : {}), immutablePrefix: prefix, - async runTurn(threadId, turnId) { - await remoteTargetRegistry.prime(threadId) + runTurn(threadId, turnId) { return loop.runTurn(threadId, turnId) }, - disposeThreadResources(threadId) { - remoteTargetRegistry.evict(threadId) - remotePortForwardRuntime.disposeThreadResources(threadId) - }, resumeInterruptedGoals(threadIds) { return loop.resumeInterruptedGoals(threadIds) }, @@ -671,7 +655,6 @@ 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 21f488079..245282936 100644 --- a/kun/src/services/thread-service.ts +++ b/kun/src/services/thread-service.ts @@ -11,7 +11,6 @@ import type { ThreadMode, ThreadRecord, ThreadRelation, - ThreadRemoteTarget, ThreadStatus, ThreadTodoItem, ThreadTodoList, @@ -137,7 +136,6 @@ 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 } : {}), @@ -160,7 +158,6 @@ export class ThreadService { status?: ThreadStatus approvalPolicy?: ApprovalPolicy sandboxMode?: SandboxMode - remoteTarget?: ThreadRemoteTarget | null pinned?: boolean costBudgetUsd?: number | null costBudgetWarningSent?: boolean @@ -168,10 +165,8 @@ export class ThreadService { }): Promise { const current = await this.threadStore.get(threadId) if (!current) throw new Error(`thread not found: ${threadId}`) - const { costBudgetUsd, costBudgetWarningSent, remoteTarget, ...standardPatch } = patch + const { costBudgetUsd, costBudgetWarningSent, ...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 fc8616d46..0fcc07f1d 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -45,28 +45,6 @@ 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 5782fb1a3..428ade5f3 100644 --- a/kun/tests/thread-service.test.ts +++ b/kun/tests/thread-service.test.ts @@ -131,30 +131,6 @@ 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 3ac905754..c9fbd0326 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -11,8 +11,6 @@ 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, @@ -156,8 +154,6 @@ 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 1ce269138..4dafc3a54 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -1,5 +1,4 @@ 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' @@ -40,7 +39,6 @@ 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 fd02ac16c..aa8628341 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -70,7 +70,6 @@ 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 da7c97c7c..6815ce277 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -81,118 +81,6 @@ 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 9374470c0..49b5803af 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -15,8 +15,6 @@ 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, @@ -79,11 +77,6 @@ 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)}` @@ -174,7 +167,6 @@ export class KunRuntimeProvider implements AgentProvider { providerId?: string model?: string systemPrompt?: string - remoteTarget?: NormalizedRemoteTarget }): Promise { const settings = await rendererRuntimeClient.getSettings() const runtime = getKunRuntimeSettings(settings) @@ -191,8 +183,7 @@ 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.remoteTarget ? { remoteTarget: input.remoteTarget } : {}) + ...(input.systemPrompt?.trim() ? { systemPrompt: input.systemPrompt.trim() } : {}) }) ) if (!response.ok) { @@ -204,39 +195,6 @@ 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 deleted file mode 100644 index ab7f40c7c..000000000 --- a/src/renderer/src/agent/remote-target.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 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 259779fd4..f43b10bf2 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -9,11 +9,6 @@ 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' @@ -145,7 +140,6 @@ export type NormalizedThread = { forkedFromTurnCount?: number goal?: ThreadGoal | null todos?: ThreadTodoList | null - remoteTarget?: NormalizedRemoteTarget } export type ThreadGoalStatus = @@ -463,7 +457,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; remoteTarget?: NormalizedRemoteTarget }): Promise + createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string }): Promise getThreadDetail(threadId: string): Promise<{ blocks: ChatBlock[] latestSeq: number @@ -511,8 +505,6 @@ 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 deleted file mode 100644 index 37ac25b43..000000000 --- a/src/renderer/src/components/RemoteTargetPicker.tsx +++ /dev/null @@ -1,244 +0,0 @@ -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 ? ( -
- - - - - - - - -