From 2358a823426d3f1a88f6499d12b42d69f7e3672b Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Thu, 2 Jul 2026 00:19:33 +0800 Subject: [PATCH 1/3] Revert "feat(remote): add ssh port forwarding tool (#676)" This reverts commit 0718c873d5300d1c8e8451161e1473c7b6997ee1. --- .../tool/remote-port-forward-tool.test.ts | 69 ------- .../adapters/tool/remote-port-forward-tool.ts | 173 ------------------ .../remote-port-forward-manager.test.ts | 68 ------- kun/src/remote/remote-port-forward-manager.ts | 128 ------------- kun/src/remote/ssh-port-forward.test.ts | 16 -- kun/src/remote/ssh-port-forward.ts | 46 ----- kun/src/server/runtime-factory.ts | 5 - 7 files changed, 505 deletions(-) delete mode 100644 kun/src/adapters/tool/remote-port-forward-tool.test.ts delete mode 100644 kun/src/adapters/tool/remote-port-forward-tool.ts delete mode 100644 kun/src/remote/remote-port-forward-manager.test.ts delete mode 100644 kun/src/remote/remote-port-forward-manager.ts delete mode 100644 kun/src/remote/ssh-port-forward.test.ts delete mode 100644 kun/src/remote/ssh-port-forward.ts diff --git a/kun/src/adapters/tool/remote-port-forward-tool.test.ts b/kun/src/adapters/tool/remote-port-forward-tool.test.ts deleted file mode 100644 index 119dd4a82..000000000 --- a/kun/src/adapters/tool/remote-port-forward-tool.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createRemotePortForwardRuntime } from './remote-port-forward-tool.js' -import type { ToolHostContext } from '../../ports/tool-host.js' -import type { RemoteExecutionHandle } from '../../ports/remote-execution.js' -import type { SshChildProcess } from '../../remote/ssh-executor.js' - -function fakeChild(): SshChildProcess { - return { stdout: null, stderr: null, on: () => undefined, kill: vi.fn() } as unknown as SshChildProcess -} - -function sshContext(): ToolHostContext { - const handle = { - describe: () => ({ target: { kind: 'ssh', alias: 'prod' }, status: 'connected', production: false }), - production: false - } as unknown as RemoteExecutionHandle - return { - threadId: 't', - turnId: 'tn', - workspace: '/ws', - approvalPolicy: 'auto', - abortSignal: new AbortController().signal, - awaitApproval: vi.fn(async () => 'allow' as const), - executionTarget: handle - } -} - -function localContext(): ToolHostContext { - return { - threadId: 't', - turnId: 'tn', - workspace: '/ws', - approvalPolicy: 'auto', - abortSignal: new AbortController().signal, - awaitApproval: vi.fn(async () => 'allow' as const) - } -} - -describe('remote_port_forward tool', () => { - it('opens a tunnel and returns a local preview url once it is ready', async () => { - const spawn = vi.fn(() => fakeChild()) - const runtime = createRemotePortForwardRuntime({ spawn, allocatePort: async () => 54321, probeReady: async () => true }) - const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 3000 }, sshContext()) - const out = result.output as Record - expect(out.url).toBe('http://127.0.0.1:54321') - expect(out.status).toBe('ready') - }) - - it('refuses on a local thread', async () => { - const runtime = createRemotePortForwardRuntime({ spawn: () => fakeChild(), allocatePort: async () => 5000, probeReady: async () => true }) - const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 3000 }, localContext()) - expect(result.isError).toBe(true) - }) - - it('validates the remote port', async () => { - const runtime = createRemotePortForwardRuntime({ spawn: () => fakeChild(), allocatePort: async () => 5000, probeReady: async () => true }) - const result = await runtime.provider.tools[0].execute({ action: 'open', remotePort: 0 }, sshContext()) - expect(result.isError).toBe(true) - }) - - it('disposes per thread', async () => { - const child = fakeChild() - const runtime = createRemotePortForwardRuntime({ spawn: () => child, allocatePort: async () => 5000, probeReady: async () => true }) - await runtime.provider.tools[0].execute({ action: 'open', remotePort: 8080 }, sshContext()) - runtime.disposeThreadResources('t') - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - const listed = await runtime.provider.tools[0].execute({ action: 'list' }, sshContext()) - expect((listed.output as { forwards: unknown[] }).forwards).toHaveLength(0) - }) -}) diff --git a/kun/src/adapters/tool/remote-port-forward-tool.ts b/kun/src/adapters/tool/remote-port-forward-tool.ts deleted file mode 100644 index 14cc510ad..000000000 --- a/kun/src/adapters/tool/remote-port-forward-tool.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { spawn as nodeSpawn } from 'node:child_process' -import { connect as netConnect, createServer } from 'node:net' -import { LocalToolHost, type LocalTool } from './local-tool-host.js' -import { RemotePortForwardManager } from '../../remote/remote-port-forward-manager.js' -import type { SshChildProcess, SshSpawnFn } from '../../remote/ssh-executor.js' -import { isSshTarget } from '../../remote/remote-target.js' -import type { CapabilityToolProvider } from './capability-registry.js' -import { createApprovalRequest } from '../../domain/approval.js' - -const realSpawn: SshSpawnFn = (command, args) => - nodeSpawn(command, [...args], { shell: false }) as unknown as SshChildProcess - -function probeLocalPort(port: number, timeoutMs = 500): Promise { - return new Promise((resolve) => { - const socket = netConnect({ port, host: '127.0.0.1' }) - let settled = false - const done = (ok: boolean): void => { - if (settled) return - settled = true - socket.destroy() - resolve(ok) - } - socket.once('connect', () => done(true)) - socket.once('error', () => done(false)) - socket.setTimeout(timeoutMs, () => done(false)) - }) -} - -function allocateFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = createServer() - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - server.close(() => (port ? resolve(port) : reject(new Error('could not allocate a local port')))) - }) - }) -} - -export type RemotePortForwardToolDeps = { - spawn?: SshSpawnFn - allocatePort?: () => Promise - probeReady?: (port: number) => Promise - readyTimeoutMs?: number -} - -export type RemotePortForwardRuntime = { - provider: CapabilityToolProvider - disposeThreadResources(threadId: string): void - shutdown(): void -} - -export function createRemotePortForwardRuntime(deps: RemotePortForwardToolDeps = {}): RemotePortForwardRuntime { - const readyTimeoutMs = deps.readyTimeoutMs ?? 5_000 - const probeReady = deps.probeReady ?? ((port: number) => probeLocalPort(port)) - const managers = new Map() - - const managerFor = (threadId: string): RemotePortForwardManager => { - const existing = managers.get(threadId) - if (existing) return existing - const manager = new RemotePortForwardManager({ - spawn: deps.spawn ?? realSpawn, - allocatePort: deps.allocatePort ?? allocateFreePort, - probeReady - }) - managers.set(threadId, manager) - return manager - } - - const tool = LocalToolHost.defineTool({ - name: 'remote_port_forward', - description: - 'Preview a remote service locally over an SSH tunnel (remote thread only). ' + - 'Default action is open; list and close are available for cleanup.', - inputSchema: { - type: 'object', - properties: { - action: { type: 'string', enum: ['open', 'list', 'close'] }, - remotePort: { type: 'number' }, - localPort: { type: 'number' }, - host: { type: 'string' }, - label: { type: 'string' }, - id: { type: 'string' } - }, - additionalProperties: false - }, - policy: 'on-request', - shouldAdvertise: (context) => context.executionTarget?.describe().target.kind === 'ssh', - execute: async (args, context) => { - const executionTarget = context.executionTarget - const target = executionTarget?.describe().target - if (!target || !isSshTarget(target)) { - return { output: { error: 'remote_port_forward requires a thread bound to an SSH target' }, isError: true } - } - const manager = managerFor(context.threadId) - const action = typeof args.action === 'string' ? args.action : 'open' - try { - if (action === 'list') { - return { output: { action, forwards: manager.list() } } - } - if (action === 'close') { - const id = typeof args.id === 'string' ? args.id : '' - const closed = manager.close(id) - return { output: { action, id, closed }, isError: !closed } - } - - const remotePort = typeof args.remotePort === 'number' ? args.remotePort : 0 - if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { - return { output: { error: 'a valid remotePort (1-65535) is required' }, isError: true } - } - const rawLocalPort = typeof args.localPort === 'number' ? args.localPort : undefined - if (rawLocalPort !== undefined && rawLocalPort !== 0 && (rawLocalPort < 1024 || rawLocalPort > 65535)) { - return { output: { error: 'localPort must be omitted/0 or 1024-65535' }, isError: true } - } - if (executionTarget.production && context.approvalPolicy === 'auto') { - const approval = await context.awaitApproval(createApprovalRequest({ - id: 'appr_remote_port_forward_' + context.turnId, - threadId: context.threadId, - turnId: context.turnId, - toolName: 'remote_port_forward', - summary: 'Open SSH port forward for ' + target.alias + ':' + String(remotePort) - })) - if (approval !== 'allow') { - return { output: { error: 'remote port forward was not approved' }, isError: true } - } - } - const forward = await manager.open({ - alias: target.alias, - remotePort, - ...(typeof args.host === 'string' && args.host.trim() ? { remoteHost: args.host.trim() } : {}), - ...(typeof rawLocalPort === 'number' && rawLocalPort > 0 ? { localPort: rawLocalPort } : {}), - ...(readyTimeoutMs > 0 ? { waitForReadyMs: readyTimeoutMs } : {}) - }) - return { - output: { - target: 'ssh', - status: 'ready', - url: forward.url, - localPort: forward.localPort, - remotePort: forward.remotePort, - host: forward.remoteHost, - ...(typeof args.label === 'string' && args.label.trim() ? { label: args.label.trim() } : {}) - } - } - } catch (error) { - return { output: { error: error instanceof Error ? error.message : String(error) }, isError: true } - } - } - }) - - return { - provider: { - id: 'remote-port-forward', - kind: 'built-in', - enabled: true, - available: true, - tools: [tool] - }, - disposeThreadResources(threadId: string): void { - const manager = managers.get(threadId) - if (!manager) return - manager.closeAll() - managers.delete(threadId) - }, - shutdown(): void { - for (const manager of managers.values()) { - manager.closeAll() - } - managers.clear() - } - } -} diff --git a/kun/src/remote/remote-port-forward-manager.test.ts b/kun/src/remote/remote-port-forward-manager.test.ts deleted file mode 100644 index 488ea11f6..000000000 --- a/kun/src/remote/remote-port-forward-manager.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RemotePortForwardManager } from './remote-port-forward-manager.js' -import type { SshChildProcess } from './ssh-executor.js' - -function fakeChild() { - const handlers: Record void)[]> = {} - const child = { - stdout: null, - stderr: null, - on(event: string, listener: (...a: unknown[]) => void) { - ;(handlers[event] ??= []).push(listener as never) - }, - kill: vi.fn() - } as unknown as SshChildProcess - return { child, emit: (e: string, ...a: unknown[]) => handlers[e]?.forEach((h) => h(...a)) } -} - -describe('RemotePortForwardManager', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('opens a tunnel on an allocated local port and returns a preview url', async () => { - const { child } = fakeChild() - const spawn = vi.fn(() => child) - const manager = new RemotePortForwardManager({ spawn, allocatePort: async () => 54321 }) - const forward = await manager.open({ alias: 'prod', remotePort: 3000 }) - expect(forward.localPort).toBe(54321) - expect(forward.url).toBe('http://127.0.0.1:54321') - expect(spawn).toHaveBeenCalledWith('ssh', expect.arrayContaining(['-N', '-T', '-L'])) - expect(manager.list()).toHaveLength(1) - }) - - it('tears down and throws when the tunnel never becomes ready', async () => { - const { child } = fakeChild() - let now = 0 - const manager = new RemotePortForwardManager({ - spawn: () => child, - allocatePort: async () => 5000, - probeReady: async () => false, - delay: async () => { now += 100 }, - nowMs: () => now - }) - await expect(manager.open({ alias: 'prod', remotePort: 3000, waitForReadyMs: 200 })).rejects.toThrow(/did not become ready/) - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(manager.list()).toHaveLength(0) - }) - - it('drops a tunnel from the registry when its child exits on its own', async () => { - const { child, emit } = fakeChild() - const manager = new RemotePortForwardManager({ spawn: () => child, allocatePort: async () => 5000 }) - await manager.open({ alias: 'prod', remotePort: 8080 }) - expect(manager.list()).toHaveLength(1) - emit('close', 0, null) - expect(manager.list()).toHaveLength(0) - }) - - it('escalates from SIGTERM to SIGKILL when a tunnel does not exit', async () => { - vi.useFakeTimers() - const { child } = fakeChild() - const manager = new RemotePortForwardManager({ spawn: () => child, allocatePort: async () => 5000 }) - const forward = await manager.open({ alias: 'prod', remotePort: 8080 }) - manager.close(forward.id) - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - await vi.advanceTimersByTimeAsync(2_000) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - }) -}) diff --git a/kun/src/remote/remote-port-forward-manager.ts b/kun/src/remote/remote-port-forward-manager.ts deleted file mode 100644 index 8fc3c9100..000000000 --- a/kun/src/remote/remote-port-forward-manager.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { buildSshPortForwardArgv, type SshPortForwardOptions } from './ssh-port-forward.js' -import type { SshChildProcess, SshSpawnFn } from './ssh-executor.js' - -export type PortForward = { - id: string - localPort: number - remotePort: number - remoteHost: string - alias: string - url: string -} - -export type AllocatePortFn = () => Promise - -export type RemotePortForwardManagerDeps = { - spawn: SshSpawnFn - allocatePort: AllocatePortFn - nowMs?: () => number - probeReady?: (port: number) => Promise - delay?: (ms: number) => Promise -} - -type ActiveForward = { - forward: PortForward - child: SshChildProcess - hardKillTimer?: ReturnType -} - -const DEFAULT_READY_PROBE_INTERVAL_MS = 150 - -function defaultDelay(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (timer && typeof timer === 'object' && 'unref' in timer) { - ;(timer as { unref: () => void }).unref() - } - }) -} - -export class RemotePortForwardManager { - private readonly forwards = new Map() - private counter = 0 - - constructor(private readonly deps: RemotePortForwardManagerDeps) {} - - async open(options: Omit & { localPort?: number; waitForReadyMs?: number }): Promise { - const localPort = options.localPort ?? (await this.deps.allocatePort()) - const argv = buildSshPortForwardArgv({ ...options, localPort }) - const child = this.deps.spawn('ssh', argv) - const id = 'pf_' + String(++this.counter) - const remoteHost = options.remoteHost ?? '127.0.0.1' - const forward: PortForward = { - id, - localPort, - remotePort: options.remotePort, - remoteHost, - alias: options.alias, - url: 'http://127.0.0.1:' + String(localPort) - } - const active: ActiveForward = { forward, child } - child.on('close', () => { - if (active.hardKillTimer) clearTimeout(active.hardKillTimer) - this.forwards.delete(id) - }) - child.on('error', () => { - if (active.hardKillTimer) clearTimeout(active.hardKillTimer) - this.forwards.delete(id) - }) - this.forwards.set(id, active) - - if (options.waitForReadyMs && options.waitForReadyMs > 0 && this.deps.probeReady) { - await this.waitForReady(id, localPort, options.waitForReadyMs) - } - return forward - } - - list(): PortForward[] { - return [...this.forwards.values()].map((entry) => entry.forward) - } - - close(id: string): boolean { - const entry = this.forwards.get(id) - if (!entry) return false - this.terminate(entry) - this.forwards.delete(id) - return true - } - - closeAll(): void { - for (const entry of this.forwards.values()) { - this.terminate(entry) - } - this.forwards.clear() - } - - private async waitForReady(id: string, port: number, timeoutMs: number): Promise { - const now = this.deps.nowMs ?? (() => Date.now()) - const delay = this.deps.delay ?? defaultDelay - const probe = this.deps.probeReady! - const deadline = now() + timeoutMs - for (;;) { - if (!this.forwards.has(id)) { - throw new Error('SSH tunnel exited before it became ready') - } - if (await probe(port)) return - if (now() >= deadline) { - this.close(id) - throw new Error('SSH tunnel did not become ready within ' + String(timeoutMs) + 'ms') - } - await delay(DEFAULT_READY_PROBE_INTERVAL_MS) - } - } - - private terminate(entry: ActiveForward): void { - entry.child.kill('SIGTERM') - if (entry.hardKillTimer) return - entry.hardKillTimer = setTimeout(() => { - try { - entry.child.kill('SIGKILL') - } catch { - // already exited - } - }, 2_000) - if (typeof entry.hardKillTimer === 'object' && 'unref' in entry.hardKillTimer) { - ;(entry.hardKillTimer as { unref: () => void }).unref() - } - } -} diff --git a/kun/src/remote/ssh-port-forward.test.ts b/kun/src/remote/ssh-port-forward.test.ts deleted file mode 100644 index a6b33dd01..000000000 --- a/kun/src/remote/ssh-port-forward.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildSshPortForwardArgv } from './ssh-port-forward.js' - -describe('ssh-port-forward', () => { - it('builds a loopback-bound -L tunnel that runs no remote command', () => { - expect(buildSshPortForwardArgv({ alias: 'prod', localPort: 5173, remotePort: 3000 })).toEqual([ - '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ExitOnForwardFailure=yes', - '-N', '-T', '-L', '127.0.0.1:5173:127.0.0.1:3000', 'prod' - ]) - }) - - it('rejects invalid ports', () => { - expect(() => buildSshPortForwardArgv({ alias: 'p', localPort: 0, remotePort: 0 })).toThrow(/invalid port/) - expect(() => buildSshPortForwardArgv({ alias: 'p', localPort: 70000, remotePort: 80 })).toThrow(/invalid port/) - }) -}) diff --git a/kun/src/remote/ssh-port-forward.ts b/kun/src/remote/ssh-port-forward.ts deleted file mode 100644 index 52b1235d6..000000000 --- a/kun/src/remote/ssh-port-forward.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { isIP } from 'node:net' -import { assertSafeSshAlias, buildSshBaseArgs, type SshConnectionOptions } from './ssh-command.js' - -export type SshPortForwardOptions = SshConnectionOptions & { - localPort: number - remotePort: number - remoteHost?: string - localBindHost?: string -} - -export function buildSshPortForwardArgv(options: SshPortForwardOptions): string[] { - const localBindHost = options.localBindHost ?? '127.0.0.1' - const remoteHost = options.remoteHost ?? '127.0.0.1' - if (!isValidPort(options.localPort) || !isValidPort(options.remotePort)) { - throw new Error('invalid port for SSH forward (1-65535 required)') - } - const forwardSpec = formatForwardHost(localBindHost) + ':' + options.localPort + ':' + formatForwardHost(remoteHost) + ':' + options.remotePort - return [ - ...buildSshBaseArgs({ - ...options, - batchMode: options.batchMode ?? true, - connectTimeoutSec: options.connectTimeoutSec ?? 10 - }), - '-o', 'ExitOnForwardFailure=yes', - '-N', - '-T', - '-L', forwardSpec, - assertSafeSshAlias(options.alias) - ] -} - -function isValidPort(port: number): boolean { - return Number.isInteger(port) && port >= 1 && port <= 65535 -} - -function formatForwardHost(host: string): string { - const value = host.trim() - if (!value) throw new Error('SSH forward host must not be empty') - const version = isIP(value) - if (version === 4) return value - if (version === 6) return '[' + value + ']' - if (!/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$/.test(value)) { - throw new Error('invalid SSH forward host: ' + host) - } - return value -} diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index c13353222..47bdb5b52 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -26,7 +26,6 @@ import { buildDelegationToolProviders } from '../adapters/tool/delegation-tool-p import { buildWebToolProviders } from '../adapters/tool/web-tool-provider.js' import { buildImageGenToolProviders } from '../adapters/tool/image-gen-tool-provider.js' import { buildComputerUseToolProviders } from '../adapters/tool/computer-use-tool-provider.js' -import { createRemotePortForwardRuntime } from '../adapters/tool/remote-port-forward-tool.js' import { createRemoteHostsService } from '../remote/remote-hosts-service.js' import { RemoteTargetRegistry } from '../remote/remote-target-registry.js' import { @@ -275,7 +274,6 @@ export async function createKunServeRuntime( const backgroundShellTool = createBackgroundShellTool({ listBackgroundSessions: (threadId) => backgroundShellRuntime.listSessions(threadId) }) - const remotePortForwardRuntime = createRemotePortForwardRuntime() const withBackgroundShellTools = (tools: LocalTool[]): LocalTool[] => { const mapped = tools.map((tool) => tool.name === 'bash' @@ -460,7 +458,6 @@ export async function createKunServeRuntime( // Host control is available to the top-level agent only, never to // delegated subagents (which use childRegistry/baseToolProviders). ...computerUseProviders.providers, - remotePortForwardRuntime.provider, { id: 'goal', kind: 'gui' as const, @@ -608,7 +605,6 @@ export async function createKunServeRuntime( }, disposeThreadResources(threadId) { remoteTargetRegistry.evict(threadId) - remotePortForwardRuntime.disposeThreadResources(threadId) }, resumeInterruptedGoals(threadIds) { return loop.resumeInterruptedGoals(threadIds) @@ -671,7 +667,6 @@ export async function createKunServeRuntime( shutdown: async () => { try { loop.shutdownGoalResume() - remotePortForwardRuntime.shutdown() await mcpProviders.close() } finally { await stores.shutdown?.() From 31ca1bc1a3b14ba4556b7cf79c2cf7467967251f Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Thu, 2 Jul 2026 00:19:33 +0800 Subject: [PATCH 2/3] Revert "feat(remote): add target picker UI (#675)" This reverts commit 3340787557cbe88b73930a3b32994f7af80aa21b. --- 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 | 244 ------------------ src/renderer/src/components/Workbench.tsx | 26 -- .../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 - 14 files changed, 6 insertions(+), 542 deletions(-) delete mode 100644 src/renderer/src/agent/remote-target.ts delete 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 3ac905754..c9fbd0326 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -11,8 +11,6 @@ import { KUN_MEMORY_TEMPLATE, KUN_MCP_OAUTH_SERVER_TEMPLATE, KUN_MCP_OAUTH_TEMPLATE, - KUN_REMOTE_HOSTS_TEMPLATE, - KUN_REMOTE_TEST_TEMPLATE, KUN_RUNTIME_INFO_TEMPLATE, KUN_RUNTIME_TOOLS_TEMPLATE, KUN_SESSION_RESUME_TEMPLATE, @@ -156,8 +154,6 @@ const ENDPOINTS: readonly EndpointTemplate[] = [ compileEndpoint(KUN_HEALTH_TEMPLATE, ['GET']), compileEndpoint(KUN_RUNTIME_INFO_TEMPLATE, ['GET']), compileEndpoint(KUN_RUNTIME_TOOLS_TEMPLATE, ['GET']), - compileEndpoint(KUN_REMOTE_HOSTS_TEMPLATE, ['GET']), - compileEndpoint(KUN_REMOTE_TEST_TEMPLATE, ['POST']), compileEndpoint(KUN_SKILLS_TEMPLATE, ['GET']), compileEndpoint(KUN_ATTACHMENTS_TEMPLATE, ['POST']), compileEndpoint(KUN_ATTACHMENT_DIAGNOSTICS_TEMPLATE, ['GET']), diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index 1ce269138..4dafc3a54 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -1,5 +1,4 @@ import { GUI_PLAN_CREATE_PLAN_TOOL_NAME } from '@shared/gui-plan' -import type { NormalizedRemoteTarget } from './remote-target' export type CoreThreadStatus = 'idle' | 'running' | 'archived' | 'deleted' export type CoreTurnStatus = 'queued' | 'running' | 'completed' | 'failed' | 'aborted' @@ -40,7 +39,6 @@ export type CoreThreadSummaryJson = { forkedFromTurnCount?: number goal?: CoreThreadGoalJson | null todos?: CoreThreadTodoListJson | null - remoteTarget?: NormalizedRemoteTarget createdAt: string updatedAt: string } diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index fd02ac16c..aa8628341 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -70,7 +70,6 @@ export function threadFromCore(thread: CoreThreadSummaryJson): NormalizedThread forkedAt: thread.forkedAt, forkedFromMessageCount: thread.forkedFromMessageCount, forkedFromTurnCount: thread.forkedFromTurnCount, - ...(thread.remoteTarget ? { remoteTarget: thread.remoteTarget } : {}), goal: thread.goal ? goalFromCore(thread.goal) : null, todos: thread.todos ? todosFromCore(thread.todos) : null } diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index da7c97c7c..6815ce277 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -81,118 +81,6 @@ describe('KunRuntimeProvider', () => { expect(caps.approvals).toBe(true) }) - it('posts remote target metadata when creating a thread', async () => { - const runtimeRequest = vi.fn(async () => ({ - ok: true, - status: 201, - body: JSON.stringify({ - id: 'thr_remote', - title: 'Remote', - workspace: '/tmp/workspace', - model: defaultKunRuntimeSettings().model, - mode: 'agent', - status: 'idle', - remoteTarget: { - kind: 'ssh', - alias: 'prod-box', - remoteDir: '/srv/app', - runMode: 'develop', - production: true, - protectedPaths: ['.env'] - }, - createdAt: 't0', - updatedAt: 't1' - }) - })) - installDsGui({ runtimeRequest }) - const provider = new KunRuntimeProvider() - - const thread = await provider.createThread({ - workspace: '/tmp/workspace', - title: 'Remote', - mode: 'agent', - remoteTarget: { - kind: 'ssh', - alias: 'prod-box', - remoteDir: '/srv/app', - runMode: 'develop', - production: true, - protectedPaths: ['.env'] - } - }) - - expect(runtimeRequest).toHaveBeenCalledWith( - '/v1/threads', - 'POST', - JSON.stringify({ - workspace: '/tmp/workspace', - title: 'Remote', - model: defaultKunRuntimeSettings().model, - mode: 'agent', - approvalPolicy: 'auto', - sandboxMode: 'danger-full-access', - remoteTarget: { - kind: 'ssh', - alias: 'prod-box', - remoteDir: '/srv/app', - runMode: 'develop', - production: true, - protectedPaths: ['.env'] - } - }) - ) - expect(thread.remoteTarget?.alias).toBe('prod-box') - }) - - it('lists remote SSH hosts through the runtime', async () => { - const runtimeRequest = vi.fn(async () => ({ - ok: true, - status: 200, - body: JSON.stringify({ - configFound: true, - hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }] - }) - })) - installDsGui({ runtimeRequest }) - const provider = new KunRuntimeProvider() - - await expect(provider.listRemoteHosts()).resolves.toEqual({ - configFound: true, - hosts: [{ alias: 'dev', hostName: 'dev.example.com', user: 'kun', port: 22 }] - }) - expect(runtimeRequest).toHaveBeenCalledWith('/v1/remote/hosts', 'GET') - }) - - it('tests remote SSH connections through the runtime', async () => { - const runtimeRequest = vi.fn(async () => ({ - ok: true, - status: 200, - body: JSON.stringify({ - ok: true, - alias: 'dev', - remoteDir: '/srv/app', - status: 'connected', - latencyMs: 42, - os: 'linux', - branch: 'main', - dirty: false, - repoRoot: '/srv/app', - tools: { bash: true, read: true } - }) - })) - installDsGui({ runtimeRequest }) - const provider = new KunRuntimeProvider() - - const result = await provider.testRemoteConnection({ alias: 'dev', remoteDir: '/srv/app' }) - - expect(runtimeRequest).toHaveBeenCalledWith( - '/v1/remote/test', - 'POST', - JSON.stringify({ alias: 'dev', remoteDir: '/srv/app' }) - ) - expect(result).toMatchObject({ ok: true, status: 'connected', latencyMs: 42 }) - }) - it('reports invalid runtime JSON responses with a stable error message', async () => { installDsGui({ runtimeRequest: vi.fn(async () => ({ diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 9374470c0..49b5803af 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -15,8 +15,6 @@ import { KUN_MEMORY_DIAGNOSTICS_PATH, KUN_MEMORY_PATH, KUN_MCP_OAUTH_PATH, - KUN_REMOTE_HOSTS_PATH, - KUN_REMOTE_TEST_PATH, KUN_RUNTIME_INFO_PATH, KUN_RUNTIME_TOOLS_PATH, KUN_SKILLS_PATH, @@ -79,11 +77,6 @@ import { threadFromCore } from './kun-mapper' import { rendererRuntimeClient } from './runtime-client' -import type { - NormalizedRemoteTarget, - RemoteConnectionTestResult, - RemoteHostsResult -} from './remote-target' function createSseStreamId(): string { return globalThis.crypto?.randomUUID?.() ?? `sse-${Date.now()}-${Math.random().toString(16).slice(2)}` @@ -174,7 +167,6 @@ export class KunRuntimeProvider implements AgentProvider { providerId?: string model?: string systemPrompt?: string - remoteTarget?: NormalizedRemoteTarget }): Promise { const settings = await rendererRuntimeClient.getSettings() const runtime = getKunRuntimeSettings(settings) @@ -191,8 +183,7 @@ export class KunRuntimeProvider implements AgentProvider { sandboxMode: runtime.sandboxMode, ...(input.providerId?.trim() ? { providerId: input.providerId.trim() } : {}), ...(input.agentId?.trim() ? { agentId: input.agentId.trim() } : {}), - ...(input.systemPrompt?.trim() ? { systemPrompt: input.systemPrompt.trim() } : {}), - ...(input.remoteTarget ? { remoteTarget: input.remoteTarget } : {}) + ...(input.systemPrompt?.trim() ? { systemPrompt: input.systemPrompt.trim() } : {}) }) ) if (!response.ok) { @@ -204,39 +195,6 @@ export class KunRuntimeProvider implements AgentProvider { )) } - async listRemoteHosts(): Promise { - const response = await rendererRuntimeClient.runtimeRequest(KUN_REMOTE_HOSTS_PATH, 'GET') - if (!response.ok) { - throw runtimeErrorToError(readRuntimeError(response.body, 'failed to list remote hosts')) - } - return readRuntimeJson( - response.body, - 'runtime returned an invalid remote hosts response' - ) - } - - async testRemoteConnection(input: { - alias: string - remoteDir?: string - }): Promise { - const body = { - alias: input.alias.trim(), - ...(input.remoteDir?.trim() ? { remoteDir: input.remoteDir.trim() } : {}) - } - const response = await rendererRuntimeClient.runtimeRequest( - KUN_REMOTE_TEST_PATH, - 'POST', - JSON.stringify(body) - ) - if (!response.ok) { - throw runtimeErrorToError(readRuntimeError(response.body, 'failed to test remote connection')) - } - return readRuntimeJson( - response.body, - 'runtime returned an invalid remote connection test response' - ) - } - async getThreadDetail(threadId: string): Promise<{ blocks: ChatBlock[] latestSeq: number diff --git a/src/renderer/src/agent/remote-target.ts b/src/renderer/src/agent/remote-target.ts deleted file mode 100644 index ab7f40c7c..000000000 --- a/src/renderer/src/agent/remote-target.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Renderer-side, secret-free SSH remote target input. - * - * The UI only sends an SSH alias, remote working directory, run mode, and - * safety metadata. Credentials remain in the user's ssh config / ssh-agent. - */ - -export type RemoteRunMode = 'observe' | 'develop' | 'operations' | 'deploy' - -export type RemoteTargetInput = { - alias: string - host?: string - remoteDir?: string - runMode?: RemoteRunMode - production?: boolean - profileName?: string - protectedPaths?: string[] -} - -export type NormalizedRemoteTarget = { - kind: 'ssh' - alias: string - host?: string - remoteDir?: string - runMode: RemoteRunMode - production: boolean - profileName?: string - protectedPaths: string[] -} - -export function normalizeRemoteTarget(input: RemoteTargetInput): NormalizedRemoteTarget { - return { - kind: 'ssh', - alias: input.alias.trim(), - ...(input.host?.trim() ? { host: input.host.trim() } : {}), - ...(input.remoteDir?.trim() ? { remoteDir: input.remoteDir.trim() } : {}), - runMode: input.runMode ?? 'observe', - production: input.production ?? false, - ...(input.profileName?.trim() ? { profileName: input.profileName.trim() } : {}), - protectedPaths: (input.protectedPaths ?? []).map((path) => path.trim()).filter(Boolean) - } -} - -export type RemoteHostSummary = { - alias: string - hostName?: string - user?: string - port?: number - proxyJump?: string -} - -export type RemoteHostsResult = { - hosts: RemoteHostSummary[] - configFound: boolean -} - -export type RemoteConnectionTestResult = { - ok: boolean - alias: string - remoteDir?: string - status: 'connected' | 'connecting' | 'degraded' | 'disconnected' | 'error' - latencyMs?: number - os?: string - branch?: string - dirty?: boolean - repoRoot?: string - tools: Record - error?: string -} diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index 259779fd4..f43b10bf2 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -9,11 +9,6 @@ import type { CoreRuntimeSkillJson, CoreRuntimeToolDiagnosticsJson } from './kun-contract' -import type { - NormalizedRemoteTarget, - RemoteConnectionTestResult, - RemoteHostsResult -} from './remote-target' import type { ApprovalPolicy, SandboxMode } from '@shared/app-settings' export type ToolItemKind = 'tool_call' | 'command_execution' | 'file_change' @@ -145,7 +140,6 @@ export type NormalizedThread = { forkedFromTurnCount?: number goal?: ThreadGoal | null todos?: ThreadTodoList | null - remoteTarget?: NormalizedRemoteTarget } export type ThreadGoalStatus = @@ -463,7 +457,7 @@ export interface AgentProvider { } connect(): Promise listThreads(options?: ThreadListOptions): Promise - createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string; remoteTarget?: NormalizedRemoteTarget }): Promise + createThread(input: { workspace?: string; title?: string; titleAuto?: boolean; mode?: string; agentId?: string; providerId?: string; model?: string; systemPrompt?: string }): Promise getThreadDetail(threadId: string): Promise<{ blocks: ChatBlock[] latestSeq: number @@ -511,8 +505,6 @@ export interface AgentProvider { clearMcpOAuthCredentials?(serverId?: string): Promise authorizeMcpOAuthCredentials?(serverId: string): Promise listSkills?(): Promise - listRemoteHosts?(): Promise - testRemoteConnection?(input: { alias: string; remoteDir?: string }): Promise uploadAttachment?(input: { name: string mimeType?: string diff --git a/src/renderer/src/components/RemoteTargetPicker.tsx b/src/renderer/src/components/RemoteTargetPicker.tsx deleted file mode 100644 index 37ac25b43..000000000 --- a/src/renderer/src/components/RemoteTargetPicker.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useEffect, useState, type ReactElement } from 'react' -import type { - RemoteConnectionTestResult, - RemoteHostsResult, - RemoteRunMode, - RemoteTargetInput -} from '../agent/remote-target' - -const RUN_MODE_OPTIONS: Array<{ value: RemoteRunMode; label: string }> = [ - { value: 'observe', label: 'Observe · 只读' }, - { value: 'develop', label: 'Develop · 开发' }, - { value: 'operations', label: 'Operations · 运维' }, - { value: 'deploy', label: 'Deploy · 部署' } -] - -const fieldClass = - 'rounded-lg border border-ds-border-muted bg-ds-card px-2.5 py-1.5 text-xs text-ds-primary outline-none transition focus:border-ds-accent focus:ring-2 focus:ring-ds-accent/20 disabled:opacity-60' -const labelClass = 'text-[11px] font-medium uppercase tracking-[0.12em] text-ds-muted' - -export type RemoteTargetPickerProps = { - value: RemoteTargetInput | null - onChange: (value: RemoteTargetInput | null) => void - listHosts: () => Promise - testConnection: (input: { alias: string; remoteDir?: string }) => Promise - disabled?: boolean -} - -export function RemoteTargetPicker({ - value, - onChange, - listHosts, - testConnection, - disabled = false -}: RemoteTargetPickerProps): ReactElement { - const isRemote = value !== null - const [hosts, setHosts] = useState(null) - const [loadingHosts, setLoadingHosts] = useState(false) - const [hostsError, setHostsError] = useState(null) - const [testing, setTesting] = useState(false) - const [testResult, setTestResult] = useState(null) - - useEffect(() => { - if (!isRemote || hosts || loadingHosts) return - let cancelled = false - setLoadingHosts(true) - setHostsError(null) - listHosts() - .then((result) => { - if (!cancelled) setHosts(result) - }) - .catch((error: unknown) => { - if (!cancelled) setHostsError(error instanceof Error ? error.message : String(error)) - }) - .finally(() => { - if (!cancelled) setLoadingHosts(false) - }) - return () => { - cancelled = true - } - }, [hosts, isRemote, listHosts, loadingHosts]) - - const patchValue = (patch: Partial): void => { - onChange({ alias: '', runMode: 'observe', production: false, protectedPaths: [], ...value, ...patch }) - setTestResult(null) - } - - const setRemoteEnabled = (enabled: boolean): void => { - setTestResult(null) - if (!enabled) { - onChange(null) - return - } - onChange({ alias: '', runMode: 'observe', production: false, protectedPaths: [] }) - } - - const runTest = async (): Promise => { - const alias = value?.alias.trim() ?? '' - if (!alias) return - setTesting(true) - setTestResult(null) - try { - setTestResult(await testConnection({ - alias, - ...(value?.remoteDir?.trim() ? { remoteDir: value.remoteDir.trim() } : {}) - })) - } catch (error) { - setTestResult({ - ok: false, - alias, - status: 'error', - tools: {}, - error: error instanceof Error ? error.message : String(error) - }) - } finally { - setTesting(false) - } - } - - return ( -
-
- Run location / 运行位置 - - - {isRemote ? ( - Applies to the next new thread. - ) : null} -
- - {isRemote ? ( -
- - - - - - - - -