Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/main/ipc/app-ipc-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
KUN_MEMORY_TEMPLATE,
KUN_MCP_OAUTH_SERVER_TEMPLATE,
KUN_MCP_OAUTH_TEMPLATE,
KUN_REMOTE_HOSTS_TEMPLATE,
KUN_REMOTE_TEST_TEMPLATE,
KUN_RUNTIME_INFO_TEMPLATE,
KUN_RUNTIME_TOOLS_TEMPLATE,
KUN_SESSION_RESUME_TEMPLATE,
Expand Down Expand Up @@ -154,6 +156,8 @@ const ENDPOINTS: readonly EndpointTemplate[] = [
compileEndpoint(KUN_HEALTH_TEMPLATE, ['GET']),
compileEndpoint(KUN_RUNTIME_INFO_TEMPLATE, ['GET']),
compileEndpoint(KUN_RUNTIME_TOOLS_TEMPLATE, ['GET']),
compileEndpoint(KUN_REMOTE_HOSTS_TEMPLATE, ['GET']),
compileEndpoint(KUN_REMOTE_TEST_TEMPLATE, ['POST']),
compileEndpoint(KUN_SKILLS_TEMPLATE, ['GET']),
compileEndpoint(KUN_ATTACHMENTS_TEMPLATE, ['POST']),
compileEndpoint(KUN_ATTACHMENT_DIAGNOSTICS_TEMPLATE, ['GET']),
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/agent/kun-contract.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,6 +40,7 @@ export type CoreThreadSummaryJson = {
forkedFromTurnCount?: number
goal?: CoreThreadGoalJson | null
todos?: CoreThreadTodoListJson | null
remoteTarget?: NormalizedRemoteTarget
createdAt: string
updatedAt: string
}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/agent/kun-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
112 changes: 112 additions & 0 deletions src/renderer/src/agent/kun-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,118 @@ describe('KunRuntimeProvider', () => {
expect(caps.approvals).toBe(true)
})

it('posts remote target metadata when creating a thread', async () => {
const runtimeRequest = vi.fn(async () => ({
ok: true,
status: 201,
body: JSON.stringify({
id: 'thr_remote',
title: 'Remote',
workspace: '/tmp/workspace',
model: defaultKunRuntimeSettings().model,
mode: 'agent',
status: 'idle',
remoteTarget: {
kind: 'ssh',
alias: 'prod-box',
remoteDir: '/srv/app',
runMode: 'develop',
production: true,
protectedPaths: ['.env']
},
createdAt: 't0',
updatedAt: 't1'
})
}))
installDsGui({ runtimeRequest })
const provider = new KunRuntimeProvider()

const thread = await provider.createThread({
workspace: '/tmp/workspace',
title: 'Remote',
mode: 'agent',
remoteTarget: {
kind: 'ssh',
alias: 'prod-box',
remoteDir: '/srv/app',
runMode: 'develop',
production: true,
protectedPaths: ['.env']
}
})

expect(runtimeRequest).toHaveBeenCalledWith(
'/v1/threads',
'POST',
JSON.stringify({
workspace: '/tmp/workspace',
title: 'Remote',
model: defaultKunRuntimeSettings().model,
mode: 'agent',
approvalPolicy: 'auto',
sandboxMode: 'danger-full-access',
remoteTarget: {
kind: 'ssh',
alias: 'prod-box',
remoteDir: '/srv/app',
runMode: 'develop',
production: true,
protectedPaths: ['.env']
}
})
)
expect(thread.remoteTarget?.alias).toBe('prod-box')
})

it('lists remote SSH hosts through the runtime', async () => {
const runtimeRequest = vi.fn(async () => ({
ok: true,
status: 200,
body: JSON.stringify({
configFound: true,
hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }]
})
}))
installDsGui({ runtimeRequest })
const provider = new KunRuntimeProvider()

await expect(provider.listRemoteHosts()).resolves.toEqual({
configFound: true,
hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }]
})
expect(runtimeRequest).toHaveBeenCalledWith('/v1/remote/hosts', 'GET')
})

