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