From e4ecebfee49ae803d28f3fc222131f7d070919fe Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 15:08:22 -0600 Subject: [PATCH 1/8] feat: add SSH agent forwarding support Add forwardAgent toggle to SSH workspace runtime so users can forward their local SSH agent to the remote host (enables git clone/push over SSH on remote machines). Changes: - Schema: add forwardAgent boolean to SSH runtime schema - Types: add forwardAgent to ParsedRuntime SSH variant and buildRuntimeConfig - SSHConnectionConfig: add forwardAgent field - runtimeFactory: pass forwardAgent through to SSH transports - OpenSSHTransport: add -A flag to ssh args when forwardAgent is set - SSH2ConnectionPool: pass agentForward:true in connect options - useDraftWorkspaceSettings: persist/restore forwardAgent in SSH config - CreationControls: add 'Forward SSH agent' checkbox for SSH mode --- .../components/ChatInput/CreationControls.tsx | 21 +++++++++++++++++++ .../hooks/useDraftWorkspaceSettings.test.tsx | 2 ++ .../hooks/useDraftWorkspaceSettings.ts | 19 ++++++++++++++--- src/common/orpc/schemas/runtime.ts | 3 +++ src/common/types/runtime.ts | 3 ++- src/node/runtime/SSH2ConnectionPool.ts | 1 + src/node/runtime/runtimeFactory.ts | 1 + src/node/runtime/sshConnectionPool.ts | 2 ++ .../runtime/transports/OpenSSHTransport.ts | 2 ++ 9 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 15a84c9133..25a30649d3 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -800,6 +800,27 @@ export function CreationControls(props: CreationControlsProps) { /> )} + {/* SSH agent forwarding checkbox - hidden when Coder runtime is selected */} + {selectedRuntime.mode === "ssh" && !isCoderSelected && ( + + )} + {/* Runtime-specific config inputs */} {selectedRuntime.mode === "docker" && ( diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index fb80d461e9..4a3f28b2d5 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -149,6 +149,7 @@ describe("useDraftWorkspaceSettings", () => { mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER, coder: { existingWorkspace: false }, + forwardAgent: false, }); }); }); @@ -183,6 +184,7 @@ describe("useDraftWorkspaceSettings", () => { mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER, coder: { existingWorkspace: false }, + forwardAgent: false, }); }); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index b908741998..5b3f05c737 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -48,12 +48,14 @@ export interface DraftWorkspaceSettings { interface SshRuntimeConfig { host: string; coder?: CoderWorkspaceConfig; + forwardAgent?: boolean; } interface SshRuntimeState { host: string; coderEnabled: boolean; coderConfig: CoderWorkspaceConfig | null; + forwardAgent: boolean; } /** Stable fallback for Coder config to avoid new object on every render */ @@ -80,6 +82,7 @@ const buildRuntimeForMode = ( mode: "ssh", host: effectiveHost, coder: sshConfig.coder, + forwardAgent: sshConfig.forwardAgent, }; } case RUNTIME_MODE.DOCKER: @@ -220,6 +223,7 @@ export function useDraftWorkspaceSettings( "coderConfig", null ), + forwardAgent: readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "forwardAgent", false), }); const readSshRuntimeConfig = (configs: LastRuntimeConfigs): SshRuntimeConfig => { @@ -228,6 +232,7 @@ export function useDraftWorkspaceSettings( return { host: sshState.host, coder: sshState.coderEnabled && sshState.coderConfig ? sshState.coderConfig : undefined, + forwardAgent: sshState.forwardAgent, }; }; @@ -282,6 +287,7 @@ export function useDraftWorkspaceSettings( if (config.coder) { setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", config.coder); } + setLastRuntimeConfig(RUNTIME_MODE.SSH, "forwardAgent", config.forwardAgent ?? false); }, [setLastRuntimeConfig] ); @@ -337,7 +343,7 @@ export function useDraftWorkspaceSettings( const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: lastSsh.forwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -361,7 +367,7 @@ export function useDraftWorkspaceSettings( setSelectedRuntimeState( buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: lastSsh.forwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -379,6 +385,7 @@ export function useDraftWorkspaceSettings( defaultDockerImage, lastShareCredentials, defaultSshCoder, + lastSsh.forwardAgent, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -458,7 +465,11 @@ export function useDraftWorkspaceSettings( // Avoid wiping the remembered value when the UI switches modes with an empty field. // Avoid persisting the Coder placeholder as the remembered SSH host. if (runtime.mode === RUNTIME_MODE.SSH) { - writeSshRuntimeConfig({ host: runtime.host, coder: runtime.coder }); + writeSshRuntimeConfig({ + host: runtime.host, + coder: runtime.coder, + forwardAgent: runtime.forwardAgent, + }); } else if (runtime.mode === RUNTIME_MODE.DOCKER) { if (runtime.image.trim()) { setLastRuntimeConfig(RUNTIME_MODE.DOCKER, "image", runtime.image); @@ -495,10 +506,12 @@ export function useDraftWorkspaceSettings( ? { host: CODER_RUNTIME_PLACEHOLDER, coder: freshSshState.coderConfig ?? DEFAULT_CODER_CONFIG, + forwardAgent: freshSshState.forwardAgent, } : { host: freshSshState.host, coder: undefined, + forwardAgent: freshSshState.forwardAgent, }; const newRuntime = buildRuntimeForMode( diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index cdab0503e9..7229ebb1a0 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -93,6 +93,9 @@ export const RuntimeConfigSchema = z.union([ coder: CoderWorkspaceConfigSchema.optional().meta({ description: "Coder workspace configuration (when using Coder as SSH backend)", }), + forwardAgent: z.boolean().optional().meta({ + description: "Forward SSH agent to remote host (enables git/SSH on remote)", + }), }), // Docker runtime - each workspace runs in its own container z.object({ diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 6e9311aa69..896b1bcef2 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -56,7 +56,7 @@ export type RuntimeConfig = z.infer; export type ParsedRuntime = | { mode: "local" } | { mode: "worktree" } - | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig } + | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig; forwardAgent?: boolean } | { mode: "docker"; image: string; shareCredentials?: boolean } | { mode: "devcontainer"; configPath: string; shareCredentials?: boolean }; @@ -172,6 +172,7 @@ export function buildRuntimeConfig(parsed: ParsedRuntime): RuntimeConfig | undef host: parsed.host.trim(), srcBaseDir: "~/mux", // Default remote base directory (tilde resolved by backend) coder: parsed.coder, + forwardAgent: parsed.forwardAgent, }; case RUNTIME_MODE.DOCKER: return { diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index c8dac90946..f420a39438 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -607,6 +607,7 @@ export class SSH2ConnectionPool { keepaliveInterval: 5000, keepaliveCountMax: 2, ...(privateKey ? { privateKey } : {}), + ...(config.forwardAgent && { agentForward: true }), }; client.connect(connectOptions); diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 02e7ed5e2f..c1f38f46a9 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -164,6 +164,7 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti bgOutputDir: config.bgOutputDir, identityFile: config.identityFile, port: config.port, + forwardAgent: config.forwardAgent, }; const useSSH2 = shouldUseSSH2Runtime(); diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index cadd1ed17b..19aad5eb9b 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -30,6 +30,8 @@ export interface SSHConnectionConfig { identityFile?: string; /** Optional: SSH port (default: 22) */ port?: number; + /** Optional: Forward SSH agent to remote host (enables git/SSH on remote) */ + forwardAgent?: boolean; } /** diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index 37fb475eba..3d46f1390e 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -103,6 +103,8 @@ export class OpenSSHTransport implements SSHTransport { private buildSSHArgs(): string[] { const args: string[] = []; + if (this.config.forwardAgent) args.push("-A"); + if (this.config.port) { args.push("-p", this.config.port.toString()); } From e3e30da227e08503347be727aac8cbfc23793f66 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 15:30:28 -0600 Subject: [PATCH 2/8] fix: preserve forwardAgent in SSH runtime updates and connection pool keys Fix two bugs found by Codex review: 1. In CreationControls.tsx, three call sites that construct SSH runtime objects were missing the forwardAgent field, causing it to be silently reset to false when the runtime was persisted: - Auto-switch to SSH mode (disabled runtime guard) - SSH tab click handler - Host text field onChange handler 2. In both SSH2ConnectionPool.ts and sshConnectionPool.ts, the makeConnectionKey function did not include forwardAgent in the connection pool key. This meant connections with different agent-forwarding settings could incorrectly share a pooled connection. --- .../components/ChatInput/CreationControls.tsx | 15 ++++++++++++++- src/node/runtime/SSH2ConnectionPool.ts | 1 + src/node/runtime/sshConnectionPool.ts | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index ad47376c20..eb52191af9 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -675,9 +675,12 @@ export function CreationControls(props: CreationControlsProps) { selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER ? selectedRuntime.host : props.sshHostFallback; + const sshForwardAgent = + selectedRuntime.mode === RUNTIME_MODE.SSH ? selectedRuntime.forwardAgent : false; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, + forwardAgent: sshForwardAgent, }); return; } @@ -907,9 +910,12 @@ export function CreationControls(props: CreationControlsProps) { selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER ? selectedRuntime.host : props.sshHostFallback; + const sshForwardAgent = + selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : false; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, + forwardAgent: sshForwardAgent, }); break; } @@ -1000,7 +1006,14 @@ export function CreationControls(props: CreationControlsProps) { onSelectedRuntimeChange({ mode: "ssh", host: value })} + onChange={(value) => + onSelectedRuntimeChange({ + mode: "ssh", + host: value, + forwardAgent: + selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : undefined, + }) + } placeholder="user@host" disabled={props.disabled} hasError={props.runtimeFieldError === "ssh"} diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index acfa6eba91..c95ebe846a 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -174,6 +174,7 @@ function makeConnectionKey(config: SSHConnectionConfig): string { config.host, config.port?.toString() ?? "22", config.identityFile ?? "default", + config.forwardAgent ? "fwd" : "nofwd", ]; return parts.join(":"); } diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 6fd2567975..8e5eb455d5 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -559,13 +559,14 @@ export function getControlPath(config: SSHConnectionConfig): string { */ function makeConnectionKey(config: SSHConnectionConfig): string { // Note: srcBaseDir is intentionally excluded - connection identity is determined - // by user + host + port + key. This allows health tracking and multiplexing - // to be shared across workspaces on the same host. + // by user + host + port + key + agent forwarding. This allows health tracking + // and multiplexing to be shared across workspaces on the same host. const parts = [ os.userInfo().username, // Include local user to prevent cross-user collisions config.host, config.port?.toString() ?? "22", config.identityFile ?? "default", + config.forwardAgent ? "fwd" : "nofwd", ]; return parts.join(":"); } From d3599eeca590cf6e400fc627f9644273a2b944a3 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 15:36:07 -0600 Subject: [PATCH 3/8] ci: re-trigger checks after resolving Codex comments From 0f0ea99b8e79a5e962ad11fc4e539544426047aa Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 15:46:37 -0600 Subject: [PATCH 4/8] fix: preserve forwardAgent when switching runtime modes - writeSshRuntimeConfig: skip persisting forwardAgent when undefined, so mode switches and Coder selection don't overwrite the stored value - CreationControls: pass undefined (not false) for forwardAgent when the current mode isn't SSH, preventing destructive overwrites --- src/browser/components/ChatInput/CreationControls.tsx | 4 ++-- src/browser/hooks/useDraftWorkspaceSettings.ts | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index eb52191af9..2036f510fe 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -676,7 +676,7 @@ export function CreationControls(props: CreationControlsProps) { ? selectedRuntime.host : props.sshHostFallback; const sshForwardAgent = - selectedRuntime.mode === RUNTIME_MODE.SSH ? selectedRuntime.forwardAgent : false; + selectedRuntime.mode === RUNTIME_MODE.SSH ? selectedRuntime.forwardAgent : undefined; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, @@ -911,7 +911,7 @@ export function CreationControls(props: CreationControlsProps) { ? selectedRuntime.host : props.sshHostFallback; const sshForwardAgent = - selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : false; + selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : undefined; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index ab57474772..1d27af6578 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -349,7 +349,12 @@ export function useDraftWorkspaceSettings( if (config.coder) { setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", config.coder); } - setLastRuntimeConfig(RUNTIME_MODE.SSH, "forwardAgent", config.forwardAgent ?? false); + // Only persist forwardAgent when explicitly set — callers that don't care + // about it (mode switches, Coder selection) pass undefined and should not + // overwrite the stored value. + if (config.forwardAgent != null) { + setLastRuntimeConfig(RUNTIME_MODE.SSH, "forwardAgent", config.forwardAgent); + } }, [setLastRuntimeConfig] ); From 03df9af259c564b3e860e6525c88df897cb33066 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 15:59:38 -0600 Subject: [PATCH 5/8] fix: preserve forwardAgent in-memory when switching back to SSH mode When switching from a non-SSH runtime to SSH, forwardAgent was set to undefined in the in-memory state. While writeSshRuntimeConfig correctly skips writing undefined values (preserving persisted state), the in-memory runtime object used for workspace creation lacked forwardAgent, silently disabling agent forwarding. Follow the same fallback pattern used for sshHost: expose sshForwardAgentFallback from useDraftWorkspaceSettings (sourced from lastSshState.forwardAgent), thread it through useCreationWorkspace, and use it in CreationControls when switching to SSH from another mode. --- .../components/ChatInput/CreationControls.tsx | 15 ++++++++++++--- src/browser/components/ChatInput/index.tsx | 1 + .../components/ChatInput/useCreationWorkspace.ts | 4 ++++ src/browser/hooks/useDraftWorkspaceSettings.ts | 5 +++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 2036f510fe..7998f5ee0e 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -140,6 +140,8 @@ interface CreationControlsProps { coderConfigFallback: CoderWorkspaceConfig; /** Fallback SSH host to restore when leaving Coder. */ sshHostFallback: string; + /** Fallback SSH forwardAgent to restore when switching back to SSH mode. */ + sshForwardAgentFallback?: boolean; defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ onSelectedRuntimeChange: (runtime: ParsedRuntime) => void; @@ -676,7 +678,9 @@ export function CreationControls(props: CreationControlsProps) { ? selectedRuntime.host : props.sshHostFallback; const sshForwardAgent = - selectedRuntime.mode === RUNTIME_MODE.SSH ? selectedRuntime.forwardAgent : undefined; + selectedRuntime.mode === RUNTIME_MODE.SSH + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, @@ -722,6 +726,7 @@ export function CreationControls(props: CreationControlsProps) { props.coderProps, props.runtimeEnablement, props.sshHostFallback, + props.sshForwardAgentFallback, props.allowedRuntimeModes, props.allowSshHost, props.allowSshCoder, @@ -911,7 +916,9 @@ export function CreationControls(props: CreationControlsProps) { ? selectedRuntime.host : props.sshHostFallback; const sshForwardAgent = - selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : undefined; + selectedRuntime.mode === "ssh" + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, @@ -1011,7 +1018,9 @@ export function CreationControls(props: CreationControlsProps) { mode: "ssh", host: value, forwardAgent: - selectedRuntime.mode === "ssh" ? selectedRuntime.forwardAgent : undefined, + selectedRuntime.mode === "ssh" + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback, }) } placeholder="user@host" diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index dbbfa7c780..8bc4de92e1 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -803,6 +803,7 @@ const ChatInputInner: React.FC = (props) => { selectedRuntime: creationState.selectedRuntime, coderConfigFallback: creationState.coderConfigFallback, sshHostFallback: creationState.sshHostFallback, + sshForwardAgentFallback: creationState.sshForwardAgentFallback, defaultRuntimeMode: creationState.defaultRuntimeMode, onSelectedRuntimeChange: creationState.setSelectedRuntime, onSetDefaultRuntime: creationState.setDefaultRuntimeChoice, diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 9f2d9f9318..1db26c3b47 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -160,6 +160,8 @@ interface UseCreationWorkspaceReturn { coderConfigFallback: CoderWorkspaceConfig; /** Fallback SSH host used when leaving the Coder runtime. */ sshHostFallback: string; + /** Fallback SSH forwardAgent setting when switching back to SSH mode. */ + sshForwardAgentFallback: boolean | undefined; defaultRuntimeMode: RuntimeChoice; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; @@ -240,6 +242,7 @@ export function useCreationWorkspace({ settings, coderConfigFallback, sshHostFallback, + sshForwardAgentFallback, setSelectedRuntime, setDefaultRuntimeChoice, setTrunkBranch, @@ -611,6 +614,7 @@ export function useCreationWorkspace({ selectedRuntime: settings.selectedRuntime, coderConfigFallback, sshHostFallback, + sshForwardAgentFallback, defaultRuntimeMode: settings.defaultRuntimeMode, setSelectedRuntime, setDefaultRuntimeChoice, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 1d27af6578..267bc332fe 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -157,6 +157,8 @@ export function useDraftWorkspaceSettings( coderConfigFallback: CoderWorkspaceConfig; /** Preserves the last SSH host when leaving Coder so the input stays populated. */ sshHostFallback: string; + /** Preserves the last SSH forwardAgent setting when switching away from SSH mode. */ + sshForwardAgentFallback: boolean | undefined; /** Set the currently selected runtime (discriminated union) */ setSelectedRuntime: (runtime: ParsedRuntime) => void; /** Set the default runtime choice for this project (persists via checkbox) */ @@ -302,6 +304,8 @@ export function useDraftWorkspaceSettings( // Preserve the last SSH host when switching out of Coder so the input stays populated. const sshHostFallback = lastSshState.host; + // Preserve the last SSH forwardAgent setting when switching away from SSH mode. + const sshForwardAgentFallback = lastSshState.forwardAgent; // Restore prior Coder selections when switching back into Coder mode. const coderConfigFallback = lastSshState.coderConfig ?? DEFAULT_CODER_CONFIG; @@ -621,6 +625,7 @@ export function useDraftWorkspaceSettings( }, coderConfigFallback, sshHostFallback, + sshForwardAgentFallback, setSelectedRuntime, setDefaultRuntimeChoice, setTrunkBranch, From 4949250edbf5b26f209a30f702549b120f74971c Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 16:08:35 -0600 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20preserve=20forwardAgent=20across=20C?= =?UTF-8?q?oder=E2=86=92SSH=20mode=20switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ChatInput/CreationControls.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 7998f5ee0e..6a9af69e52 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -672,15 +672,15 @@ export function CreationControls(props: CreationControlsProps) { switch (firstEnabled) { case RUNTIME_MODE.SSH: { - const sshHost = + const isNonCoderSsh = selectedRuntime.mode === RUNTIME_MODE.SSH && - selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER - ? selectedRuntime.host - : props.sshHostFallback; - const sshForwardAgent = - selectedRuntime.mode === RUNTIME_MODE.SSH - ? selectedRuntime.forwardAgent - : props.sshForwardAgentFallback; + selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER; + const sshHost = isNonCoderSsh ? selectedRuntime.host : props.sshHostFallback; + // Use in-memory forwardAgent only when already in host-SSH mode; + // otherwise fall back to the persisted value so Coder→SSH switches don't drop it. + const sshForwardAgent = isNonCoderSsh + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, @@ -910,15 +910,15 @@ export function CreationControls(props: CreationControlsProps) { // Convert mode to ParsedRuntime with appropriate defaults switch (mode) { case RUNTIME_MODE.SSH: { - const sshHost = + const isNonCoderSsh = selectedRuntime.mode === "ssh" && - selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER - ? selectedRuntime.host - : props.sshHostFallback; - const sshForwardAgent = - selectedRuntime.mode === "ssh" - ? selectedRuntime.forwardAgent - : props.sshForwardAgentFallback; + selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER; + const sshHost = isNonCoderSsh ? selectedRuntime.host : props.sshHostFallback; + // Use in-memory forwardAgent only when already in host-SSH mode; + // otherwise fall back to the persisted value so Coder→SSH switches don't drop it. + const sshForwardAgent = isNonCoderSsh + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback; onSelectedRuntimeChange({ mode: "ssh", host: sshHost, From a3d77f9c0c5b28b0501c44bfb751ecd37c6868b6 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 16:20:39 -0600 Subject: [PATCH 7/8] fix: omit forwardAgent from Coder runtime defaults --- src/browser/hooks/useDraftWorkspaceSettings.test.tsx | 1 - src/browser/hooks/useDraftWorkspaceSettings.ts | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 1f0836e8fa..3f015342c9 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -252,7 +252,6 @@ describe("useDraftWorkspaceSettings", () => { mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER, coder: { existingWorkspace: false }, - forwardAgent: false, }); }); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 267bc332fe..6a992383d3 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -89,7 +89,7 @@ const buildRuntimeForMode = ( mode: "ssh", host: effectiveHost, coder: sshConfig.coder, - forwardAgent: sshConfig.forwardAgent, + ...(sshConfig.forwardAgent != null && { forwardAgent: sshConfig.forwardAgent }), }; } case RUNTIME_MODE.DOCKER: @@ -587,7 +587,8 @@ export function useDraftWorkspaceSettings( ? { host: CODER_RUNTIME_PLACEHOLDER, coder: freshSshState.coderConfig ?? DEFAULT_CODER_CONFIG, - forwardAgent: freshSshState.forwardAgent, + // forwardAgent intentionally omitted for Coder — the UI hides + // the toggle and Coder manages its own credential forwarding. } : { host: freshSshState.host, From 13ffeff4589f9db4eb3ebe609433f75788a558a8 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 23 Feb 2026 16:30:36 -0600 Subject: [PATCH 8/8] fix: add -A to OpenSSH probe + omit forwardAgent from Coder defaults --- src/browser/hooks/useDraftWorkspaceSettings.test.tsx | 1 - src/browser/hooks/useDraftWorkspaceSettings.ts | 8 +++++--- src/node/runtime/sshConnectionPool.ts | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 3f015342c9..9898056946 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -215,7 +215,6 @@ describe("useDraftWorkspaceSettings", () => { mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER, coder: { existingWorkspace: false }, - forwardAgent: false, }); }); }); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 6a992383d3..c8da70e53b 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -419,9 +419,11 @@ export function useDraftWorkspaceSettings( ? parsedDefault.configPath : lastDevcontainerConfigPath; + // Only include forwardAgent for non-Coder SSH runtimes; Coder manages its own credential forwarding. + const defaultSshForwardAgent = defaultSshCoder ? undefined : lastSsh.forwardAgent; const defaultRuntime = buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: lastSsh.forwardAgent }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: defaultSshForwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -447,7 +449,7 @@ export function useDraftWorkspaceSettings( setSelectedRuntimeState( buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: lastSsh.forwardAgent }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: defaultSshForwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -466,7 +468,7 @@ export function useDraftWorkspaceSettings( defaultDockerImage, lastShareCredentials, defaultSshCoder, - lastSsh.forwardAgent, + defaultSshForwardAgent, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); diff --git a/src/node/runtime/sshConnectionPool.ts b/src/node/runtime/sshConnectionPool.ts index 8e5eb455d5..44c90bbcaf 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -406,6 +406,9 @@ export class SSHConnectionPool { const args: string[] = ["-T"]; // No PTY needed for probe + // Forward the local SSH agent so multiplexed sessions inherit the socket. + if (config.forwardAgent) args.push("-A"); + if (config.port) { args.push("-p", config.port.toString()); }