Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions kun/src/adapters/tool/builtin-bash-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions kun/src/adapters/tool/builtin-file-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<partial json>" }`
Expand Down Expand Up @@ -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 : ''
Expand Down Expand Up @@ -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 : ''
Expand Down
2 changes: 2 additions & 0 deletions kun/src/adapters/tool/builtin-read-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions kun/src/adapters/tool/builtin-search-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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)
Expand Down Expand Up @@ -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 : '.'
Expand Down Expand Up @@ -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)
Expand Down
102 changes: 102 additions & 0 deletions kun/src/adapters/tool/remote-command-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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 })
})
})
100 changes: 100 additions & 0 deletions kun/src/adapters/tool/remote-command-tool.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
isError?: boolean
}

export async function executeRemoteCommand(input: {
handle: RemoteExecutionHandle
command: string
timeoutSeconds: number
context: ToolHostContext
}): Promise<RemoteCommandToolResult> {
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)
}
}
Loading