diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index 6f95b6d6a6..6a9af69e52 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; @@ -670,14 +672,19 @@ 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; + 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, + forwardAgent: sshForwardAgent, }); return; } @@ -719,6 +726,7 @@ export function CreationControls(props: CreationControlsProps) { props.coderProps, props.runtimeEnablement, props.sshHostFallback, + props.sshForwardAgentFallback, props.allowedRuntimeModes, props.allowSshHost, props.allowSshCoder, @@ -902,14 +910,19 @@ 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; + 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, + forwardAgent: sshForwardAgent, }); break; } @@ -1000,13 +1013,43 @@ export function CreationControls(props: CreationControlsProps) { onSelectedRuntimeChange({ mode: "ssh", host: value })} + onChange={(value) => + onSelectedRuntimeChange({ + mode: "ssh", + host: value, + forwardAgent: + selectedRuntime.mode === "ssh" + ? selectedRuntime.forwardAgent + : props.sshForwardAgentFallback, + }) + } placeholder="user@host" disabled={props.disabled} hasError={props.runtimeFieldError === "ssh"} /> )} + {/* 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/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 6763787b05..c8da70e53b 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -50,12 +50,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 */ @@ -87,6 +89,7 @@ const buildRuntimeForMode = ( mode: "ssh", host: effectiveHost, coder: sshConfig.coder, + ...(sshConfig.forwardAgent != null && { forwardAgent: sshConfig.forwardAgent }), }; } case RUNTIME_MODE.DOCKER: @@ -154,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) */ @@ -282,6 +287,7 @@ export function useDraftWorkspaceSettings( "coderConfig", null ), + forwardAgent: readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "forwardAgent", false), }); const readSshRuntimeConfig = (configs: LastRuntimeConfigs): SshRuntimeConfig => { @@ -290,6 +296,7 @@ export function useDraftWorkspaceSettings( return { host: sshState.host, coder: sshState.coderEnabled && sshState.coderConfig ? sshState.coderConfig : undefined, + forwardAgent: sshState.forwardAgent, }; }; @@ -297,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; @@ -344,6 +353,12 @@ export function useDraftWorkspaceSettings( if (config.coder) { setLastRuntimeConfig(RUNTIME_MODE.SSH, "coderConfig", config.coder); } + // 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] ); @@ -404,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 }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: defaultSshForwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -432,7 +449,7 @@ export function useDraftWorkspaceSettings( setSelectedRuntimeState( buildRuntimeForMode( defaultRuntimeMode, - { host: defaultSshHost, coder: defaultSshCoder }, + { host: defaultSshHost, coder: defaultSshCoder, forwardAgent: defaultSshForwardAgent }, defaultDockerImage, lastShareCredentials, defaultDevcontainerConfigPath, @@ -451,6 +468,7 @@ export function useDraftWorkspaceSettings( defaultDockerImage, lastShareCredentials, defaultSshCoder, + defaultSshForwardAgent, defaultDevcontainerConfigPath, lastDevcontainerShareCredentials, ]); @@ -530,7 +548,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); @@ -567,10 +589,13 @@ export function useDraftWorkspaceSettings( ? { host: CODER_RUNTIME_PLACEHOLDER, coder: freshSshState.coderConfig ?? DEFAULT_CODER_CONFIG, + // forwardAgent intentionally omitted for Coder — the UI hides + // the toggle and Coder manages its own credential forwarding. } : { host: freshSshState.host, coder: undefined, + forwardAgent: freshSshState.forwardAgent, }; const newRuntime = buildRuntimeForMode( @@ -603,6 +628,7 @@ export function useDraftWorkspaceSettings( }, coderConfigFallback, sshHostFallback, + sshForwardAgentFallback, setSelectedRuntime, setDefaultRuntimeChoice, setTrunkBranch, diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index d97cc89c2f..be0b81bbb7 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -102,6 +102,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 21f05598d7..bc41609327 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -83,7 +83,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 }; @@ -199,6 +199,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 ed89d8e947..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(":"); } @@ -621,6 +622,7 @@ export class SSH2ConnectionPool { keepaliveInterval: 5000, keepaliveCountMax: 2, ...(privateKey ? { privateKey } : {}), + ...(config.forwardAgent && { agentForward: true }), // TODO(ethanndickson): Implement known_hosts support for SSH2 // and restore interactive host key verification once approvals // can be persisted between connections. diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 71eba8e670..5c8e784e4a 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -165,6 +165,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 cb36d59513..44c90bbcaf 100644 --- a/src/node/runtime/sshConnectionPool.ts +++ b/src/node/runtime/sshConnectionPool.ts @@ -60,6 +60,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; } /** @@ -404,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()); } @@ -557,13 +562,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(":"); } diff --git a/src/node/runtime/transports/OpenSSHTransport.ts b/src/node/runtime/transports/OpenSSHTransport.ts index b20f7523a6..53a7e63bf6 100644 --- a/src/node/runtime/transports/OpenSSHTransport.ts +++ b/src/node/runtime/transports/OpenSSHTransport.ts @@ -116,6 +116,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()); }