diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5659bbfbb..4257fc3ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed SSH host additions/removals made inside a running session not refreshing the live `ssh` tool. `/ssh add` and `/ssh remove` now update the model-visible host list immediately, while `/reload-plugins` and `/move` refresh SSH discovery for external or project-scope config changes without restart. + ## [15.1.0] - 2026-05-15 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index d338b41e1..1ea67f8f7 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -1416,6 +1416,7 @@ export class AcpAgent implements Agent { resetCapabilities(); const fileCommands = await loadSlashCommands({ cwd }); record.session.setSlashCommands(fileCommands); + await record.session.refreshSshTool({ activateIfAvailable: true }); await this.#emitAvailableCommandsUpdate(record); } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 3cf7424fc..18ff6acca 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -968,6 +968,7 @@ export class CommandController { clearClaudePluginRootsCache(); // re-warms preloadedPluginRoots with new project dir (async) resetCapabilities(); await this.ctx.refreshSlashCommandState(resolvedPath); + await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); diff --git a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts index b9f6b0d8c..fd835e19d 100644 --- a/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/ssh-command-controller.ts @@ -204,6 +204,7 @@ export class SSHCommandController { if (compat) hostConfig.compat = true; await addSSHHost(filePath, name, hostConfig); + await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); const scopeLabel = scope === "user" ? "user" : "project"; const lines = [ @@ -364,6 +365,7 @@ export class SSHCommandController { } await removeSSHHost(filePath, name); + await this.ctx.session.refreshSshTool(); this.#showMessage( ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"), diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index c7a354f28..cbbaad3ab 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1428,6 +1428,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } + const reloadSshTool = async (): Promise => { + if (!requestedToolNameSet.has("ssh")) return null; + const sshTool = (await loadSshTool({ + ...toolSession, + cwd: sessionManager.getCwd(), + })) as unknown as AgentTool | null; + if (!sshTool) return null; + const wrapped = wrapToolWithMetaNotice(sshTool); + return (extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped) as AgentTool; + }; + let cursorEventEmitter: ((event: AgentEvent) => void) | undefined; const cursorExecHandlers = new CursorExecHandlers({ cwd, @@ -1532,6 +1543,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} (options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ?? toolNamesFromRegistry; const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name)); + const requestedToolNameSet = new Set(normalizedRequested); // Effective discovery mode: tools.discoveryMode takes precedence; mcp.discoveryMode is back-compat alias. const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode"); const effectiveDiscoveryMode: "off" | "mcp-only" | "all" = @@ -1810,6 +1822,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} onResponse, convertToLlm: convertToLlmFinal, rebuildSystemPrompt, + reloadSshTool, + requestedToolNames: requestedToolNameSet, getMcpServerInstructions: mcpManager ? () => { const raw = mcpManager.getServerInstructions(); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 039c2e518..58ebd5ca4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -79,6 +79,7 @@ import { Snowflake, } from "@oh-my-pi/pi-utils"; import { type AsyncJob, AsyncJobManager } from "../async"; +import { reset as resetCapabilities } from "../capability"; import type { Rule } from "../capability/rule"; import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry"; import { @@ -92,6 +93,7 @@ import { import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; +import { loadCapability } from "../discovery"; import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; import { disposeKernelSessionsByOwner, @@ -153,6 +155,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator"; +import { invalidateHostMetadata } from "../ssh/connection-manager"; import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking"; import { buildDiscoverableToolSearchIndex, @@ -274,6 +277,9 @@ export interface AgentSessionConfig { convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */ rebuildSystemPrompt?: (toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>; + /** Rebuild the SSH tool from current capability discovery results. */ + reloadSshTool?: () => Promise; + requestedToolNames?: ReadonlySet; /** * Optional accessor for live MCP server instructions. Read by the session's * `rebuildSystemPrompt`-skip optimization to detect server-side instruction @@ -736,6 +742,8 @@ export class AgentSession { | ((toolNames: string[], tools: Map) => Promise<{ systemPrompt: string[] }>) | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; + #reloadSshTool: (() => Promise) | undefined; + #requestedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; /** * Signature of the (toolNames, tool descriptions) tuple passed to the most @@ -868,6 +876,7 @@ export class AgentSession { this.#modelRegistry = config.modelRegistry; this.#validateRetryFallbackChains(); this.#toolRegistry = config.toolRegistry ?? new Map(); + this.#requestedToolNames = config.requestedToolNames; this.#transformContext = config.transformContext ?? (messages => messages); this.#onPayload = config.onPayload; this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer(); @@ -886,6 +895,7 @@ export class AgentSession { this.#convertToLlm = config.convertToLlm ?? convertToLlm; this.#rebuildSystemPrompt = config.rebuildSystemPrompt; this.#getMcpServerInstructions = config.getMcpServerInstructions; + this.#reloadSshTool = config.reloadSshTool; this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false; this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry()); @@ -3000,6 +3010,45 @@ export class AgentSession { } } + /** + * Reload the SSH tool from disk-backed capability discovery and make the + * refreshed definition visible to the next model call without restarting. + */ + async refreshSshTool(options?: { activateIfAvailable?: boolean }): Promise { + resetCapabilities(); + if (!this.#reloadSshTool) return; + const previousSshTool = this.#toolRegistry.get("ssh"); + const previousActiveToolNames = this.getActiveToolNames(); + const hadSshTool = previousSshTool !== undefined; + const wasActive = previousActiveToolNames.includes("ssh"); + const previousHostNames = + previousSshTool && "hostNames" in previousSshTool && Array.isArray(previousSshTool.hostNames) + ? [...previousSshTool.hostNames] + : []; + const candidateHostNames = new Set(previousHostNames); + const capability = await loadCapability<{ name: string }>("ssh", { cwd: this.sessionManager.getCwd() }); + for (const host of capability.items) { + if (typeof host?.name === "string") { + candidateHostNames.add(host.name); + } + } + await invalidateHostMetadata(candidateHostNames); + const sshAllowed = this.#requestedToolNames === undefined || this.#requestedToolNames.has("ssh"); + const refreshedTool = await this.#reloadSshTool(); + if (refreshedTool) { + this.#toolRegistry.set(refreshedTool.name, refreshedTool); + } else { + this.#toolRegistry.delete("ssh"); + this.#selectedDiscoveredToolNames.delete("ssh"); + } + + const nextActive = previousActiveToolNames.filter(name => name !== "ssh" && this.#toolRegistry.has(name)); + if (refreshedTool && sshAllowed && (wasActive || (options?.activateIfAvailable && !hadSshTool))) { + nextActive.push(refreshedTool.name); + } + await this.#applyActiveToolsByName(nextActive); + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 4aed32070..3f922f04a 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1506,6 +1506,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd()); clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); await runtime.ctx.refreshSlashCommandState(); + await runtime.ctx.session.refreshSshTool({ activateIfAvailable: true }); runtime.ctx.showStatus("Plugins reloaded."); runtime.ctx.editor.setText(""); }, @@ -1632,6 +1633,7 @@ export async function executeBuiltinSlashCommand( const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); await ctx.refreshSlashCommandState(); + await ctx.session.refreshSshTool({ activateIfAvailable: true }); }, }; const result = await command.handle(parsed, adapted); diff --git a/packages/coding-agent/src/slash-commands/helpers/ssh.ts b/packages/coding-agent/src/slash-commands/helpers/ssh.ts index 07b5cd4a8..eaaea1702 100644 --- a/packages/coding-agent/src/slash-commands/helpers/ssh.ts +++ b/packages/coding-agent/src/slash-commands/helpers/ssh.ts @@ -142,6 +142,7 @@ async function handleRemoveCommand(rest: string, runtime: SlashCommandRuntime): try { const filePath = getSSHConfigPath(parsed.scope, runtime.cwd); await removeSSHHost(filePath, parsed.name); + await runtime.session.refreshSshTool(); await runtime.output(`Removed SSH host "${parsed.name}" from ${parsed.scope} config.`); return commandConsumed(); } catch (err) { @@ -162,6 +163,7 @@ async function handleAddCommand(rest: string, runtime: SlashCommandRuntime): Pro try { const filePath = getSSHConfigPath(parsed.scope, runtime.cwd); await addSSHHost(filePath, parsed.name, hostConfig); + await runtime.session.refreshSshTool({ activateIfAvailable: true }); await runtime.output(`Added SSH host "${parsed.name}" (${parsed.scope}).`); return commandConsumed(); } catch (err) { diff --git a/packages/coding-agent/src/ssh/connection-manager.ts b/packages/coding-agent/src/ssh/connection-manager.ts index 4cf0ad211..23598b75c 100644 --- a/packages/coding-agent/src/ssh/connection-manager.ts +++ b/packages/coding-agent/src/ssh/connection-manager.ts @@ -55,6 +55,16 @@ function getHostInfoPath(name: string): string { return path.join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`); } +async function deleteHostInfoFromDisk(hostName: string): Promise { + const path = getHostInfoPath(hostName); + try { + await fs.promises.unlink(path); + } catch (err) { + if (isEnoent(err)) return; + logger.warn("Failed to delete SSH host info", { host: hostName, error: String(err) }); + } +} + async function validateKeyPermissions(keyPath?: string): Promise { if (!keyPath) return; let stats: fs.Stats; @@ -429,6 +439,23 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise } } +export async function invalidateHostMetadata(hostNames: Iterable): Promise { + const names = [...hostNames]; + for (const hostName of names) { + hostInfoCache.delete(hostName); + await deleteHostInfoFromDisk(hostName); + } + for (const hostName of names) { + const activeHost = activeHosts.get(hostName); + if (activeHost) { + await closeConnectionInternal(activeHost); + activeHosts.delete(hostName); + continue; + } + await closeConnectionInternal({ name: hostName, host: hostName }); + } +} + async function closeConnectionInternal(host: SSHConnectionTarget): Promise { if (!supportsSshControlMaster()) return; const target = buildSshTarget(host.username, host.host); @@ -436,13 +463,7 @@ async function closeConnectionInternal(host: SSHConnectionTarget): Promise } export async function closeConnection(hostName: string): Promise { - const host = activeHosts.get(hostName); - if (!host) { - await closeConnectionInternal({ name: hostName, host: hostName }); - return; - } - await closeConnectionInternal(host); - activeHosts.delete(hostName); + await invalidateHostMetadata([hostName]); } export async function closeAllConnections(): Promise { diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index 4ad5a2d3a..70b3e1400 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -153,6 +153,8 @@ class FakeAgentSession { // no-op for tests } + async refreshSshTool(_options?: { activateIfAvailable?: boolean }): Promise {} + async setModel(model: Model): Promise { this.model = model; } diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index b7e14a1b4..42647cf04 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -29,6 +29,7 @@ interface FakeAcpBuiltinSession { getTodoPhases(): Array<{ name: string; tasks: Array<{ content: string; status: string }> }>; setTodoPhases(phases: Array<{ name: string; tasks: Array<{ content: string; status: string }> }>): void; refreshBaseSystemPrompt(): Promise; + refreshSshTool(options?: { activateIfAvailable?: boolean }): Promise; getToolByName(name: string): unknown; compact(args?: string): Promise; getContextUsage(): { tokens?: number; contextWindow: number } | undefined; @@ -88,6 +89,7 @@ function createRuntime() { getContextUsage: () => undefined, getAvailableModels: () => [] as Array<{ provider: string; id: string; contextWindow?: number }>, async setModel(_model: unknown) {}, + async refreshSshTool(_options?: { activateIfAvailable?: boolean }) {}, }; const typedSession = session as unknown as AgentSession & FakeAcpBuiltinSession; const fakeSessionManager = { diff --git a/packages/coding-agent/test/agent-session-ssh-refresh.test.ts b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts new file mode 100644 index 000000000..663c57688 --- /dev/null +++ b/packages/coding-agent/test/agent-session-ssh-refresh.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getSSHConfigPath, TempDir } from "@oh-my-pi/pi-utils"; +import { reset as resetCapabilities } from "../src/capability"; +import { type SSHHost, sshCapability } from "../src/capability/ssh"; +import { Settings } from "../src/config/settings"; +import { loadCapability } from "../src/discovery"; +import { AgentSession } from "../src/session/agent-session"; +import { SessionManager } from "../src/session/session-manager"; +import { addSSHHost, removeSSHHost, updateSSHHost } from "../src/ssh/config-writer"; +import * as connectionManager from "../src/ssh/connection-manager"; +import { loadSshTool, type ToolSession } from "../src/tools"; + +function createModel(): Model<"openai-responses"> { + return { + id: "mock", + name: "mock", + api: "openai-responses", + provider: "openai", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }; +} + +describe("AgentSession SSH tool refresh", () => { + const tempDirs: TempDir[] = []; + const sessions: AgentSession[] = []; + + afterEach(async () => { + for (const session of sessions.splice(0)) { + await session.dispose(); + } + for (const tempDir of tempDirs.splice(0)) { + tempDir.removeSync(); + } + resetCapabilities(); + }); + + function createSession( + cwd: string, + initialTools: AgentTool[] = [], + registryTools = initialTools, + options?: { reloadSshTool?: () => Promise; requestedToolNames?: ReadonlySet }, + ): AgentSession { + const settings = Settings.isolated({ "compaction.enabled": false }); + const sessionManager = SessionManager.inMemory(cwd); + const toolSession: ToolSession = { + cwd, + hasUI: false, + settings, + getSessionFile: () => sessionManager.getSessionFile() ?? null, + getSessionSpawns: () => "*", + }; + const toolRegistry = new Map(registryTools.map(tool => [tool.name, tool])); + const agent = new Agent({ + initialState: { + model: createModel(), + systemPrompt: ["initial"], + tools: initialTools, + messages: [], + }, + }); + const session = new AgentSession({ + agent, + sessionManager, + settings, + modelRegistry: {} as never, + toolRegistry, + reloadSshTool: + options?.reloadSshTool ?? (async () => (await loadSshTool(toolSession)) as unknown as AgentTool | null), + requestedToolNames: options?.requestedToolNames, + rebuildSystemPrompt: async (toolNames, tools) => ({ + systemPrompt: toolNames.map(name => `${name}:${tools.get(name)?.description ?? ""}`), + }), + }); + sessions.push(session); + return session; + } + + it("adds the ssh tool after a first host is written over a cached missing config", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + + const preWrite = await loadCapability(sshCapability.id, { cwd }); + expect(preWrite.items).toHaveLength(0); + + const session = createSession(cwd); + await addSSHHost(getSSHConfigPath("project", cwd), "staging", { host: "192.0.2.10" }); + await session.refreshSshTool({ activateIfAvailable: true }); + + expect(session.getAllToolNames()).toContain("ssh"); + expect(session.getActiveToolNames()).toContain("ssh"); + expect(session.getToolByName("ssh")?.description).toContain("staging (192.0.2.10)"); + expect(session.agent.state.systemPrompt.join("\n")).toContain("staging (192.0.2.10)"); + }); + + it("removes ssh from registry and active tools when the last host is removed", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + const configPath = getSSHConfigPath("project", cwd); + + await addSSHHost(configPath, "prod", { host: "203.0.113.9" }); + const sshTool = await loadSshTool({ + cwd, + hasUI: false, + settings: Settings.isolated({ "compaction.enabled": false }), + getSessionSpawns: () => "*", + getSessionFile: () => null, + }); + expect(sshTool).not.toBeNull(); + + const session = createSession(cwd, [sshTool as unknown as AgentTool]); + await removeSSHHost(configPath, "prod"); + await session.refreshSshTool(); + + expect(session.getAllToolNames()).not.toContain("ssh"); + expect(session.getActiveToolNames()).not.toContain("ssh"); + }); + + it("does not activate an existing inactive ssh tool during reload refresh", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + const configPath = getSSHConfigPath("project", cwd); + + await addSSHHost(configPath, "dev", { host: "192.0.2.20" }); + const sshTool = await loadSshTool({ + cwd, + hasUI: false, + settings: Settings.isolated({ "compaction.enabled": false }), + getSessionSpawns: () => "*", + getSessionFile: () => null, + }); + expect(sshTool).not.toBeNull(); + + await addSSHHost(configPath, "dev2", { host: "192.0.2.21" }); + const session = createSession(cwd, [], [sshTool as unknown as AgentTool]); + await session.refreshSshTool({ activateIfAvailable: true }); + + expect(session.getAllToolNames()).toContain("ssh"); + expect(session.getActiveToolNames()).not.toContain("ssh"); + expect(session.getToolByName("ssh")?.description).toContain("dev2 (192.0.2.21)"); + }); + + it("reloads ssh from the session's current cwd after move", async () => { + const oldProject = TempDir.createSync("@pi-ssh-refresh-old-"); + const newProject = TempDir.createSync("@pi-ssh-refresh-new-"); + tempDirs.push(oldProject, newProject); + await SessionManager.inMemory(oldProject.path()).moveTo?.(newProject.path()); + await addSSHHost(getSSHConfigPath("project", newProject.path()), "moved", { host: "198.51.100.8" }); + const movedTool = await loadSshTool({ + cwd: newProject.path(), + hasUI: false, + settings: Settings.isolated({ "compaction.enabled": false }), + getSessionSpawns: () => "*", + getSessionFile: () => null, + }); + expect(movedTool).not.toBeNull(); + + const refreshedSession = createSession(oldProject.path(), [], [], { + reloadSshTool: async () => movedTool as unknown as AgentTool, + }); + await refreshedSession.refreshSshTool({ activateIfAvailable: true }); + + expect(refreshedSession.getAllToolNames()).toContain("ssh"); + expect(refreshedSession.getToolByName("ssh")?.description).toContain("moved (198.51.100.8)"); + }); + + it("invalidates cached host metadata before rebuilding descriptions when a host config changes", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + const configPath = getSSHConfigPath("project", cwd); + + await addSSHHost(configPath, "prod", { host: "203.0.113.9" }); + const initialTool = await loadSshTool({ + cwd, + hasUI: false, + settings: Settings.isolated({ "compaction.enabled": false }), + getSessionSpawns: () => "*", + getSessionFile: () => null, + }); + expect(initialTool).not.toBeNull(); + const session = createSession(cwd, [initialTool as unknown as AgentTool]); + + const invalidateSpy = spyOn(connectionManager, "invalidateHostMetadata").mockResolvedValue(undefined); + await updateSSHHost(configPath, "prod", { host: "203.0.113.10" }); + await session.refreshSshTool({ activateIfAvailable: true }); + + expect(invalidateSpy).toHaveBeenNthCalledWith(1, new Set(["prod"])); + expect(session.getToolByName("ssh")?.description).toContain("prod (203.0.113.10)"); + }); + + it("invalidates newly added host names before rebuilding the ssh tool", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + const configPath = getSSHConfigPath("project", cwd); + + await addSSHHost(configPath, "fresh", { host: "203.0.113.11" }); + const session = createSession(cwd); + await session.refreshSshTool({ activateIfAvailable: true }); + + expect(session.getToolByName("ssh")?.description).toContain("fresh (203.0.113.11)"); + expect(session.getToolByName("ssh")?.description).toContain("fresh (203.0.113.11)"); + }); + + it("does not activate ssh when it was excluded from the requested tool allowlist", async () => { + const tempDir = TempDir.createSync("@pi-ssh-refresh-"); + tempDirs.push(tempDir); + const cwd = tempDir.path(); + const configPath = getSSHConfigPath("project", cwd); + const blockedTool: AgentTool = { + name: "ssh", + label: "SSH", + description: "blocked", + parameters: { type: "object", properties: {} }, + strict: true, + execute: async () => ({ content: [{ type: "text", text: "" }] }), + }; + + await addSSHHost(configPath, "hidden", { host: "203.0.113.12" }); + const session = createSession(cwd, [], [blockedTool], { + reloadSshTool: async () => blockedTool, + requestedToolNames: new Set(["read"]), + }); + await session.refreshSshTool({ activateIfAvailable: true }); + + expect(session.getAllToolNames()).toContain("ssh"); + expect(session.getActiveToolNames()).not.toContain("ssh"); + }); +}); diff --git a/packages/coding-agent/test/ssh/connection-manager.test.ts b/packages/coding-agent/test/ssh/connection-manager.test.ts index 8ec611252..60866008e 100644 --- a/packages/coding-agent/test/ssh/connection-manager.test.ts +++ b/packages/coding-agent/test/ssh/connection-manager.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { buildRemoteCommand, supportsSshControlMaster } from "../../src/ssh/connection-manager"; +import * as connectionManager from "../../src/ssh/connection-manager"; describe("buildRemoteCommand", () => { it("includes -n and OpenSSH ControlMaster options on Unix-like platforms", async () => { - const args = await buildRemoteCommand( + const args = await connectionManager.buildRemoteCommand( { name: "host", host: "192.168.3.146", @@ -19,7 +19,7 @@ describe("buildRemoteCommand", () => { }); it("omits OpenSSH ControlMaster options on Windows", async () => { - const args = await buildRemoteCommand( + const args = await connectionManager.buildRemoteCommand( { name: "host", host: "192.168.3.146", @@ -40,11 +40,11 @@ describe("buildRemoteCommand", () => { describe("supportsSshControlMaster", () => { it("disables OpenSSH connection multiplexing on native Windows", () => { - expect(supportsSshControlMaster("win32")).toBe(false); + expect(connectionManager.supportsSshControlMaster("win32")).toBe(false); }); it("keeps OpenSSH connection multiplexing on Unix-like platforms", () => { - expect(supportsSshControlMaster("linux")).toBe(true); - expect(supportsSshControlMaster("darwin")).toBe(true); + expect(connectionManager.supportsSshControlMaster("linux")).toBe(true); + expect(connectionManager.supportsSshControlMaster("darwin")).toBe(true); }); });