(ep?.kind ?? "socket");
const [path, setPath] = useState(ep?.kind === "socket" ? ep.path : "/var/run/docker.sock");
const [host, setHost] = useState(ep?.kind === "tcp" ? ep.host : "");
const [tls, setTls] = useState(ep?.kind === "tcp" ? (ep.tlsCertDir ?? "") : "");
const [context, setContext] = useState(ep?.kind === "context" ? ep.name : "");
+ // The configuration picked in the dropdown; shown even when docker cannot use it yet.
+ const [picked, setPicked] = useState(() => (ep?.kind === "tcp" ? sshConfigForUrl(ep.host, sshConfigs) : ""));
+ const fromUrl = sshConfigForUrl(host, sshConfigs);
+ const shown = fromUrl || picked;
+ const unusable = !fromUrl && sshConfigs.find((c) => c.id === picked && c.auth !== "openSsh");
+ const pickSsh = (id: string) => {
+ setPicked(id);
+ const config = sshConfigs.find((c) => c.id === id);
+ if (config?.auth === "openSsh" && config.host.trim()) setHost(sshConfigUrl(config));
+ };
const endpoint = endpointFrom(kind, { path, host, tls, context });
const ok = !!endpoint && name.trim().length > 0;
const submit = async () => {
@@ -184,6 +227,29 @@ function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; o
)}
{kind === "tcp" && (
<>
+
+ SSH configuration:
+
+
+
+
+
+ {unusable && (
+
+ Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.
+
+ )}
diff --git a/src/features/services/services.test.ts b/src/features/services/services.test.ts
index cd8a08c32..9ebe83ff3 100644
--- a/src/features/services/services.test.ts
+++ b/src/features/services/services.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import type { DockerContainer, DockerSnapshot } from "@/platform/ipc";
+import type { SshConfigValid } from "@/platform/remoteSettings";
import { fileIconName } from "@/ui/icons";
import {
composeFileIcon,
@@ -54,7 +55,7 @@ import {
splitLayerInstruction,
unitsToBytes,
} from "./dashboard";
-import { endpointFrom } from "./dialogs";
+import { endpointFrom, sshConfigForUrl, sshConfigUrl } from "./dialogs";
import { dockerItems } from "./docker";
import { setDockerFilters, useServices } from "./store";
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
@@ -209,6 +210,36 @@ describe("services tree host", () => {
expect(endpointFrom("context", { ...f, context: "orbstack" })).toEqual({ kind: "context", name: "orbstack" });
expect(endpointFrom("default", f)).toEqual({ kind: "default" });
});
+
+ it("fills the Engine API URL from an OpenSSH-config SSH configuration and shows the selection", () => {
+ const box: SshConfigValid = {
+ id: "ssh1",
+ name: "Build server",
+ host: "build-server",
+ port: 2222,
+ user: "ops",
+ auth: "openSsh",
+ keyFile: "",
+ saveSecret: false,
+ };
+ const bare: SshConfigValid = { ...box, id: "ssh2", name: "", host: "bare", port: null, user: "", auth: "openSsh" };
+ const password: SshConfigValid = { ...box, id: "ssh3", host: "vault", user: "root", port: null, auth: "password" };
+ const configs = [box, bare, password];
+ expect(sshConfigUrl(box)).toBe("ssh://ops@build-server:2222");
+ expect(sshConfigUrl(bare)).toBe("ssh://bare");
+ // The selection follows the URL the field holds; only OpenSSH-config configurations match.
+ expect(sshConfigForUrl("ssh://ops@build-server:2222", configs)).toBe("ssh1");
+ expect(sshConfigForUrl(" ssh://build-server:2222 ", configs)).toBe("ssh1");
+ expect(sshConfigForUrl("ssh://build-server", configs)).toBe("ssh1");
+ expect(sshConfigForUrl("ssh://bare", configs)).toBe("ssh2");
+ // Another user or port than the configuration's, a tcp URL or a password configuration is not a selection.
+ expect(sshConfigForUrl("ssh://root@build-server", configs)).toBe("");
+ expect(sshConfigForUrl("ssh://build-server:22", configs)).toBe("");
+ expect(sshConfigForUrl("ssh://root@vault", configs)).toBe("");
+ expect(sshConfigForUrl("tcp://build-host:2376", configs)).toBe("");
+ expect(sshConfigForUrl("ssh://", configs)).toBe("");
+ expect(sshConfigForUrl("", configs)).toBe("");
+ });
});
describe("docker run targets", () => {
diff --git a/src/features/settings/components/SshConfigSelect.tsx b/src/features/settings/components/SshConfigSelect.tsx
index a943efb66..8eb37f295 100644
--- a/src/features/settings/components/SshConfigSelect.tsx
+++ b/src/features/settings/components/SshConfigSelect.tsx
@@ -14,15 +14,20 @@ export function SshConfigSelect({
value,
onChange,
none,
+ configs: given,
"aria-label": ariaLabel = "SSH configuration",
}: {
value: string;
onChange: (id: string) => void;
/** Label of an empty choice (e.g. "Local machine"); omitted: no empty choice. */
none?: string;
+ /** The configurations to pick from; omitted: the global `ssh.configs` setting. Forms
+ * outside the settings window pass the live list (the staged base is not loaded there). */
+ configs?: SshConfigValid[];
"aria-label"?: string;
}) {
- const configs = useSshConfigs();
+ const staged = useSshConfigs();
+ const configs = given ?? staged;
const options: SelectOption[] = [
...(none !== undefined ? [{ value: "", label: none }] : []),
...configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) })),
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index eb5aeb91b..3774c5a42 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -7652,5 +7652,8 @@
"Suggested for This Project": "为当前项目推荐",
"Featured": "精选",
"All Plugins": "全部插件",
- "Examples": "示例插件"
+ "Examples": "示例插件",
+ "Settings › Tools › SSH Configurations": "设置 › 工具 › SSH 配置",
+ "Edit SSH Configurations": "编辑 SSH 配置",
+ "Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。"
}