it('tests remote SSH connections through the runtime', async () => {
const runtimeRequest = vi.fn(async () => ({
ok: true,
status: 200,
body: JSON.stringify({
ok: true,
alias: 'dev',
remoteDir: '/srv/app',
status: 'connected',
latencyMs: 42,
os: 'linux',
branch: 'main',
dirty: false,
repoRoot: '/srv/app',
tools: { bash: true, read: true }
})
}))
installDsGui({ runtimeRequest })
const provider = new KunRuntimeProvider()

const result = await provider.testRemoteConnection({ alias: 'dev', remoteDir: '/srv/app' })

expect(runtimeRequest).toHaveBeenCalledWith(
'/v1/remote/test',
'POST',
JSON.stringify({ alias: 'dev', remoteDir: '/srv/app' })
)
expect(result).toMatchObject({ ok: true, status: 'connected', latencyMs: 42 })
})

it('reports invalid runtime JSON responses with a stable error message', async () => {
installDsGui({
runtimeRequest: vi.fn(async () => ({
Expand Down
44 changes: 43 additions & 1 deletion src/renderer/src/agent/kun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`
Expand Down Expand Up @@ -167,6 +174,7 @@ export class KunRuntimeProvider implements AgentProvider {
providerId?: string
model?: string
systemPrompt?: string
remoteTarget?: NormalizedRemoteTarget
}): Promise<NormalizedThread> {
const settings = await rendererRuntimeClient.getSettings()
const runtime = getKunRuntimeSettings(settings)
Expand All @@ -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) {
Expand All @@ -195,6 +204,39 @@ export class KunRuntimeProvider implements AgentProvider {
))
}

async listRemoteHosts(): Promise<RemoteHostsResult> {
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<RemoteHostsResult>(
response.body,
'runtime returned an invalid remote hosts response'
)
}

async testRemoteConnection(input: {
alias: string
remoteDir?: string
}): Promise<RemoteConnectionTestResult> {
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<RemoteConnectionTestResult>(
response.body,
'runtime returned an invalid remote connection test response'
)
}

async getThreadDetail(threadId: string): Promise<{
blocks: ChatBlock[]
latestSeq: number
Expand Down
69 changes: 69 additions & 0 deletions src/renderer/src/agent/remote-target.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>
error?: string
}
10 changes: 9 additions & 1 deletion src/renderer/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -140,6 +145,7 @@ export type NormalizedThread = {
forkedFromTurnCount?: number
goal?: ThreadGoal | null
todos?: ThreadTodoList | null
remoteTarget?: NormalizedRemoteTarget
}

export type ThreadGoalStatus =
Expand Down Expand Up @@ -457,7 +463,7 @@ export interface AgentProvider {
}
connect(): Promise<void>
listThreads(options?: ThreadListOptions): Promise<NormalizedThread[]>
createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string }): Promise<NormalizedThread>
createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string; remoteTarget?: NormalizedRemoteTarget }): Promise<NormalizedThread>
getThreadDetail(threadId: string): Promise<{
blocks: ChatBlock[]
latestSeq: number
Expand Down Expand Up @@ -505,6 +511,8 @@ export interface AgentProvider {
clearMcpOAuthCredentials?(serverId?: string): Promise<string[]>
authorizeMcpOAuthCredentials?(serverId: string): Promise<import('./kun-contract').CoreMcpOAuthAuthorizeResponseJson>
listSkills?(): Promise<CoreRuntimeSkillJson[]>
listRemoteHosts?(): Promise<RemoteHostsResult>
testRemoteConnection?(input: { alias: string; remoteDir?: string }): Promise<RemoteConnectionTestResult>
uploadAttachment?(input: {
name: string
mimeType?: string
Expand Down
Loading