Skip to content
Closed
61 changes: 52 additions & 9 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +675 to +677

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require coder absence in non-Coder SSH detection

The isNonCoderSsh predicate only checks whether the host is not coder://, so Coder runtimes that use a concrete host (for example existing workspaces like name.coder) are treated as plain SSH. In that case this branch pulls sshForwardAgent from selectedRuntime.forwardAgent (typically undefined for Coder flows) instead of sshForwardAgentFallback, so switching from Coder back to SSH can silently clear the user’s forwarding setting and break agent-dependent Git/SSH commands until manually re-enabled.

Useful? React with πŸ‘Β / πŸ‘Ž.

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;
}
Expand Down Expand Up @@ -719,6 +726,7 @@ export function CreationControls(props: CreationControlsProps) {
props.coderProps,
props.runtimeEnablement,
props.sshHostFallback,
props.sshForwardAgentFallback,
props.allowedRuntimeModes,
props.allowSshHost,
props.allowSshCoder,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1000,13 +1013,43 @@ export function CreationControls(props: CreationControlsProps) {
<RuntimeConfigInput
label="host"
value={selectedRuntime.host}
onChange={(value) => 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 && (
<label className="flex items-center gap-1.5 text-xs">
<input
type="checkbox"
checked={selectedRuntime.forwardAgent ?? false}
onChange={(e) =>
onSelectedRuntimeChange({
mode: "ssh",
host: selectedRuntime.host,
coder: selectedRuntime.coder,
forwardAgent: e.target.checked,
})
}
disabled={props.disabled}
className="accent-accent"
/>
<span className="text-muted">Forward SSH agent</span>
</label>
)}

{/* Runtime-specific config inputs */}

{selectedRuntime.mode === "docker" && (
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
selectedRuntime: creationState.selectedRuntime,
coderConfigFallback: creationState.coderConfigFallback,
sshHostFallback: creationState.sshHostFallback,
sshForwardAgentFallback: creationState.sshForwardAgentFallback,
defaultRuntimeMode: creationState.defaultRuntimeMode,
onSelectedRuntimeChange: creationState.setSelectedRuntime,
onSetDefaultRuntime: creationState.setDefaultRuntimeChoice,
Expand Down
4 changes: 4 additions & 0 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -240,6 +242,7 @@ export function useCreationWorkspace({
settings,
coderConfigFallback,
sshHostFallback,
sshForwardAgentFallback,
setSelectedRuntime,
setDefaultRuntimeChoice,
setTrunkBranch,
Expand Down Expand Up @@ -611,6 +614,7 @@ export function useCreationWorkspace({
selectedRuntime: settings.selectedRuntime,
coderConfigFallback,
sshHostFallback,
sshForwardAgentFallback,
defaultRuntimeMode: settings.defaultRuntimeMode,
setSelectedRuntime,
setDefaultRuntimeChoice,
Expand Down
32 changes: 29 additions & 3 deletions src/browser/hooks/useDraftWorkspaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -87,6 +89,7 @@ const buildRuntimeForMode = (
mode: "ssh",
host: effectiveHost,
coder: sshConfig.coder,
...(sshConfig.forwardAgent != null && { forwardAgent: sshConfig.forwardAgent }),
};
}
case RUNTIME_MODE.DOCKER:
Expand Down Expand Up @@ -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) */
Expand Down Expand Up @@ -282,6 +287,7 @@ export function useDraftWorkspaceSettings(
"coderConfig",
null
),
forwardAgent: readRuntimeConfigFrom(configs, RUNTIME_MODE.SSH, "forwardAgent", false),
});

const readSshRuntimeConfig = (configs: LastRuntimeConfigs): SshRuntimeConfig => {
Expand All @@ -290,13 +296,16 @@ export function useDraftWorkspaceSettings(
return {
host: sshState.host,
coder: sshState.coderEnabled && sshState.coderConfig ? sshState.coderConfig : undefined,
forwardAgent: sshState.forwardAgent,
};
};

const lastSshState = readSshRuntimeState(lastRuntimeConfigs);

// 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;
Expand Down Expand Up @@ -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]
);
Expand Down Expand Up @@ -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,
Expand All @@ -432,7 +449,7 @@ export function useDraftWorkspaceSettings(
setSelectedRuntimeState(
buildRuntimeForMode(
defaultRuntimeMode,
{ host: defaultSshHost, coder: defaultSshCoder },
{ host: defaultSshHost, coder: defaultSshCoder, forwardAgent: defaultSshForwardAgent },
defaultDockerImage,
lastShareCredentials,
defaultDevcontainerConfigPath,
Expand All @@ -451,6 +468,7 @@ export function useDraftWorkspaceSettings(
defaultDockerImage,
lastShareCredentials,
defaultSshCoder,
defaultSshForwardAgent,
defaultDevcontainerConfigPath,
lastDevcontainerShareCredentials,
]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -603,6 +628,7 @@ export function useDraftWorkspaceSettings(
},
coderConfigFallback,
sshHostFallback,
sshForwardAgentFallback,
setSelectedRuntime,
setDefaultRuntimeChoice,
setTrunkBranch,
Expand Down
3 changes: 3 additions & 0 deletions src/common/orpc/schemas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion src/common/types/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
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 };

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/node/runtime/SSH2ConnectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(":");
}
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/node/runtime/runtimeFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 8 additions & 2 deletions src/node/runtime/sshConnectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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(":");
}
Expand Down
2 changes: 2 additions & 0 deletions src/node/runtime/transports/OpenSSHTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Loading