props.onFocusServer(props.server)}
>
-
+
-
-
+
+
{props.server.displayName ?? new URL(props.server.http.url).host}
@@ -390,8 +440,9 @@ function HomeServerRow(props: {
}
aria-label={props.language.t("home.project.add")}
- disabled={props.health?.healthy === false}
+ disabled={props.health?.healthy === false && !authentication()}
onClick={() => props.onChooseProject(props.server)}
/>
@@ -602,6 +653,10 @@ function HomeProjectRow(
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
+ data-home-row
+ data-dimmed={serverUnreachable()}
+ data-dragging={sortable.isDragSource()}
+ data-selected={props.selected ? "" : undefined}
onContextMenu={(event) => {
event.preventDefault()
props.onSetContextMenuOpen(contextMenuID(), true)
@@ -610,7 +665,7 @@ function HomeProjectRow(
- {displayName(props.project)}
+
+ {displayName(props.project)}
+
diff --git a/packages/app/src/runtime/server/client.tsx b/packages/app/src/runtime/server/client.tsx
index 21f7b09bc1b..dcf383399cd 100644
--- a/packages/app/src/runtime/server/client.tsx
+++ b/packages/app/src/runtime/server/client.tsx
@@ -1,7 +1,7 @@
import type { OpenCodeEvent } from "@opencode/client/promise"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { type Accessor, onCleanup } from "solid-js"
+import { type Accessor, createEffect, on, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
@@ -74,8 +74,18 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
+ if (server.type === "ssh") {
+ createEffect(
+ on(
+ () => `${server.http.url}\0${server.http.password ?? ""}`,
+ () => transport.update(server.http),
+ { defer: true },
+ ),
+ )
+ }
const events = createOpenCodeEventSource()
- const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
+ const reconnect =
+ server.type === "ssh" || (server.type === "sidecar" && server.variant === "base") ? server.reconnect : undefined
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
diff --git a/packages/app/src/runtime/server/health.ts b/packages/app/src/runtime/server/health.ts
index 9fe084082ec..64427ef03d4 100644
--- a/packages/app/src/runtime/server/health.ts
+++ b/packages/app/src/runtime/server/health.ts
@@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
-export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean }
+export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -142,25 +142,73 @@ export function useCheckServerHealth() {
}
export const useServerHealth = (servers: Accessor
, enabled: Accessor) => {
- const checkServerHealth = useCheckServerHealth()
+ return createServerHealth(servers, enabled, useCheckServerHealth())
+}
+
+export function createServerHealth(
+ servers: Accessor,
+ enabled: Accessor,
+ check: (http: ServerConnection.HttpBase) => Promise,
+) {
const [status, setStatus] = createStore({} as Record)
+ const endpoints = new Map()
createEffect(() => {
if (!enabled()) {
+ endpoints.clear()
setStatus(reconcile({}))
return
}
- const list = servers()
+ // Snapshot transport fields synchronously so a newly established SSH tunnel
+ // invalidates both the old result and any probe still using the old endpoint.
+ const list = servers().map((conn) => ({
+ key: ServerConnection.key(conn),
+ type: conn.type,
+ http: conn.http,
+ stage: conn.type === "ssh" ? conn.stage : undefined,
+ }))
+ for (const conn of list) {
+ if (conn.stage && conn.stage !== "ready") {
+ endpoints.delete(conn.key)
+ setStatus(
+ conn.key,
+ reconcile(
+ conn.stage === "failed"
+ ? { healthy: false }
+ : conn.stage === "incompatible"
+ ? { healthy: false, incompatible: true }
+ : undefined,
+ ),
+ )
+ continue
+ }
+ const endpoint = cacheKey(conn.http)
+ if (conn.type === "ssh" && endpoints.get(conn.key) !== endpoint) {
+ setStatus(conn.key, reconcile({ healthy: false, checking: true }))
+ }
+ endpoints.set(conn.key, endpoint)
+ }
+ for (const key of endpoints.keys()) {
+ if (!list.some((conn) => conn.key === key)) endpoints.delete(key)
+ }
let dead = false
const refresh = async () => {
- const results: Record = {}
+ const results: Record = {}
await Promise.all(
list.map(async (conn) => {
- const key = ServerConnection.key(conn)
- const result = await checkServerHealth(conn.http)
- results[key] = result
- if (!dead) setStatus(key, result)
+ if (conn.stage && conn.stage !== "ready") {
+ results[conn.key] =
+ conn.stage === "failed"
+ ? { healthy: false }
+ : conn.stage === "incompatible"
+ ? { healthy: false, incompatible: true }
+ : undefined
+ return
+ }
+ const result = await check(conn.http)
+ results[conn.key] = result
+ if (!dead) setStatus(conn.key, reconcile(result))
}),
)
if (dead) return
diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx
index 4de6aeaf692..87c0273b8e3 100644
--- a/packages/app/src/runtime/server/registry.tsx
+++ b/packages/app/src/runtime/server/registry.tsx
@@ -5,6 +5,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { pathKey } from "@/workspaces/path-key"
import { ServerScope } from "@/runtime/server/scope"
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
+import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
@@ -24,6 +25,7 @@ export function normalizeServerUrl(input: string) {
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
+ if (conn.type === "ssh") return conn.host
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
@@ -159,9 +161,14 @@ export namespace ServerConnection {
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
+ stage?: SshItem["stage"]
+ connecting?: boolean
+ authenticationRequired?: boolean
+ id?: string
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
+ reconnect?: (signal: AbortSignal) => Promise
} & Base
export type Any =
@@ -178,7 +185,7 @@ export namespace ServerConnection {
return Key.make("sidecar")
}
case "ssh":
- return Key.make(`ssh:${conn.host}`)
+ return Key.make(`ssh:${conn.id ?? conn.host}`)
}
}
diff --git a/packages/app/src/servers/registry/controller.ts b/packages/app/src/servers/registry/controller.ts
index b3ecf635045..d0d12fb1a69 100644
--- a/packages/app/src/servers/registry/controller.ts
+++ b/packages/app/src/servers/registry/controller.ts
@@ -8,6 +8,7 @@ import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
+import { useSsh } from "../ssh/context"
function showRequestError(language: ReturnType, err: unknown) {
showToast({
@@ -50,6 +51,7 @@ function useDefaultServer() {
export function useServerActionsController() {
const server = useServers()
+ const ssh = useSsh()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -58,6 +60,7 @@ export function useServerActionsController() {
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
+ if (key.startsWith("ssh:")) await ssh.forget(key.slice(4))
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
diff --git a/packages/app/src/servers/registry/row-menu.tsx b/packages/app/src/servers/registry/row-menu.tsx
index d8e7b6755c0..0cf19e6fe3a 100644
--- a/packages/app/src/servers/registry/row-menu.tsx
+++ b/packages/app/src/servers/registry/row-menu.tsx
@@ -5,6 +5,7 @@ import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/servers/registry/controller"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
+import { SshMenu } from "../ssh/menu"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
@@ -15,6 +16,7 @@ export const ServerRowMenu: Component<{
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
+ if (props.server.type === "ssh" && props.server.id) return
return (
(
+
+
+ {(state) => (
+
+ )}
+
+
+ ),
+}
diff --git a/packages/app/src/servers/registry/row.tsx b/packages/app/src/servers/registry/row.tsx
index 6a4e398bde8..60e96095ff4 100644
--- a/packages/app/src/servers/registry/row.tsx
+++ b/packages/app/src/servers/registry/row.tsx
@@ -1,5 +1,7 @@
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
+import { Spinner } from "@opencode/ui/spinner"
+import { useLanguage } from "@/runtime/i18n/language"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
@@ -102,22 +104,53 @@ export function ServerRow(props: ServerRowProps) {
)
}
-export function ServerHealthIndicator(props: { health?: ServerHealth }) {
+export function ServerHealthIndicator(props: {
+ health?: ServerHealth
+ connecting?: boolean
+ authenticationRequired?: boolean
+}) {
+ const language = useLanguage()
return (
+
+ }
+ >
+
+
+ }
+ >
+
+
+
+
}
>
-
+
+
+
)
}
diff --git a/packages/app/src/servers/ssh/authenticate.tsx b/packages/app/src/servers/ssh/authenticate.tsx
new file mode 100644
index 00000000000..0d98517ea1a
--- /dev/null
+++ b/packages/app/src/servers/ssh/authenticate.tsx
@@ -0,0 +1,13 @@
+import { useSsh } from "./context"
+import type { ServerConnection } from "@/runtime/server/registry"
+
+export function useSshAuthenticate() {
+ const ssh = useSsh()
+ return (server: ServerConnection.Any, onConnected?: () => void) => {
+ if (server.type !== "ssh" || !server.authenticationRequired) return false
+ const item = ssh.servers.find((item) => item.config.id === server.id)
+ if (!item) return false
+ ssh.connect(item.config, { onConnected })
+ return true
+ }
+}
diff --git a/packages/app/src/servers/ssh/authentication-state.ts b/packages/app/src/servers/ssh/authentication-state.ts
new file mode 100644
index 00000000000..f374cb548ae
--- /dev/null
+++ b/packages/app/src/servers/ssh/authentication-state.ts
@@ -0,0 +1,25 @@
+import { createEffect } from "solid-js"
+import type { SshItem } from "./types"
+
+// Offer authentication once per selected tab. Cancelling must not immediately
+// reopen the prompt; background hosts never open a dialog here.
+export function createSshAuthentication(input: {
+ selection: () => string | undefined
+ item: () => SshItem | undefined
+ busy: () => boolean
+ open: (item: SshItem) => void
+}) {
+ const state = { selection: undefined as string | undefined, offered: false }
+ createEffect(() => {
+ const selection = input.selection()
+ if (state.selection !== selection) {
+ state.selection = selection
+ state.offered = false
+ }
+ const item = input.item()
+ if (!selection || state.offered || item?.stage !== "authentication" || item.authenticatingElsewhere || input.busy())
+ return
+ state.offered = true
+ input.open(item)
+ })
+}
diff --git a/packages/app/src/servers/ssh/authentication.tsx b/packages/app/src/servers/ssh/authentication.tsx
new file mode 100644
index 00000000000..d1ed09b29c0
--- /dev/null
+++ b/packages/app/src/servers/ssh/authentication.tsx
@@ -0,0 +1,59 @@
+import { useDialog } from "@opencode/ui/context/dialog"
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { useCurrentRoute } from "@/shell/state/layout"
+import { useTabs } from "@/shell/tabs/tabs"
+import { useSsh } from "./context"
+import { createSshAuthentication } from "./authentication-state"
+import { SshConnectionPanel } from "./connection-panel"
+
+export function SshAuthentication(props: ParentProps) {
+ const ssh = useSsh()
+ const route = useCurrentRoute()
+ const tabs = useTabs()
+ const dialog = useDialog()
+ const item = createMemo(() => {
+ const current = route()
+ const key =
+ current.type === "session"
+ ? current.server
+ : current.type === "draft"
+ ? tabs.store.find((tab) => tab.type === "draft" && tab.draftID === current.draftID)?.server
+ : undefined
+ return ssh.servers.find((item) => `ssh:${item.config.id}` === key && item.stage !== "ready")
+ })
+ createSshAuthentication({
+ selection: () => {
+ const current = route()
+ if (current.type === "session") return `${current.server}:${current.sessionId}`
+ if (current.type === "draft") return current.draftID
+ return undefined
+ },
+ item,
+ busy: () => !!dialog.active,
+ open: (item) => ssh.connect(item.config),
+ })
+ return (
+
+ {/* Keep the route mounted so reconnecting preserves its draft and local UI state. */}
+
+ {props.children}
+
+
+ {(item) => (
+
+ ssh.connect(item().config)}
+ />
+
+ )}
+
+
+ )
+}
diff --git a/packages/app/src/servers/ssh/connection-panel.tsx b/packages/app/src/servers/ssh/connection-panel.tsx
new file mode 100644
index 00000000000..283fd42ccc3
--- /dev/null
+++ b/packages/app/src/servers/ssh/connection-panel.tsx
@@ -0,0 +1,47 @@
+import { Button } from "@opencode/ui/button"
+import { Icon } from "@opencode/ui/icon"
+import { Spinner } from "@opencode/ui/spinner"
+import { Show } from "solid-js"
+import { useLanguage } from "@/runtime/i18n/language"
+import { sshName } from "./name"
+import { isSshConnecting } from "./status"
+import type { SshItem } from "./types"
+
+export function SshConnectionPanel(props: { item: SshItem; pending?: boolean; onReconnect: () => void }) {
+ const language = useLanguage()
+ const connecting = () => props.pending || isSshConnecting(props.item.stage)
+ return (
+
+
+
+
{language.t("ssh.session.disconnected")}
+
+ {sshName(props.item.config)}
+
+
{language.t("ssh.session.reconnectDescription")}
+
+
+ {(error) => (
+
+ {language.t(`ssh.error.${error()}`)}
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/app/src/servers/ssh/context.tsx b/packages/app/src/servers/ssh/context.tsx
new file mode 100644
index 00000000000..eee73bf7826
--- /dev/null
+++ b/packages/app/src/servers/ssh/context.tsx
@@ -0,0 +1,69 @@
+import { createSimpleContext } from "@opencode/ui/context"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
+import { createEffect, onCleanup, untrack, type ParentProps } from "solid-js"
+import { usePlatform } from "@/runtime/platform/platform"
+import { useLanguage } from "@/runtime/i18n/language"
+import { showToast } from "@/shell/notifications/toast"
+import { createSshController } from "./controller"
+import { DialogSsh } from "./dialog"
+import type { SshState } from "./types"
+
+const key = ["platform", "sshServers"] as const
+const context = createSimpleContext({
+ name: "Ssh",
+ init: () => {
+ const platform = usePlatform()
+ const client = useQueryClient()
+ const language = useLanguage()
+ const query = useQuery(() =>
+ queryOptions({
+ queryKey: key,
+ queryFn: () => platform.sshServers?.getState() ?? Promise.resolve({ servers: [] }),
+ staleTime: Infinity,
+ }),
+ )
+ createEffect(() => {
+ const off = platform.sshServers?.subscribe((state) => client.setQueryData(key, state))
+ if (off) onCleanup(off)
+ })
+ return {
+ get servers() {
+ return query.data?.servers ?? []
+ },
+ get loading() {
+ return query.isLoading
+ },
+ ...createSshController({
+ items: () => query.data?.servers ?? [],
+ api: platform.sshServers,
+ refresh: () => query.refetch({ throwOnError: true }),
+ error: () => showToast({ variant: "error", title: language.t("common.requestFailed") }),
+ }),
+ }
+ },
+})
+
+export const useSsh = () => context.use()
+
+export function SshProvider(props: ParentProps) {
+ return (
+
+
+ {props.children}
+
+ )
+}
+
+function SshDialogs() {
+ const ssh = useSsh()
+ // Capture an owner inside the SSH context, independent of transient rows and menus.
+ const dialog = useDialog()
+ createEffect(() => {
+ const item = ssh.dialog.next()
+ if (!item || dialog.active) return
+ ssh.dialog.opened(item.config.id)
+ untrack(() => void dialog.push(() => ))
+ })
+ return null
+}
diff --git a/packages/app/src/servers/ssh/controller.ts b/packages/app/src/servers/ssh/controller.ts
new file mode 100644
index 00000000000..58d71abc807
--- /dev/null
+++ b/packages/app/src/servers/ssh/controller.ts
@@ -0,0 +1,153 @@
+import { Effect, Fiber } from "effect"
+import { createEffect, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { SshConfig, SshItem, SshPlatform } from "./types"
+import { isSshConnecting } from "./status"
+
+export function createSshController(input: {
+ items: () => readonly SshItem[]
+ api: Pick | undefined
+ refresh: () => Promise
+ error: () => void
+}) {
+ const [attempts, setAttempts] = createStore<
+ Record<
+ string,
+ | {
+ active: boolean
+ submitting: boolean
+ prompted: boolean
+ answered?: string
+ error: boolean
+ onConnected?: () => void
+ }
+ | undefined
+ >
+ >({})
+ const tasks = new Map>()
+ const item = (id: string) => input.items().find((item) => item.config.id === id)
+ const settle = (id: string) => {
+ const attempt = attempts[id]
+ if (!attempt?.active) return
+ const onConnected = attempt.onConnected
+ setAttempts(id, { active: false, onConnected: undefined })
+ if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
+ }
+ const run = (id: string, effect: Effect.Effect) => {
+ setAttempts(id, { submitting: true, error: false })
+ tasks.set(
+ id,
+ Effect.runFork(
+ effect.pipe(
+ Effect.asVoid,
+ Effect.catch(() =>
+ Effect.sync(() => {
+ setAttempts(id, "error", true)
+ if (!attempts[id]?.prompted) input.error()
+ }),
+ ),
+ Effect.ensuring(
+ Effect.sync(() => {
+ tasks.delete(id)
+ setAttempts(id, "submitting", false)
+ }),
+ ),
+ ),
+ ),
+ )
+ }
+ onCleanup(() => {
+ Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
+ })
+ createEffect(() => {
+ for (const item of input.items()) {
+ const attempt = attempts[item.config.id]
+ if (!attempt?.active || attempt.submitting) continue
+ if (
+ item.stage === "ready" ||
+ item.stage === "failed" ||
+ item.stage === "disconnected" ||
+ item.authenticatingElsewhere ||
+ (attempt.prompted && item.stage === "authentication" && !item.prompt)
+ ) {
+ settle(item.config.id)
+ }
+ }
+ })
+ return {
+ item,
+ submitting: (id: string) => !!attempts[id]?.submitting,
+ error: (id: string) => !!attempts[id]?.error,
+ answered: (id: string) =>
+ !!item(id)?.prompt && !attempts[id]?.error && attempts[id]?.answered === item(id)?.prompt?.id,
+ pending: (id: string) =>
+ !!attempts[id]?.submitting ||
+ !!item(id)?.authenticatingElsewhere ||
+ isSshConnecting(item(id)?.stage ?? "disconnected"),
+ dialog: {
+ next: () =>
+ input.items().find((item) => {
+ const attempt = attempts[item.config.id]
+ return (
+ attempt?.active &&
+ !attempt.submitting &&
+ !attempt.prompted &&
+ !attempt.error &&
+ (item.prompt || item.stage === "incompatible")
+ )
+ }),
+ opened: (id: string) => setAttempts(id, "prompted", true),
+ },
+ connect: (config: SshConfig, options?: { dialog?: boolean; replace?: boolean; onConnected?: () => void }) => {
+ const api = input.api
+ if (!api || item(config.id)?.authenticatingElsewhere) return
+ if (
+ attempts[config.id]?.submitting ||
+ (attempts[config.id]?.active && !attempts[config.id]?.error && !options?.replace)
+ )
+ return
+ setAttempts(config.id, {
+ active: true,
+ submitting: true,
+ prompted: !!options?.dialog,
+ answered: undefined,
+ error: false,
+ onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
+ })
+ run(
+ config.id,
+ Effect.gen(function* () {
+ yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
+ // Observe admission before treating an older disconnected snapshot as cancellation.
+ yield* Effect.tryPromise(input.refresh)
+ }),
+ )
+ },
+ respond: (id: string, prompt: string, value: string) => {
+ const api = input.api
+ if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
+ if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
+ setAttempts(id, "answered", prompt)
+ run(
+ id,
+ Effect.tryPromise(() => api.respond(id, prompt, value)),
+ )
+ },
+ cancel: (id: string) => {
+ const task = tasks.get(id)
+ const api = input.api
+ Effect.runFork(
+ Effect.gen(function* () {
+ if (task) yield* Fiber.interrupt(task)
+ setAttempts(id, undefined)
+ if (!api) return
+ yield* Effect.tryPromise(() => api.cancel(id))
+ if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
+ }).pipe(Effect.ignore),
+ )
+ },
+ restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
+ disconnect: (id: string) => input.api?.disconnect(id),
+ forget: (id: string) => input.api?.forget(id),
+ }
+}
diff --git a/packages/app/src/servers/ssh/dialog.stories.tsx b/packages/app/src/servers/ssh/dialog.stories.tsx
new file mode 100644
index 00000000000..96a45d655b7
--- /dev/null
+++ b/packages/app/src/servers/ssh/dialog.stories.tsx
@@ -0,0 +1,289 @@
+import { Button } from "@opencode/ui/button"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
+import { onCleanup, onMount, Show } from "solid-js"
+import { PlatformProvider } from "@/runtime/platform/platform"
+import { SshProvider, useSsh } from "./context"
+import { useSshAuthenticate } from "./authenticate"
+import { HomeProjectsView } from "@/home/projects/view"
+import { ServerConnection } from "@/runtime/server/registry"
+import { useLanguage } from "@/runtime/i18n/language"
+import { DialogSsh } from "./dialog"
+import type { SshItem, SshPlatform, SshState } from "./types"
+import { SshConnectionPanel } from "./connection-panel"
+import { SshServerSettings } from "./settings"
+
+function Fixture(props: {
+ session?: boolean
+ settings?: boolean
+ incompatible?: boolean
+ keyOnly?: boolean
+ connectionDelay?: number
+ initial?: "connecting" | "password" | "confirmation" | "failure" | "required"
+ responseDelay?: number
+}) {
+ const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType } = {
+ step: 0,
+ item:
+ props.initial === "required"
+ ? {
+ config: { id: "story", target: "ssh linuxbook", name: "" },
+ stage: props.session || props.settings ? "disconnected" : "authentication",
+ saved: true,
+ detail: "",
+ }
+ : undefined,
+ }
+ onCleanup(() => clearTimeout(state.timer))
+ const listeners = new Set<(state: SshState) => void>()
+ const snapshot = (): SshState => ({ servers: state.item ? [state.item] : [] })
+ const update = (changes: Partial) => {
+ if (!state.item) return
+ state.item = { ...state.item, ...changes }
+ listeners.forEach((listener) => listener(snapshot()))
+ }
+ const prompts = [
+ {
+ id: "host-key",
+ text: "The authenticity of host 'dev.example.com' can't be established.\nED25519 key fingerprint is SHA256:EXAMPLE-FINGERPRINT-FOR-STORY-ONLY.\nAre you sure you want to continue connecting?",
+ confirm: true,
+ },
+ { id: "password", text: "brendon@dev.example.com's password:", confirm: false },
+ { id: "otp", text: "Verification code:", confirm: false },
+ ]
+ const api: SshPlatform = {
+ getState: async () => snapshot(),
+ subscribe(callback) {
+ listeners.add(callback)
+ return () => {
+ listeners.delete(callback)
+ }
+ },
+ hosts: async () => ["devbox", "staging", "build-host"],
+ start: async (input) => {
+ clearTimeout(state.timer)
+ state.before = state.item
+ state.step = props.initial === "password" || props.initial === "required" ? 1 : 0
+ state.item = {
+ config: input,
+ saved: props.initial === "required",
+ stage: "connecting",
+ detail: "",
+ destination: "brendon@dev.example.com:22",
+ }
+ if (props.initial === "connecting") return
+ if (props.incompatible && !input.replace) {
+ update({ stage: "incompatible", error: "version" })
+ return
+ }
+ if (props.connectionDelay) {
+ update({ stage: "connecting" })
+ state.timer = setTimeout(
+ () => update(props.keyOnly ? { stage: "ready" } : { stage: "authentication", prompt: prompts[state.step] }),
+ props.connectionDelay,
+ )
+ return
+ }
+ update(
+ props.initial === "failure"
+ ? {
+ stage: "failed",
+ error: "connection",
+ detail: "ssh: connect to host dev.example.com port 22: Connection refused",
+ }
+ : { stage: "authentication", prompt: prompts[state.step] },
+ )
+ },
+ respond: async () => {
+ const next = () => {
+ state.step += 1
+ update(
+ state.step < prompts.length
+ ? { stage: "authentication", prompt: prompts[state.step] }
+ : { stage: "ready", prompt: undefined },
+ )
+ }
+ if (!props.responseDelay) return next()
+ update({ stage: "connecting", prompt: undefined })
+ state.timer = setTimeout(next, props.responseDelay)
+ },
+ resolve: async () => null,
+ disconnect: async () => {
+ clearTimeout(state.timer)
+ update({ stage: "disconnected", prompt: undefined })
+ },
+ cancel: async () => {
+ if (state.item?.stage === "incompatible") return
+ clearTimeout(state.timer)
+ update({ ...state.before, stage: state.before?.stage ?? "disconnected", prompt: undefined })
+ },
+ forget: async () => {
+ state.item = undefined
+ listeners.forEach((listener) => listener(snapshot()))
+ },
+ openConfig: async () => {},
+ }
+ return (
+ {},
+ notify: async () => {},
+ openDirectoryPickerDialog: async () => null,
+ }}
+ >
+
+
+ {props.settings ? (
+
+ ) : props.session ? (
+
+ ) : props.initial === "required" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+function AuthenticationSettings() {
+ return (
+
+ [], health: () => ({}) },
+ defaults: { available: () => false, key: () => null, set: async () => {} },
+ connection: {
+ canRemove: () => false,
+ remove: async () => {},
+ canHide: () => false,
+ isHidden: () => false,
+ setHidden: () => {},
+ },
+ }}
+ />
+
+ )
+}
+
+function AuthenticationSession() {
+ const ssh = useSsh()
+ return (
+
+
+ {(item) => (
+ Session connected
}>
+ ssh.connect(item().config)}
+ />
+
+ )}
+
+
+ )
+}
+
+function AuthenticationHome() {
+ const language = useLanguage()
+ const ssh = useSsh()
+ const authenticate = useSshAuthenticate()
+ const server: ServerConnection.Ssh = {
+ type: "ssh",
+ id: "story",
+ host: "linuxbook",
+ displayName: "linuxbook",
+ label: "SSH",
+ http: { url: "http://127.0.0.1:0" },
+ get authenticationRequired() {
+ return ssh.servers[0]?.stage === "authentication"
+ },
+ get connecting() {
+ return ssh.servers[0]?.stage === "connecting"
+ },
+ }
+ const projects = [{ worktree: "/home/user/project", expanded: true }]
+ return (
+
+ ({ healthy: ssh.servers[0]?.stage === "ready" })}
+ projectsForServer={() => projects}
+ collapsed={() => false}
+ canDefaultServer={false}
+ defaultServerKey={null}
+ canRevealProject={() => false}
+ unseenCount={() => 0}
+ onWheel={() => {}}
+ onChooseProject={(server) => {
+ authenticate(server)
+ }}
+ onFocusServer={(server) => {
+ authenticate(server)
+ }}
+ onAuthenticateServer={(server) => {
+ authenticate(server)
+ }}
+ onToggleCollapsed={() => {}}
+ onEditServer={() => {}}
+ onSetDefaultServer={() => {}}
+ canRemoveServer={() => false}
+ onRemoveServer={() => {}}
+ canHideServer={() => false}
+ onHideServer={() => {}}
+ onMoveProject={() => {}}
+ onSelectProject={() => {}}
+ onAddProjects={() => {}}
+ onOpenProjectNewSession={() => {}}
+ canImportSession={false}
+ onImportSession={() => {}}
+ onEditProject={() => {}}
+ onRevealProject={() => {}}
+ onClearNotifications={() => {}}
+ onCloseProject={() => {}}
+ onOpenSettings={() => {}}
+ onOpenHelp={() => {}}
+ />
+
+ )
+}
+
+function Open(props: { initial?: string }) {
+ const dialog = useDialog()
+ const open = () =>
+ dialog.show(() => (
+
+ ))
+ onMount(open)
+ return
+}
+
+export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
+export const AuthenticationRequired = { render: () =>
}
+export const SettingsReconnect = { render: () =>
}
+export const IncompatibleSession = { render: () =>
}
+export const InactiveSession = { render: () =>
}
+export const KeyReconnect = { render: () =>
}
+export const Host = { render: () =>
}
+export const Connecting = { render: () =>
}
+export const Password = { render: () =>
}
+export const SlowPassword = { render: () =>
}
+export const Confirmation = { render: () =>
}
+export const Failure = { render: () =>
}
diff --git a/packages/app/src/servers/ssh/dialog.tsx b/packages/app/src/servers/ssh/dialog.tsx
new file mode 100644
index 00000000000..0c6530cbc96
--- /dev/null
+++ b/packages/app/src/servers/ssh/dialog.tsx
@@ -0,0 +1,246 @@
+import { Button } from "@opencode/ui/button"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
+import { Divider } from "@opencode/ui/divider"
+import { TextInput } from "@opencode/ui/text-input"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { createEffect, createMemo, onCleanup, onMount, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/runtime/i18n/language"
+import { ServerConnection, useServers } from "@/runtime/server/registry"
+import { useTabs } from "@/shell/tabs/tabs"
+import { useDirectoryPicker } from "@/workspaces/selection/picker"
+import { useSsh } from "./context"
+import type { SshConfig, SshItem } from "./types"
+import { sshName } from "./name"
+import { isSshConnecting } from "./status"
+import "@/settings/settings.css"
+import "./ssh.css"
+
+export function useOpenSshProject() {
+ const servers = useServers()
+ const picker = useDirectoryPicker()
+ const tabs = useTabs()
+ const language = useLanguage()
+ return (id: string) => {
+ const server = servers.list.find((server) => server.type === "ssh" && server.id === id)
+ if (!server) return
+ picker({
+ server,
+ title: language.t("ssh.project", { host: server.displayName || (server.type === "ssh" ? server.host : "") }),
+ onSelect: (value) => {
+ const directory = Array.isArray(value) ? value[0] : value
+ if (!directory) return
+ const key = ServerConnection.key(server)
+ servers.projects.forServer(key).open(directory)
+ void tabs.newDraft({ server: key, directory })
+ },
+ })
+ }
+}
+
+export function DialogSsh(props: {
+ config?: SshConfig
+ connect?: boolean
+ promptOnly?: boolean
+ openProject?: boolean
+ onConnected?: () => void
+}) {
+ const dialog = useDialog()
+ const language = useLanguage()
+ const ssh = useSsh()
+ // Entry-point behavior is fixed for the lifetime of this dialog. Settings
+ // connects without needing the project/tab contexts used by the palette.
+ const openProject = props.openProject ? useOpenSshProject() : undefined
+ const id = props.config?.id ?? crypto.randomUUID()
+ let cancelButton: HTMLButtonElement | undefined
+ const [state, setState] = createStore({
+ target: props.config?.target ?? "",
+ name: props.config?.name ?? "",
+ started: !!props.promptOnly,
+ prompted: !!props.promptOnly,
+ response: "",
+ complete: false,
+ })
+ const item = createMemo(() => ssh.item(id))
+ const error = createMemo(() => {
+ if (ssh.error(id)) return language.t("common.requestFailed")
+ const error = item()?.error
+ return error ? language.t(`ssh.error.${error}`) : undefined
+ })
+ const busy = createMemo(
+ () => ssh.submitting(id) || (state.started && !ssh.error(id) && isSshConnecting(item()?.stage ?? "connecting")),
+ )
+ const prompt = createMemo
((previous) => item()?.prompt ?? (busy() ? previous : undefined))
+ const waiting = () => busy() || ssh.answered(id)
+ const start = (replace = false) => {
+ if (busy() || !state.target.trim()) return
+ setState({ started: true, prompted: !!prompt() })
+ ssh.connect({ id, target: state.target, name: state.name }, { dialog: true, replace })
+ }
+ const respond = () => {
+ const current = item()?.prompt
+ if (!current || waiting() || (!current.confirm && !state.response)) return
+ ssh.respond(id, current.id, current.confirm ? "yes" : state.response)
+ }
+ createEffect(() => {
+ prompt()?.id
+ setState("response", "")
+ if (prompt()) setState("prompted", true)
+ // Never let a focused Continue button become Trust between SSH challenges.
+ if (prompt()?.confirm) queueMicrotask(() => cancelButton?.focus())
+ })
+ createEffect(() => {
+ if (!state.started || item()?.stage !== "ready" || ssh.submitting(id) || state.complete) return
+ setState("complete", true)
+ dialog.close()
+ if (openProject) queueMicrotask(() => openProject(id))
+ if (props.onConnected) queueMicrotask(props.onConnected)
+ })
+ onMount(() => {
+ if (props.connect) start()
+ })
+ onCleanup(() => {
+ if (state.started && !state.complete) ssh.cancel(id)
+ })
+ const keyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Enter" || event.isComposing) return
+ event.preventDefault()
+ if (prompt()) return
+ start(item()?.stage === "incompatible")
+ }
+ return (
+
+ )
+}
diff --git a/packages/app/src/servers/ssh/menu.tsx b/packages/app/src/servers/ssh/menu.tsx
new file mode 100644
index 00000000000..c07688de82f
--- /dev/null
+++ b/packages/app/src/servers/ssh/menu.tsx
@@ -0,0 +1,56 @@
+import { Icon } from "@opencode/ui/icon"
+import { IconButton } from "@opencode/ui/icon-button"
+import { Menu } from "@opencode/ui/menu"
+import { Show } from "solid-js"
+import { useLanguage } from "@/runtime/i18n/language"
+import type { ServerActionsController } from "@/servers/registry/controller"
+import { ServerConnection } from "@/runtime/server/registry"
+import { useSsh } from "./context"
+
+export function SshMenu(props: { id: string; domain: ServerActionsController }) {
+ const ssh = useSsh()
+ const language = useLanguage()
+ const item = () => ssh.item(props.id)
+ const key = () => ServerConnection.Key.make(`ssh:${props.id}`)
+ return (
+
+ {(item) => (
+
+ )}
+
+ )
+}
diff --git a/packages/app/src/servers/ssh/name.test.ts b/packages/app/src/servers/ssh/name.test.ts
new file mode 100644
index 00000000000..0c524d7572e
--- /dev/null
+++ b/packages/app/src/servers/ssh/name.test.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "bun:test"
+import { sshHostname, sshName } from "./name"
+
+test.each([
+ ["brendan-box.exe.xyz", "brendan-box.exe.xyz"],
+ ["ssh brendan-box.exe.xyz", "brendan-box.exe.xyz"],
+ ["ssh anomaly@brendan-box.exe.xyz", "brendan-box.exe.xyz"],
+ ['ssh -p 2222 -i "/keys/my key" -J jump@example.com anomaly@devbox', "devbox"],
+ ["ssh -o 'ProxyCommand=ssh jump -W %h:%p' 'anomaly@devbox'", "devbox"],
+ [" ssh 'brendan-'box.exe.xyz ", "brendan-box.exe.xyz"],
+ ["ssh user@[2001:db8::1]", "[2001:db8::1]"],
+ ["", ""],
+])("uses the hostname from %s", (target, hostname) => {
+ expect(sshHostname(target)).toBe(hostname)
+ expect(sshName({ target, name: "" })).toBe(hostname)
+})
+
+test("preserves custom names and the original command", () => {
+ const config = { name: "Development", target: "ssh -p 2222 anomaly@devbox" }
+ expect(sshName(config)).toBe("Development")
+ expect(config.target).toBe("ssh -p 2222 anomaly@devbox")
+})
diff --git a/packages/app/src/servers/ssh/name.ts b/packages/app/src/servers/ssh/name.ts
new file mode 100644
index 00000000000..5469bae8701
--- /dev/null
+++ b/packages/app/src/servers/ssh/name.ts
@@ -0,0 +1,16 @@
+import type { SshConfig } from "./types"
+
+export function sshHostname(target: string) {
+ // Accepted SSH targets end with a hostname or user@hostname, never a remote
+ // command. Strip shell quoting for display only; keep the saved target intact.
+ return (
+ (target.trim().split(/\s+/).at(-1) ?? "")
+ .replace(/["'\\]/g, "")
+ .split("@")
+ .at(-1) ?? ""
+ )
+}
+
+export function sshName(config: Pick) {
+ return config.name || sshHostname(config.target)
+}
diff --git a/packages/app/src/servers/ssh/restore-state.ts b/packages/app/src/servers/ssh/restore-state.ts
new file mode 100644
index 00000000000..cef7d76ccc7
--- /dev/null
+++ b/packages/app/src/servers/ssh/restore-state.ts
@@ -0,0 +1,17 @@
+import { createEffect } from "solid-js"
+import type { SshStart, SshState } from "./types"
+
+export function createSshRestore(input: {
+ state: () => SshState | undefined
+ start: (input: SshStart) => Promise | undefined
+}) {
+ const restored = new Set()
+ createEffect(() => {
+ for (const item of input.state()?.servers ?? []) {
+ if (!item.saved || restored.has(item.config.id)) continue
+ // Mark active connections too, so a later manual disconnect is respected.
+ restored.add(item.config.id)
+ if (item.stage === "disconnected") void input.start({ ...item.config, background: true })
+ }
+ })
+}
diff --git a/packages/app/src/servers/ssh/restore.tsx b/packages/app/src/servers/ssh/restore.tsx
new file mode 100644
index 00000000000..7cc088adac1
--- /dev/null
+++ b/packages/app/src/servers/ssh/restore.tsx
@@ -0,0 +1,11 @@
+import { useSsh } from "./context"
+import { createSshRestore } from "./restore-state"
+
+export function SshRestore() {
+ const ssh = useSsh()
+ createSshRestore({
+ state: () => ({ servers: ssh.servers }),
+ start: ssh.restore,
+ })
+ return null
+}
diff --git a/packages/app/src/servers/ssh/settings.tsx b/packages/app/src/servers/ssh/settings.tsx
new file mode 100644
index 00000000000..05c53fda797
--- /dev/null
+++ b/packages/app/src/servers/ssh/settings.tsx
@@ -0,0 +1,87 @@
+import { For, Show } from "solid-js"
+import { useLanguage } from "@/runtime/i18n/language"
+import type { ServerCollectionController } from "@/servers/registry/controller"
+import { ServerHealthIndicator } from "@/servers/registry/row"
+import { ServerConnection } from "@/runtime/server/registry"
+import { useSsh } from "./context"
+import { SshMenu } from "./menu"
+import { Badge } from "@opencode/ui/badge"
+import { Button } from "@opencode/ui/button"
+import { Spinner } from "@opencode/ui/spinner"
+import { sshName } from "./name"
+import { isSshConnecting } from "./status"
+
+export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
+ const ssh = useSsh()
+ const language = useLanguage()
+ return (
+
+ item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
+ )}
+ >
+ {(item) => {
+ const key = ServerConnection.Key.make(`ssh:${item.config.id}`)
+ const health = () => props.domain.collection.health()[key]
+ const indicator = () => {
+ if (item.stage === "ready") return health() ?? { healthy: true }
+ if (item.stage === "incompatible") return { healthy: false, incompatible: true }
+ if (item.stage === "failed") return { healthy: false }
+ return undefined
+ }
+ return (
+
+
+
+
+
+
+ {sshName(item.config)}
+
+
+ {language.t("ssh.label")}
+
+
+
+ {(version) => v{version()}}
+
+ }
+ >
+ {language.t("ssh.stage.authentication")}
+
+
+
+
+
+
+
+
+ {language.t("dialog.server.status.default")}
+
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/packages/app/src/servers/ssh/ssh.css b/packages/app/src/servers/ssh/ssh.css
new file mode 100644
index 00000000000..fe374e5292a
--- /dev/null
+++ b/packages/app/src/servers/ssh/ssh.css
@@ -0,0 +1,10 @@
+.ssh-prompt {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-family: var(--font-family-mono);
+ font-size: 12px;
+ line-height: var(--line-height-compact);
+ max-height: 200px;
+ overflow: auto;
+ user-select: text;
+}
diff --git a/packages/app/src/servers/ssh/status.ts b/packages/app/src/servers/ssh/status.ts
new file mode 100644
index 00000000000..7fe585fe7cc
--- /dev/null
+++ b/packages/app/src/servers/ssh/status.ts
@@ -0,0 +1,11 @@
+import type { SshItem } from "./types"
+
+export function isSshConnecting(stage: SshItem["stage"]) {
+ return (
+ stage === "connecting" ||
+ stage === "checking" ||
+ stage === "downloading" ||
+ stage === "uploading" ||
+ stage === "starting"
+ )
+}
diff --git a/packages/app/src/servers/ssh/types.ts b/packages/app/src/servers/ssh/types.ts
new file mode 100644
index 00000000000..97a1983cd56
--- /dev/null
+++ b/packages/app/src/servers/ssh/types.ts
@@ -0,0 +1,72 @@
+import { Schema } from "effect"
+export { sshHostname, sshName } from "./name"
+export { isSshConnecting } from "./status"
+
+export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
+export type SshConfig = typeof SshConfig.Type
+export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
+export type SshHttp = typeof SshHttp.Type
+export const SshStage = Schema.Literals([
+ "disconnected",
+ "connecting",
+ "checking",
+ "downloading",
+ "uploading",
+ "starting",
+ "ready",
+ "authentication",
+ "incompatible",
+ "failed",
+])
+export const SshPrompt = Schema.Struct({
+ id: Schema.String,
+ text: Schema.String,
+ confirm: Schema.Boolean,
+})
+export const SshItem = Schema.Struct({
+ config: SshConfig,
+ saved: Schema.Boolean,
+ destination: Schema.optional(Schema.String),
+ stage: SshStage,
+ http: Schema.optional(SshHttp),
+ prompt: Schema.optional(SshPrompt),
+ authenticatingElsewhere: Schema.optional(Schema.Boolean),
+ detail: Schema.String,
+ error: Schema.optional(
+ Schema.Literals([
+ "connection",
+ "input",
+ "platform",
+ "version",
+ "install",
+ "service",
+ "host-key",
+ "ssh-missing",
+ "unpublished",
+ ]),
+ ),
+})
+export type SshItem = typeof SshItem.Type
+export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
+export type SshState = typeof SshState.Type
+export const SshStart = Schema.Struct({
+ id: Schema.String,
+ target: Schema.String,
+ name: Schema.String,
+ replace: Schema.optional(Schema.Boolean),
+ background: Schema.optional(Schema.Boolean),
+})
+export type SshStart = typeof SshStart.Type
+
+export type SshPlatform = {
+ getState(): Promise
+ subscribe(callback: (state: SshState) => void): () => void
+ hosts(): Promise
+ start(input: SshStart): Promise
+ resolve(id: string): Promise
+ respond(id: string, prompt: string, value: string): Promise
+ disconnect(id: string): Promise
+ cancel(id: string): Promise
+ forget(id: string): Promise
+ openConfig(): Promise
+}
diff --git a/packages/app/src/servers/wsl/settings.tsx b/packages/app/src/servers/wsl/settings.tsx
index 3324b723366..a0f00420e6d 100644
--- a/packages/app/src/servers/wsl/settings.tsx
+++ b/packages/app/src/servers/wsl/settings.tsx
@@ -16,6 +16,7 @@ import { showToast } from "@/shell/notifications/toast"
import { DialogAddWslServer } from "./dialog"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
+import { DialogSsh } from "../ssh/dialog"
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
@@ -30,7 +31,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
}
return (
{language.t("dialog.server.add.button")}
@@ -44,7 +45,12 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
{language.t("dialog.server.add.button")}
- {language.t("wsl.server.add")}
+
+ void dialog.push(() => )}>{language.t("ssh.add")}
+
+
+ {language.t("wsl.server.add")}
+
diff --git a/packages/app/src/settings/servers/servers.tsx b/packages/app/src/settings/servers/servers.tsx
index ddd769001fa..3fbaaebd891 100644
--- a/packages/app/src/settings/servers/servers.tsx
+++ b/packages/app/src/settings/servers/servers.tsx
@@ -13,6 +13,8 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { SettingsList } from "@/settings/list"
+import { SshServerSettings } from "@/servers/ssh/settings"
+import { useSsh } from "@/servers/ssh/context"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import "@/settings/settings.css"
@@ -22,13 +24,14 @@ export const SettingsServers: Component = () => {
const controller = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
+ const ssh = useSsh()
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
- const items = controller.collection.items().filter((item) => !isWslServer(item))
+ const items = controller.collection.items().filter((item) => !isWslServer(item) && item.type !== "ssh")
const query = store.filter.trim()
if (!query) return items
return fuzzysort
@@ -89,7 +92,7 @@ export const SettingsServers: Component = () => {
0 || wslServers().length > 0}
+ when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
fallback={
{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}
@@ -100,6 +103,7 @@ export const SettingsServers: Component = () => {
}
>
+
{(item) => {
diff --git a/packages/app/src/shell/commands/desktop.tsx b/packages/app/src/shell/commands/desktop.tsx
index 277d74d8acb..71e3ba3d40c 100644
--- a/packages/app/src/shell/commands/desktop.tsx
+++ b/packages/app/src/shell/commands/desktop.tsx
@@ -1,14 +1,24 @@
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand, type CommandOption } from "./command"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { DialogSsh } from "@/servers/ssh/dialog"
export function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
+ const dialog = useDialog()
command.register("desktop", () => {
const commands: CommandOption[] = []
+ if (platform.sshServers)
+ commands.push({
+ id: "server.ssh.add",
+ title: language.t("ssh.add"),
+ category: language.t("command.category.server"),
+ onSelect: () => void dialog.push(() => ),
+ })
if (platform.platform !== "desktop" || !platform.exportDebugLogs) return commands
commands.push({
id: "logs.export",
diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx
index 2c084c1cdf6..8e65e36a5d4 100644
--- a/packages/app/src/shell/shell.tsx
+++ b/packages/app/src/shell/shell.tsx
@@ -8,6 +8,7 @@ import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
+import { SshAuthentication } from "@/servers/ssh/authentication"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -97,9 +98,9 @@ export default function Layout(props: ParentProps) {
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
-
+
{props.children}
-
+
diff --git a/packages/app/test-browser/ssh-authentication.test.ts b/packages/app/test-browser/ssh-authentication.test.ts
new file mode 100644
index 00000000000..dc9148a87e5
--- /dev/null
+++ b/packages/app/test-browser/ssh-authentication.test.ts
@@ -0,0 +1,100 @@
+import { expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSshAuthentication } from "../src/servers/ssh/authentication-state"
+import type { SshItem } from "../src/servers/ssh/types"
+
+test("background authentication stays quiet; selecting a tab prompts once and cancellation is respected", () => {
+ const opened: string[] = []
+ const fixture = createRoot((dispose) => {
+ const [state, setState] = createStore<{ selection?: string; busy: boolean; item: SshItem }>({
+ busy: false,
+ item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "authentication", saved: true, detail: "" },
+ })
+ createSshAuthentication({
+ selection: () => state.selection,
+ item: () => state.item,
+ busy: () => state.busy,
+ open: (item) => {
+ opened.push(item.config.id)
+ setState("busy", true)
+ },
+ })
+ return { dispose, setState }
+ })
+ try {
+ expect(opened).toEqual([])
+ fixture.setState("selection", "session-1")
+ expect(opened).toEqual(["host"])
+ fixture.setState("busy", false)
+ fixture.setState("item", "detail", "new status")
+ expect(opened).toHaveLength(1)
+ fixture.setState("selection", undefined)
+ fixture.setState("selection", "session-1")
+ expect(opened).toHaveLength(2)
+ } finally {
+ fixture.dispose()
+ }
+})
+
+test("a selected tab waits for authentication and other dialogs before offering a prompt", () => {
+ const opened: string[] = []
+ const fixture = createRoot((dispose) => {
+ const [state, setState] = createStore<{ busy: boolean; item: SshItem }>({
+ busy: true,
+ item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "connecting", saved: true, detail: "" },
+ })
+ createSshAuthentication({
+ selection: () => "draft-1",
+ item: () => state.item,
+ busy: () => state.busy,
+ open: (item) => {
+ opened.push(item.config.id)
+ },
+ })
+ return { dispose, setState }
+ })
+ try {
+ expect(opened).toEqual([])
+ fixture.setState("item", "stage", "authentication")
+ expect(opened).toEqual([])
+ fixture.setState("busy", false)
+ expect(opened).toEqual(["host"])
+ fixture.setState("item", "stage", "connecting")
+ fixture.setState("item", "stage", "authentication")
+ expect(opened).toHaveLength(1)
+ } finally {
+ fixture.dispose()
+ }
+})
+
+test("selecting a tab waits for another window to release authentication", () => {
+ const opened: string[] = []
+ const fixture = createRoot((dispose) => {
+ const [state, setState] = createStore<{ item: SshItem }>({
+ item: {
+ config: { id: "host", target: "linuxbook", name: "" },
+ stage: "authentication",
+ authenticatingElsewhere: true,
+ saved: true,
+ detail: "",
+ },
+ })
+ createSshAuthentication({
+ selection: () => "session-1",
+ item: () => state.item,
+ busy: () => false,
+ open: (item) => opened.push(item.config.id),
+ })
+ return { dispose, setState }
+ })
+ try {
+ expect(opened).toEqual([])
+ fixture.setState("item", "detail", "still waiting in another window")
+ expect(opened).toEqual([])
+ fixture.setState("item", "authenticatingElsewhere", false)
+ expect(opened).toEqual(["host"])
+ } finally {
+ fixture.dispose()
+ }
+})
diff --git a/packages/app/test-browser/ssh-composer.test.ts b/packages/app/test-browser/ssh-composer.test.ts
new file mode 100644
index 00000000000..49c88c2346b
--- /dev/null
+++ b/packages/app/test-browser/ssh-composer.test.ts
@@ -0,0 +1,38 @@
+import { expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createComposerEditor } from "../src/composer/editor/interaction"
+import type { ComposerPersistedState } from "../src/composer/types"
+
+test("a disconnected composer preserves text and ignores submissions until reconnect", () => {
+ createRoot((dispose) => {
+ const [state, setState] = createStore({ connected: false, submissions: 0 })
+ const store = createStore({
+ prompt: [{ type: "text", content: "keep my draft", start: 0, end: 13 }],
+ context: { items: [] },
+ })
+ const editor = createComposerEditor({
+ store,
+ commands: () => [],
+ context: () => [],
+ searchContextFiles: () => [],
+ view: {
+ submit: {
+ available: () => state.connected,
+ stopping: () => false,
+ onStop() {},
+ onSubmit: () => setState("submissions", (count) => count + 1),
+ },
+ },
+ })
+ expect(editor.canSubmit()).toBe(false)
+ editor.submit()
+ expect(state.submissions).toBe(0)
+ expect(editor.value()).toBe("keep my draft")
+ setState("connected", true)
+ expect(editor.canSubmit()).toBe(true)
+ editor.submit()
+ expect(state.submissions).toBe(1)
+ dispose()
+ })
+})
diff --git a/packages/app/test-browser/ssh-controller.test.ts b/packages/app/test-browser/ssh-controller.test.ts
new file mode 100644
index 00000000000..98502f83313
--- /dev/null
+++ b/packages/app/test-browser/ssh-controller.test.ts
@@ -0,0 +1,257 @@
+import { expect, test } from "bun:test"
+import { createEffect, createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSshController } from "../src/servers/ssh/controller"
+import type { SshItem } from "../src/servers/ssh/types"
+
+function fixture() {
+ return createRoot((dispose) => {
+ const config = { id: "host", target: "ssh linuxbook", name: "" }
+ const [state, setState] = createStore<{ items: SshItem[]; busy: boolean }>({
+ items: [{ config, stage: "disconnected", saved: true, detail: "" }],
+ busy: false,
+ })
+ const calls = { starts: 0, responses: 0, cancels: 0, forgets: 0, prompts: 0, errors: 0, connected: 0 }
+ const admission = Promise.withResolvers()
+ const refresh = Promise.withResolvers()
+ const response = Promise.withResolvers()
+ const cancelled = Promise.withResolvers()
+ const ssh = createSshController({
+ items: () => state.items,
+ api: {
+ start: () => {
+ calls.starts++
+ return admission.promise
+ },
+ respond: () => {
+ calls.responses++
+ return calls.responses === 1 ? response.promise : Promise.resolve()
+ },
+ cancel: async () => {
+ calls.cancels++
+ setState("items", 0, { stage: "disconnected", prompt: undefined })
+ cancelled.resolve()
+ },
+ forget: async () => {
+ calls.forgets++
+ setState("items", [])
+ },
+ disconnect: async () => {},
+ },
+ refresh: () => refresh.promise,
+ error: () => calls.errors++,
+ })
+ createEffect(() => {
+ const item = ssh.dialog.next()
+ if (!item || state.busy) return
+ ssh.dialog.opened(item.config.id)
+ calls.prompts++
+ })
+ const admitted = async () => {
+ admission.resolve()
+ refresh.resolve()
+ await refresh.promise
+ await Promise.resolve()
+ await Promise.resolve()
+ }
+ return { dispose, config, setState, calls, admission, refresh, response, cancelled, admitted, ssh }
+ })
+}
+
+test("key-based reconnect stays pending through admission and refetch without opening a dialog", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
+ app.ssh.connect(app.config)
+ expect(app.ssh.pending("host")).toBe(true)
+ app.admission.resolve()
+ await app.admission.promise
+ await Promise.resolve()
+ expect(app.ssh.pending("host")).toBe(true)
+ expect(app.calls.starts).toBe(1)
+ app.setState("items", 0, "stage", "connecting")
+ await app.admitted()
+ expect(app.ssh.pending("host")).toBe(true)
+ expect(app.calls.prompts).toBe(0)
+ app.setState("items", 0, "stage", "ready")
+ await Promise.resolve()
+ expect(app.ssh.pending("host")).toBe(false)
+ expect(app.calls.connected).toBe(1)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("reconnect waits for an available dialog and opens only once across SSH challenges", async () => {
+ const app = fixture()
+ try {
+ app.setState("busy", true)
+ app.ssh.connect(app.config)
+ app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
+ await app.admitted()
+ expect(app.calls.prompts).toBe(0)
+ app.setState("busy", false)
+ expect(app.calls.prompts).toBe(1)
+ app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
+ expect(app.calls.prompts).toBe(1)
+ expect(app.calls.starts).toBe(1)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("a connection form owns its challenges and reports request failures inline", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config, { dialog: true })
+ app.admission.reject(new Error("IPC unavailable"))
+ await app.admission.promise.catch(() => {})
+ await Promise.resolve()
+ expect(app.ssh.error("host")).toBe(true)
+ expect(app.ssh.submitting("host")).toBe(false)
+ expect(app.calls.errors).toBe(0)
+ expect(app.calls.prompts).toBe(0)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("responding suppresses duplicate submissions while allowing the next SSH challenge", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config)
+ app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
+ await app.admitted()
+ app.ssh.respond("host", "expired", "ignored")
+ app.ssh.respond("host", "password", "secret")
+ app.ssh.respond("host", "password", "secret")
+ expect(app.calls.responses).toBe(1)
+ expect(app.ssh.submitting("host")).toBe(true)
+ app.response.resolve()
+ await app.response.promise
+ await Promise.resolve()
+ expect(app.ssh.answered("host")).toBe(true)
+ app.ssh.respond("host", "password", "secret")
+ expect(app.calls.responses).toBe(1)
+ app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
+ expect(app.ssh.answered("host")).toBe(false)
+ app.ssh.respond("host", "otp", "123456")
+ expect(app.calls.responses).toBe(2)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("failed reconnect becomes retryable without opening a connection form", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config)
+ app.setState("items", 0, "stage", "connecting")
+ await app.admitted()
+ app.setState("items", 0, "stage", "failed")
+ expect(app.ssh.pending("host")).toBe(false)
+ expect(app.calls.prompts).toBe(0)
+ app.ssh.connect(app.config)
+ expect(app.calls.starts).toBe(2)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("a failed response can be retried without losing the reconnect continuation", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
+ app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
+ await app.admitted()
+ app.ssh.respond("host", "password", "secret")
+ app.response.reject(new Error("IPC unavailable"))
+ await app.response.promise.catch(() => {})
+ await Promise.resolve()
+ expect(app.ssh.error("host")).toBe(true)
+ expect(app.ssh.answered("host")).toBe(false)
+ expect(app.calls.errors).toBe(0)
+ app.ssh.respond("host", "password", "secret")
+ await Promise.resolve()
+ expect(app.calls.responses).toBe(2)
+ expect(app.ssh.error("host")).toBe(false)
+ app.setState("items", 0, "stage", "ready")
+ await Promise.resolve()
+ expect(app.calls.connected).toBe(1)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("cancelling a version-mismatch dialog allows another reconnect", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config)
+ app.setState("items", 0, "stage", "incompatible")
+ await app.admitted()
+ expect(app.calls.prompts).toBe(1)
+ app.ssh.cancel("host")
+ await app.cancelled.promise
+ app.ssh.connect(app.config)
+ app.setState("items", 0, "stage", "incompatible")
+ await app.admitted()
+ expect(app.calls.starts).toBe(2)
+ expect(app.calls.prompts).toBe(2)
+ expect(app.calls.forgets).toBe(0)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("updating from the authentication dialog preserves the continuation and invokes it once", async () => {
+ const app = fixture()
+ try {
+ app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
+ app.setState("items", 0, "stage", "incompatible")
+ await app.admitted()
+ app.ssh.connect(app.config, { dialog: true, replace: true })
+ app.setState("items", 0, "stage", "connecting")
+ await app.admitted()
+ app.setState("items", 0, "stage", "ready")
+ await Promise.resolve()
+ app.setState("items", 0, "detail", "updated")
+ await Promise.resolve()
+ expect(app.calls.connected).toBe(1)
+ expect(app.calls.prompts).toBe(1)
+ } finally {
+ app.dispose()
+ }
+})
+
+test("cancelling an unsaved connection interrupts admission and forgets it", async () => {
+ const app = fixture()
+ try {
+ app.setState("items", 0, "saved", false)
+ app.ssh.connect(app.config, { dialog: true })
+ app.ssh.cancel("host")
+ await app.cancelled.promise
+ await Promise.resolve()
+ expect(app.calls.cancels).toBe(1)
+ expect(app.calls.forgets).toBe(1)
+ expect(app.ssh.item("host")).toBeUndefined()
+ expect(app.ssh.submitting("host")).toBe(false)
+ app.admission.resolve()
+ } finally {
+ app.dispose()
+ }
+})
+
+test("another window's authentication stays pending without starting a competing attempt", () => {
+ const app = fixture()
+ try {
+ app.setState("items", 0, { stage: "authentication", authenticatingElsewhere: true })
+ app.ssh.connect(app.config)
+ expect(app.ssh.pending("host")).toBe(true)
+ expect(app.calls.starts).toBe(0)
+ app.setState("items", 0, "authenticatingElsewhere", false)
+ app.ssh.connect(app.config)
+ expect(app.calls.starts).toBe(1)
+ } finally {
+ app.dispose()
+ }
+})
diff --git a/packages/app/test-browser/ssh-health.test.ts b/packages/app/test-browser/ssh-health.test.ts
new file mode 100644
index 00000000000..67faa0040de
--- /dev/null
+++ b/packages/app/test-browser/ssh-health.test.ts
@@ -0,0 +1,130 @@
+import { expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createServerHealth, type ServerHealth } from "../src/runtime/server/health"
+import { ServerConnection } from "../src/runtime/server/registry"
+import type { SshItem } from "../src/servers/ssh/types"
+
+test("SSH health is checked only with an active tunnel, and cancellation clears stale failures", async () => {
+ const requests: ReturnType>[] = []
+ const app = createRoot((dispose) => {
+ const [state, setState] = createStore<{ stage: SshItem["stage"] }>({ stage: "disconnected" })
+ const connection: ServerConnection.Ssh = {
+ type: "ssh",
+ id: "fixture",
+ host: "devbox",
+ http: { url: "http://127.0.0.1:12345" },
+ get stage() {
+ return state.stage
+ },
+ }
+ const health = createServerHealth(
+ () => [connection],
+ () => true,
+ () => {
+ const request = Promise.withResolvers()
+ requests.push(request)
+ return request.promise
+ },
+ )
+ return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
+ })
+ try {
+ expect(app.health()).toBeUndefined()
+ app.setState("stage", "connecting")
+ app.setState("stage", "authentication")
+ app.setState("stage", "disconnected")
+ expect(requests).toHaveLength(0)
+ expect(app.health()).toBeUndefined()
+ app.setState("stage", "ready")
+ expect(requests).toHaveLength(1)
+ expect(app.health()?.checking).toBe(true)
+ app.setState("stage", "authentication")
+ requests[0]?.resolve({ healthy: false })
+ await Promise.resolve()
+ expect(app.health()).toBeUndefined()
+ app.setState("stage", "failed")
+ expect(app.health()?.healthy).toBe(false)
+ app.setState("stage", "authentication")
+ expect(app.health()).toBeUndefined()
+ app.setState("stage", "ready")
+ requests[1]?.resolve({ healthy: false })
+ await Promise.resolve()
+ expect(app.health()).toEqual({ healthy: false })
+ } finally {
+ app.dispose()
+ }
+})
+
+function fixture() {
+ const requests: ReturnType>[] = []
+ return createRoot((dispose) => {
+ const [state, setState] = createStore({ url: "http://127.0.0.1:0", connecting: true })
+ const connection: ServerConnection.Ssh = {
+ type: "ssh",
+ id: "fixture",
+ host: "devbox",
+ get http() {
+ return { url: state.url }
+ },
+ get connecting() {
+ return state.connecting
+ },
+ }
+ const health = createServerHealth(
+ () => [connection],
+ () => true,
+ () => {
+ const request = Promise.withResolvers()
+ requests.push(request)
+ return request.promise
+ },
+ )
+ return { dispose, setState, requests, health: () => health[ServerConnection.key(connection)] }
+ })
+}
+
+test("a new SSH endpoint stays checking after connection completes instead of showing the old failure", async () => {
+ const app = fixture()
+ try {
+ app.requests[0]?.resolve({ healthy: false })
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(app.health()?.healthy).toBe(false)
+ app.setState({ url: "http://127.0.0.1:12345", connecting: false })
+ expect(app.health()).toEqual({ healthy: false, checking: true })
+ app.requests[1]?.resolve({ healthy: true, version: "2.0.0" })
+ await Promise.resolve()
+ expect(app.health()).toEqual({ healthy: true, version: "2.0.0" })
+ } finally {
+ app.dispose()
+ }
+})
+
+test("a late failure from the old endpoint cannot overwrite the new endpoint check", async () => {
+ const app = fixture()
+ try {
+ app.setState({ url: "http://127.0.0.1:12345", connecting: false })
+ app.requests[0]?.resolve({ healthy: false })
+ await Promise.resolve()
+ expect(app.health()).toEqual({ healthy: false, checking: true })
+ app.requests[1]?.resolve({ healthy: true })
+ await Promise.resolve()
+ expect(app.health()).toEqual({ healthy: true })
+ } finally {
+ app.dispose()
+ }
+})
+
+test("a failed check of the new tunnel stops checking and still reports failure", async () => {
+ const app = fixture()
+ try {
+ app.setState({ url: "http://127.0.0.1:12345", connecting: false })
+ expect(app.health()?.checking).toBe(true)
+ app.requests[1]?.resolve({ healthy: false })
+ await Promise.resolve()
+ expect(app.health()).toEqual({ healthy: false })
+ } finally {
+ app.dispose()
+ }
+})
diff --git a/packages/app/test-browser/ssh-restore.test.ts b/packages/app/test-browser/ssh-restore.test.ts
new file mode 100644
index 00000000000..55178791604
--- /dev/null
+++ b/packages/app/test-browser/ssh-restore.test.ts
@@ -0,0 +1,68 @@
+import { expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSshRestore } from "../src/servers/ssh/restore-state"
+import type { SshItem, SshStart, SshState } from "../src/servers/ssh/types"
+
+const server = (id: string, stage: SshItem["stage"] = "disconnected", saved = true): SshItem => ({
+ config: { id, target: `ssh ${id}`, name: "" },
+ saved,
+ stage,
+ detail: "",
+})
+
+test("restores every saved server after loading without tabs or a default server", () => {
+ const starts: SshStart[] = []
+ const fixture = createRoot((dispose) => {
+ const [state, setState] = createStore<{ current?: SshState }>({})
+ createSshRestore({
+ state: () => state.current,
+ start: (input) => {
+ starts.push(input)
+ return Promise.resolve()
+ },
+ })
+ return { dispose, setState }
+ })
+ try {
+ expect(starts).toEqual([])
+ fixture.setState("current", {
+ servers: [server("devbox"), server("buildbox"), server("draft", "disconnected", false)],
+ })
+ expect(starts).toEqual([
+ { ...server("devbox").config, background: true },
+ { ...server("buildbox").config, background: true },
+ ])
+ fixture.setState("current", "servers", 0, "stage", "connecting")
+ fixture.setState("current", "servers", 0, "stage", "disconnected")
+ expect(starts).toHaveLength(2)
+ } finally {
+ fixture.dispose()
+ }
+})
+
+test("does not restart active connections or authentication prompts after state updates", () => {
+ const starts: SshStart[] = []
+ const fixture = createRoot((dispose) => {
+ const [state, setState] = createStore({
+ servers: [server("ready", "ready"), server("busy", "connecting"), server("prompt", "authentication")],
+ })
+ createSshRestore({
+ state: () => state,
+ start: (input) => {
+ starts.push(input)
+ return Promise.resolve()
+ },
+ })
+ return { dispose, setState }
+ })
+ try {
+ expect(starts).toEqual([])
+ fixture.setState("servers", 0, "stage", "disconnected")
+ fixture.setState("servers", 1, "stage", "failed")
+ fixture.setState("servers", 2, "stage", "disconnected")
+ expect(starts).toEqual([])
+ } finally {
+ fixture.dispose()
+ }
+})
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 39e5f7bd8a5..1e63ab61dba 100755
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -15,6 +15,11 @@ import { Npm } from "@opencode/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
+if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
+ const { askpass } = await import("./ssh-askpass")
+ process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
+}
+
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
diff --git a/packages/cli/src/ssh-askpass.test.ts b/packages/cli/src/ssh-askpass.test.ts
new file mode 100644
index 00000000000..4ae5b7740ec
--- /dev/null
+++ b/packages/cli/src/ssh-askpass.test.ts
@@ -0,0 +1,36 @@
+import { expect, test } from "bun:test"
+import { createServer } from "node:net"
+import path from "node:path"
+
+test("the executable askpass branch returns only the response, without CLI output", async () => {
+ const requests: string[] = []
+ const server = createServer((socket) => {
+ socket.once("data", (data: Buffer) => {
+ requests.push(data.toString())
+ socket.end(JSON.stringify({ value: 'passphrase"with spaces' }))
+ })
+ })
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
+ const address = server.address()
+ if (!address || typeof address === "string") throw new Error("missing listener")
+ try {
+ const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "index.ts"), "Enter passphrase:"], {
+ env: {
+ ...process.env,
+ OPENCODE_SSH_ASKPASS_PORT: String(address.port),
+ OPENCODE_SSH_ASKPASS_TOKEN: "fixture",
+ SSH_ASKPASS_PROMPT: "confirm",
+ },
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ expect(await new Response(child.stdout).text()).toBe('passphrase"with spaces\n')
+ expect(await child.exited).toBe(0)
+ expect(requests.map((request) => JSON.parse(request))).toEqual([
+ { token: "fixture", text: "Enter passphrase:", confirm: true },
+ ])
+ expect(await new Response(child.stderr).text()).toBe("")
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}, 30_000)
diff --git a/packages/cli/src/ssh-askpass.ts b/packages/cli/src/ssh-askpass.ts
new file mode 100644
index 00000000000..b8db1291207
--- /dev/null
+++ b/packages/cli/src/ssh-askpass.ts
@@ -0,0 +1,40 @@
+import { NodeSocket } from "@effect/platform-node"
+import { Effect, Schema, Stdio, Stream } from "effect"
+
+const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
+const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
+
+// OpenSSH invokes the executable directly, including on Windows. Run outside
+// normal CLI observability so neither prompts nor responses enter its logs.
+export const askpass = Effect.gen(function* () {
+ const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
+ const stdio = yield* Stdio.Stdio
+ const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
+ const write = yield* socket.writer
+ const response = { text: "" }
+ yield* Effect.all(
+ [
+ socket.runString((text) =>
+ Effect.sync(() => {
+ response.text += text
+ }),
+ ),
+ write(
+ JSON.stringify({
+ token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
+ text: process.argv.slice(2).join(" "),
+ confirm: process.env.SSH_ASKPASS_PROMPT === "confirm",
+ }) + "\n",
+ ),
+ ],
+ { concurrency: "unbounded", discard: true },
+ )
+ const result = yield* Schema.decodeUnknownEffect(Response)(response.text)
+ if (result.value === null) return 1
+ yield* Stream.make(result.value + "\n").pipe(Stream.run(stdio.stdout({ endOnDone: false })))
+ return 0
+}).pipe(
+ Effect.scoped,
+ Effect.timeout("5 minutes"),
+ Effect.orElseSucceed(() => 1),
+)
diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts
index 0abdff38f49..5be8a49e073 100644
--- a/packages/desktop/electron.vite.config.test.ts
+++ b/packages/desktop/electron.vite.config.test.ts
@@ -61,6 +61,8 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
"output" in result ? result.output.filter((item) => item.type === "chunk") : [],
)
expect(chunks.length).toBeGreaterThan(0)
+ // Resource resolution must not depend on which lazy entry owns DesktopPaths.
+ expect(chunks.every((chunk) => !chunk.fileName.includes("/"))).toBe(true)
const imports = chunks.flatMap((chunk) => [...chunk.imports, ...chunk.dynamicImports])
const modules = chunks.flatMap((chunk) => Object.keys(chunk.modules))
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts
index feb761fe5b8..4d04881c855 100644
--- a/packages/desktop/electron.vite.config.ts
+++ b/packages/desktop/electron.vite.config.ts
@@ -45,6 +45,9 @@ export default defineConfig(({ command }) => ({
// corrupt bundled TypeScript, while an output banner places the shim safely.
output: {
format: "es",
+ // DesktopPaths resolves resources from the main output directory,
+ // including when the lazy desktop entry shares it with other chunks.
+ chunkFileNames: "[name]-[hash].js",
banner: `
// -- CommonJS Shims --
import __cjs_mod__ from 'node:module';
diff --git a/packages/desktop/src/main/desktop.ts b/packages/desktop/src/main/desktop.ts
new file mode 100644
index 00000000000..38169f573d5
--- /dev/null
+++ b/packages/desktop/src/main/desktop.ts
@@ -0,0 +1,32 @@
+import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
+import { app } from "electron"
+import { Effect, Layer } from "effect"
+import { Ipc } from "./ipc"
+import { DesktopInitialization } from "./lifecycle/desktop-initialization"
+import { ApplicationLifecycle } from "./lifecycle"
+import { BackgroundService } from "./service/background-service"
+import { DesktopCli } from "./service/desktop-cli"
+import { UpdaterLive } from "./updater/live"
+
+const runIpc = Effect.fn("Desktop.runIpc")(function* () {
+ const lifecycle = yield* ApplicationLifecycle.Service
+ const ipc = yield* Ipc.registerIpcHandlers
+ if (lifecycle.restoreWindows().length) ipc.installMenu()
+ yield* Effect.callback((resume) => {
+ const quit = () => resume(Effect.void)
+ app.once("will-quit", quit)
+ return Effect.sync(() => app.off("will-quit", quit))
+ })
+})
+
+runIpc().pipe(
+ Effect.provide(Ipc.layer),
+ Effect.provide(BackgroundService.layer),
+ Effect.provide(DesktopCli.layer),
+ Effect.provide(UpdaterLive.layer),
+ Effect.provide(DesktopInitialization.layer),
+ Effect.provide(ApplicationLifecycle.layer),
+ Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
+ Effect.scoped,
+ NodeRuntime.runMain,
+)
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index ceff0982225..8f34744a1ed 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -1,34 +1,3 @@
-import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
-import * as NodePath from "@effect/platform-node/NodePath"
-import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
-import { app } from "electron"
-import { Effect, Layer } from "effect"
-import { Ipc } from "./ipc"
-import { DesktopInitialization } from "./lifecycle/desktop-initialization"
-import { ApplicationLifecycle } from "./lifecycle"
-import { BackgroundService } from "./service/background-service"
-import { DesktopCli } from "./service/desktop-cli"
-import { UpdaterLive } from "./updater/live"
+export {}
-const runIpc = Effect.fn("Desktop.runIpc")(function* () {
- const lifecycle = yield* ApplicationLifecycle.Service
- const ipc = yield* Ipc.registerIpcHandlers
- if (lifecycle.restoreWindows().length) ipc.installMenu()
- yield* Effect.callback((resume) => {
- const quit = () => resume(Effect.void)
- app.once("will-quit", quit)
- return Effect.sync(() => app.off("will-quit", quit))
- })
-})
-
-runIpc().pipe(
- Effect.provide(Ipc.layer),
- Effect.provide(BackgroundService.layer),
- Effect.provide(DesktopCli.layer),
- Effect.provide(UpdaterLive.layer),
- Effect.provide(DesktopInitialization.layer),
- Effect.provide(ApplicationLifecycle.layer),
- Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
- Effect.scoped,
- NodeRuntime.runMain,
-)
+await import("./desktop")
diff --git a/packages/desktop/src/main/ipc-handlers/ssh.ts b/packages/desktop/src/main/ipc-handlers/ssh.ts
new file mode 100644
index 00000000000..38ac877e2f5
--- /dev/null
+++ b/packages/desktop/src/main/ipc-handlers/ssh.ts
@@ -0,0 +1,25 @@
+import { Effect } from "effect"
+import { SshRpcs } from "../../shared/ipc-rpc"
+import { IpcPortHandoff } from "../ipc-transport"
+import { Ssh } from "../ssh/service"
+import { sender } from "./context"
+
+export const sshHandlers = SshRpcs.toLayer(
+ Effect.gen(function* () {
+ const handoff = yield* IpcPortHandoff
+ const ssh = yield* Ssh.Service
+ return SshRpcs.of({
+ SshGetState: (_args, context) => ssh.state(sender(handoff, context).id),
+ SshSubscribe: (_args, context) => ssh.subscribeWindow(sender(handoff, context)),
+ SshUnsubscribe: (_args, context) => ssh.unsubscribeWindow(sender(handoff, context).id),
+ SshHosts: () => ssh.hosts(),
+ SshStart: (input, context) => ssh.start(input, input.background ? undefined : sender(handoff, context).id),
+ SshResolve: ({ id }) => ssh.resolve(id),
+ SshRespond: ({ id, prompt, value }, context) => ssh.respond(id, prompt, value, sender(handoff, context).id),
+ SshDisconnect: ({ id }) => ssh.disconnect(id),
+ SshCancel: ({ id }, context) => ssh.cancel(id, sender(handoff, context).id),
+ SshForget: ({ id }) => ssh.forget(id).pipe(Effect.orDie),
+ SshOpenConfig: () => ssh.openConfig().pipe(Effect.orDie),
+ })
+ }),
+)
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 257b633b18b..a79750d57c1 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -14,6 +14,8 @@ import { storageHandlers } from "./ipc-handlers/storage"
import { updaterHandlers } from "./ipc-handlers/updater"
import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
+import { sshHandlers } from "./ipc-handlers/ssh"
+import { Ssh } from "./ssh/service"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
@@ -23,7 +25,7 @@ import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
-const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
+const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -32,6 +34,7 @@ const handlers = Layer.mergeAll(
menuHandlers,
updaterHandlers,
wslHandlers,
+ sshHandlers,
eventHandlers,
)
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
diff --git a/packages/desktop/src/main/remote/cli.test.ts b/packages/desktop/src/main/remote/cli.test.ts
new file mode 100644
index 00000000000..25236626e86
--- /dev/null
+++ b/packages/desktop/src/main/remote/cli.test.ts
@@ -0,0 +1,113 @@
+import { expect, test } from "bun:test"
+import { Effect, FileSystem, Path } from "effect"
+import { NodeServices } from "@effect/platform-node"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+import { testEffect } from "../../../../core/test/lib/effect"
+import { RemoteCli } from "./cli"
+
+const it = testEffect(NodeServices.layer)
+// These scripts execute on the POSIX remote host, not the Windows desktop.
+const posix = process.platform === "win32" ? it.live.skip : it.live
+
+it.live(
+ "resolves the beta channel and rejects unavailable or invalid metadata",
+ Effect.gen(function* () {
+ for (const response of [
+ Response.json({ version: "0.0.0-beta-19059" }),
+ Response.json({ version: "2.0.0-local-123" }),
+ Response.json({ version: "0.0.0-beta-19059" }, { status: 503 }),
+ ]) {
+ const result = yield* RemoteCli.latestBeta().pipe(
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
+ return Effect.succeed(HttpClientResponse.fromWeb(request, response))
+ }),
+ ),
+ Effect.result,
+ )
+ if (response.status === 200 && result._tag === "Success") expect(result.success).toBe("0.0.0-beta-19059")
+ else expect(result._tag).toBe("Failure")
+ }
+ }),
+)
+
+posix(
+ "discovers the managed CLI by default and uses PATH only when requested",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "remote-cli-" })
+ const home = path.join(dir, "home with ' quotes")
+ yield* fs.makeDirectory(path.join(home, ".opencode/bin"), { recursive: true })
+ yield* fs.makeDirectory(path.join(dir, "bin"))
+ const managed = path.join(home, ".opencode/bin/opencode2")
+ const external = path.join(dir, "bin/opencode2")
+ yield* fs.writeFileString(managed, "#!/bin/sh\nprintf 'OpenCode v2.0.0\\n'\n", { mode: 0o755 })
+ yield* fs.writeFileString(external, "#!/bin/sh\nprintf 'OpenCode v2.1.0\\n'\n", { mode: 0o755 })
+ const run = (script: string) =>
+ spawner.string(
+ ChildProcess.make("sh", ["-c", script], {
+ env: { HOME: home, PATH: `${path.join(dir, "bin")}:/usr/bin:/bin` },
+ }),
+ )
+ expect((yield* run(RemoteCli.discoverScript())).trim()).toBe(managed)
+ expect((yield* run(RemoteCli.discoverScript({ fromPath: true }))).trim()).toBe(external)
+ expect(RemoteCli.parseVersion(yield* run(RemoteCli.versionScript(RemoteCli.quote(managed))))).toBe("2.0.0")
+ yield* fs.remove(managed)
+ expect((yield* run(RemoteCli.discoverScript())).trim()).toBe("")
+ expect(RemoteCli.parseVersion(yield* run(RemoteCli.versionScript(RemoteCli.quote(managed))))).toBeNull()
+ }),
+)
+
+test("pins platform-specific artifacts and rejects unsafe inputs", () => {
+ expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
+ "https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
+ )
+ expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
+ expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
+})
+
+posix(
+ "downloads or uploads the same archive into managed and version-specific locations",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "remote-install-" })
+ yield* fs.makeDirectory(path.join(dir, "package/bin"), { recursive: true })
+ yield* fs.writeFileString(path.join(dir, "package/bin/opencode2"), "#!/bin/sh\nprintf 'OpenCode v2.0.0\\n'\n", {
+ mode: 0o755,
+ })
+ const archive = path.join(dir, "archive.tgz")
+ expect(
+ yield* spawner.exitCode(ChildProcess.make("tar", ["-czf", archive, "-C", dir, "package"], { extendEnv: true })),
+ ).toBe(0)
+ const server = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ fetch: () => new Response(Bun.file(archive)),
+ })
+ yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true)))
+ const run = (input: Parameters[0]) =>
+ spawner.exitCode(
+ ChildProcess.make("sh", ["-c", RemoteCli.installScript(input)], {
+ env: { HOME: dir },
+ extendEnv: true,
+ stdin: fs.stream(archive),
+ }),
+ )
+ expect(yield* run({ version: "2.0.0", source: { type: "download", url: server.url.href } })).toBe(0)
+ expect(yield* fs.readFileString(path.join(dir, ".opencode/bin/opencode2"))).toContain("2.0.0")
+ expect(
+ yield* run({ version: "2.0.0", directory: ".opencode/desktop-ssh/2.0.0", source: { type: "archive" } }),
+ ).toBe(0)
+ expect(yield* fs.readFileString(path.join(dir, ".opencode/desktop-ssh/2.0.0/opencode2"))).toContain("2.0.0")
+ expect(yield* run({ version: "2.1.0", source: { type: "archive" } })).not.toBe(0)
+ expect(yield* fs.readFileString(path.join(dir, ".opencode/bin/opencode2"))).toContain("2.0.0")
+ expect(yield* fs.readDirectory(path.join(dir, ".opencode/bin"))).toEqual(["opencode2"])
+ }),
+)
diff --git a/packages/desktop/src/main/remote/cli.ts b/packages/desktop/src/main/remote/cli.ts
new file mode 100644
index 00000000000..f71a68dbd03
--- /dev/null
+++ b/packages/desktop/src/main/remote/cli.ts
@@ -0,0 +1,126 @@
+export * as RemoteCli from "./cli"
+
+import { Effect, Schema } from "effect"
+import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+import { parseCliVersion } from "../service/cli-version"
+
+export class Failure extends Schema.TaggedError()("RemoteCliFailure", {
+ code: Schema.Literals(["platform", "version", "install"]),
+ detail: Schema.String,
+}) {
+ override get message() {
+ return this.detail
+ }
+}
+
+export function quote(value: string) {
+ return `'${value.replaceAll("'", "'\\''")}'`
+}
+
+export function requireVersion(version: string) {
+ if (version !== "local" && !/^[0-9][a-zA-Z0-9.+-]*$/.test(version))
+ throw new Failure({ code: "version", detail: version })
+ return version
+}
+
+export function discoverScript(options: { fromPath?: boolean; cache?: { directory: string; prefix: string } } = {}) {
+ return `cli=${options.fromPath ? "$(command -v opencode2 || true)" : '""'}
+if [ -z "$cli" ] && [ -x "$HOME/.opencode/bin/opencode2" ]; then cli="$HOME/.opencode/bin/opencode2"; fi
+${
+ options.cache
+ ? `if [ -z "$cli" ]; then
+ for binary in "$HOME"/${quote(options.cache.directory)}/${quote(options.cache.prefix)}*/opencode2; do
+ if [ -x "$binary" ]; then cli="$binary"; fi
+ done
+fi
+`
+ : ""
+}if [ -n "$cli" ]; then printf '%s\\n' "$cli"; fi
+`
+}
+
+// Adapters supply a quoted shell expression, including remote HOME or wslpath expansion.
+export function versionScript(command: string) {
+ return `if [ -x ${command} ]; then ${command} --version 2>/dev/null || true; fi\n`
+}
+
+export function parseVersion(output: string) {
+ const line = output
+ .split(/\r?\n/)
+ .find((line) => line.trim())
+ ?.trim()
+ return line ? parseCliVersion(line) : null
+}
+
+export const probeScript = `set -eu
+os=$(uname -s | tr '[:upper:]' '[:lower:]')
+arch=$(uname -m)
+case "$os" in linux|darwin) ;; *) exit 2 ;; esac
+case "$arch" in x86_64|amd64) arch=x64 ;; aarch64|arm64) arch=arm64 ;; *) exit 2 ;; esac
+target="$os-$arch"
+if [ "$arch" = x64 ]; then target="$target-baseline"; fi
+if [ "$os" = linux ]; then
+ if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then target="$target-musl"; fi
+fi
+printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target"
+`
+
+export function archiveUrl(target: string, version: string) {
+ if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target))
+ throw new Failure({ code: "platform", detail: target })
+ return `https://registry.npmjs.org/@opencode-ai/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
+}
+
+type Source = { type: "download"; url: string } | { type: "archive" } | { type: "installer"; binary?: string }
+
+export function installScript(input: { version: string; directory?: string; source: Source }) {
+ const version = requireVersion(input.version)
+ // The managed CLI installer also configures the user's shell PATH. Private
+ // installations use archives so their destination and shell setup stay isolated.
+ if (input.source.type === "installer")
+ return `set -eu
+curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- ${input.source.binary ? `--binary ${input.source.binary}` : `--version ${quote(version)}`}
+${verifyScript('"$HOME/.opencode/bin/opencode2"', version)}
+`
+ return `set -eu
+umask 077
+destination="$HOME"/${quote(`${input.directory ?? ".opencode/bin"}/opencode2`)}
+mkdir -p "$(dirname "$destination")"
+stage=$(mktemp -d "$(dirname "$destination")/.install-XXXXXX")
+trap 'rm -rf "$stage"' EXIT
+${stageBinary(input.source)}
+chmod 755 "$stage/package/bin/opencode2"
+${verifyScript('"$stage/package/bin/opencode2"', version)}
+mv "$stage/package/bin/opencode2" "$destination"
+`
+}
+
+function stageBinary(source: Exclude) {
+ if (source.type === "archive") return 'cat > "$stage/archive.tgz"\ntar -xzf "$stage/archive.tgz" -C "$stage"'
+ return `url=${quote(source.url)}
+if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --connect-timeout 15 --max-time 180 "$url" -o "$stage/archive.tgz"
+else
+ wget -T 180 -O "$stage/archive.tgz" "$url"
+fi
+tar -xzf "$stage/archive.tgz" -C "$stage"`
+}
+
+function verifyScript(command: string, version: string) {
+ return `test "$(${command} --version | awk '{print $NF}' | sed 's/^v//')" = ${quote(version)}`
+}
+
+const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\.0\.0-beta-\d+(?:\.\d+)?$/)) })
+
+export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () {
+ const http = yield* HttpClient.HttpClient
+ const metadata = yield* http.get("https://registry.npmjs.org/@opencode-ai%2fcli/beta").pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)),
+ Effect.timeout("30 seconds"),
+ Effect.mapError(
+ () => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode-ai%2fcli/beta" }),
+ ),
+ )
+ return metadata.version
+})
diff --git a/packages/desktop/src/main/ssh/askpass.test.ts b/packages/desktop/src/main/ssh/askpass.test.ts
new file mode 100644
index 00000000000..dea8c83786c
--- /dev/null
+++ b/packages/desktop/src/main/ssh/askpass.test.ts
@@ -0,0 +1,90 @@
+import { expect } from "bun:test"
+import { NodeSocket } from "@effect/platform-node"
+import { Deferred, Effect, Fiber, Layer, Queue, Scope, Exit } from "effect"
+import { testEffect } from "../../../../core/test/lib/effect"
+import { createAskpass } from "./askpass"
+
+const it = testEffect(Layer.empty)
+
+const request = Effect.fn("test.askpass.request")(function* (
+ env: Record,
+ text: string,
+ confirm = false,
+) {
+ const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: Number(env.OPENCODE_SSH_ASKPASS_PORT) })
+ const write = yield* socket.writer
+ const result = { text: "" }
+ yield* Effect.all(
+ [
+ socket
+ .runString((text) => {
+ result.text += text
+ })
+ .pipe(Effect.ignore),
+ write(JSON.stringify({ token: env.OPENCODE_SSH_ASKPASS_TOKEN, text, confirm }) + "\n").pipe(Effect.ignore),
+ ],
+ { concurrency: "unbounded" },
+ )
+ return result.text
+}, Effect.scoped)
+
+it.live(
+ "per-prompt replies are isolated, including confirmation and OTP",
+ Effect.gen(function* () {
+ const prompts = yield* Queue.unbounded<{ id: string; text: string; confirm: boolean }>()
+ const bridge = yield* createAskpass({
+ binary: "unused",
+ prompt: (prompt) => Queue.offer(prompts, prompt).pipe(Effect.asVoid),
+ clear: () => Effect.void,
+ })
+ const password = yield* request(bridge.env, "Password:").pipe(Effect.forkScoped)
+ const first = yield* Queue.take(prompts)
+ expect(first.text).toBe("Password:")
+ const otp = yield* request(bridge.env, "Verification code:").pipe(Effect.forkScoped)
+ yield* bridge.respond(first.id, "private response")
+ expect(yield* Fiber.join(password)).toBe('{"value":"private response"}')
+ const second = yield* Queue.take(prompts)
+ expect(second.text).toBe("Verification code:")
+ yield* bridge.respond(second.id, "123456")
+ expect(yield* Fiber.join(otp)).toBe('{"value":"123456"}')
+ }),
+)
+
+it.live(
+ "closing the scope closes waiting helpers; invalid bridge credentials cannot prompt",
+ Effect.gen(function* () {
+ const parent = yield* Scope.Scope
+ const scope = yield* Scope.fork(parent)
+ const prompted = yield* Deferred.make()
+ const bridge = yield* createAskpass({
+ binary: "unused",
+ prompt: () => Deferred.succeed(prompted, undefined).pipe(Effect.asVoid),
+ clear: () => Effect.void,
+ }).pipe(Scope.provide(scope))
+ expect(yield* request({ ...bridge.env, OPENCODE_SSH_ASKPASS_TOKEN: "incorrect" }, "Password:")).toBe("")
+ const reply = yield* request(bridge.env, "Trust fingerprint?", true).pipe(Effect.forkScoped)
+ yield* Deferred.await(prompted)
+ yield* Scope.close(scope, Exit.void)
+ expect(yield* Fiber.join(reply)).toBe("")
+ }),
+)
+
+it.live(
+ "disconnecting the active helper advances the queued prompt",
+ Effect.gen(function* () {
+ const prompts = yield* Queue.unbounded<{ id: string; text: string; confirm: boolean }>()
+ const bridge = yield* createAskpass({
+ binary: "unused",
+ prompt: (prompt) => Queue.offer(prompts, prompt).pipe(Effect.asVoid),
+ clear: () => Effect.void,
+ })
+ const first = yield* request(bridge.env, "Password:").pipe(Effect.forkScoped)
+ yield* Queue.take(prompts)
+ const next = yield* request(bridge.env, "Passphrase:").pipe(Effect.forkScoped)
+ yield* Fiber.interrupt(first)
+ const prompt = yield* Queue.take(prompts)
+ expect(prompt.text).toBe("Passphrase:")
+ yield* bridge.respond(prompt.id, "another response")
+ expect(yield* Fiber.join(next)).toBe('{"value":"another response"}')
+ }),
+)
diff --git a/packages/desktop/src/main/ssh/askpass.ts b/packages/desktop/src/main/ssh/askpass.ts
new file mode 100644
index 00000000000..bff73df673a
--- /dev/null
+++ b/packages/desktop/src/main/ssh/askpass.ts
@@ -0,0 +1,81 @@
+import { NodeSocketServer } from "@effect/platform-node"
+import { Deferred, Effect, Fiber, Schema, Semaphore } from "effect"
+import { randomUUID } from "node:crypto"
+import { SshFailure } from "./command"
+
+const Request = Schema.fromJsonString(
+ Schema.Struct({ token: Schema.String, text: Schema.String, confirm: Schema.Boolean }),
+)
+
+export const createAskpass = Effect.fn("Ssh.askpass")(function* (input: {
+ binary: string
+ prompt: (prompt: { id: string; text: string; confirm: boolean }) => Effect.Effect
+ clear: (id: string) => Effect.Effect
+}) {
+ const token = randomUUID()
+ const pending = new Map>()
+ const prompts = yield* Semaphore.make(1)
+ const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }).pipe(Effect.mapError(SshFailure.from))
+ if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection"))
+
+ const serving = yield* server
+ .run((socket) =>
+ Effect.gen(function* () {
+ const request = yield* Deferred.make()
+ const state = { buffer: "", received: false }
+ const reader = yield* socket
+ .runString((chunk) => {
+ if (state.received) return Effect.fail(new SshFailure("connection"))
+ state.buffer += chunk
+ if (state.buffer.length > 16_384) return Effect.fail(new SshFailure("connection"))
+ if (!state.buffer.includes("\n")) return Effect.void
+ state.received = true
+ return Deferred.succeed(request, state.buffer.trim())
+ })
+ .pipe(Effect.ensuring(Deferred.fail(request, new SshFailure("connection"))), Effect.forkScoped)
+ const message = yield* Deferred.await(request).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Request)))
+ if (message.token !== token) return
+
+ // One scoped waiter per helper invocation. Disconnecting a helper or closing
+ // the connection interrupts that waiter and advances the prompt semaphore.
+ yield* prompts
+ .withPermit(
+ Effect.gen(function* () {
+ const id = randomUUID()
+ const response = yield* Deferred.make()
+ yield* Effect.acquireRelease(
+ Effect.sync(() => pending.set(id, response)),
+ () => Effect.sync(() => pending.delete(id)).pipe(Effect.andThen(input.clear(id))),
+ )
+ yield* input.prompt({ id, text: message.text, confirm: message.confirm })
+ const value = yield* Deferred.await(response)
+ const write = yield* socket.writer
+ yield* write(JSON.stringify({ value }))
+ }).pipe(Effect.scoped),
+ )
+ .pipe(Effect.raceFirst(Fiber.join(reader).pipe(Effect.andThen(Effect.fail(new SshFailure("connection"))))))
+ }).pipe(
+ Effect.scoped,
+ Effect.timeout("5 minutes"),
+ // Helper cancellation, invalid credentials, and socket closure are local to
+ // this request. Never log authentication payloads as error causes.
+ Effect.ignore,
+ ),
+ )
+ .pipe(Effect.mapError(SshFailure.from), Effect.forkScoped({ startImmediately: true }))
+
+ return {
+ env: {
+ SSH_ASKPASS: input.binary,
+ SSH_ASKPASS_REQUIRE: "force",
+ DISPLAY: process.env.DISPLAY || "opencode",
+ OPENCODE_SSH_ASKPASS_PORT: String(server.address.port),
+ OPENCODE_SSH_ASKPASS_TOKEN: token,
+ },
+ closed: Fiber.join(serving),
+ respond: Effect.fn("Ssh.askpass.respond")(function* (id: string, value: string) {
+ const response = pending.get(id)
+ if (response) yield* Deferred.succeed(response, value)
+ }),
+ }
+})
diff --git a/packages/desktop/src/main/ssh/bootstrap.test.ts b/packages/desktop/src/main/ssh/bootstrap.test.ts
new file mode 100644
index 00000000000..e9e4ad990d2
--- /dev/null
+++ b/packages/desktop/src/main/ssh/bootstrap.test.ts
@@ -0,0 +1,106 @@
+import { expect, test } from "bun:test"
+import { Effect, FileSystem, Path, Stream } from "effect"
+import { NodeServices } from "@effect/platform-node"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { testEffect } from "../../../../core/test/lib/effect"
+import { binaryPath, discoverScript, startScript, parseRegistration } from "./bootstrap"
+
+const it = testEffect(NodeServices.layer)
+// Bootstrap runs on the POSIX SSH host; these fixtures execute its shell locally.
+const posix = process.platform === "win32" ? it.live.skip : it.live
+
+posix(
+ "starts a staged CLI, rediscovers it, and restarts only for an explicit update",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ssh-beta-test-" })
+ const version = "0.0.0-beta-19059"
+ const bin = path.join(dir, ".opencode/desktop-ssh", version)
+ yield* fs.makeDirectory(bin, { recursive: true })
+ yield* fs.writeFileString(
+ path.join(bin, "opencode2"),
+ `#!/bin/sh
+set -eu
+case "$1 $2" in
+ "service start"|"service restart")
+ printf '%s\\n' "$2" >> "$HOME/actions"
+ mkdir -p "$XDG_STATE_HOME/opencode"
+ printf '%s' '{"url":"http://127.0.0.1:12345","password":"fixture","version":"${version}","pid":1234}' > "$XDG_STATE_HOME/opencode/service.json"
+ ;;
+ "service status")
+ if [ -f "$XDG_STATE_HOME/opencode/service.json" ]; then printf 'http://127.0.0.1:12345\\n'; else printf 'stopped\\n'; fi
+ ;;
+ *) exit 66 ;;
+esac
+`,
+ { mode: 0o755 },
+ )
+ const run = (script: string) =>
+ spawner.string(
+ ChildProcess.make("sh", ["-c", script], {
+ env: { HOME: dir, PATH: "/usr/bin:/bin", XDG_STATE_HOME: path.join(dir, "state") },
+ }),
+ )
+ expect(parseRegistration(yield* run(discoverScript))).toBeUndefined()
+ const started = parseRegistration(yield* run(startScript(version)))
+ expect(started?.version).toBe(version)
+ expect(started?.url).toBe("http://127.0.0.1:12345")
+ expect(parseRegistration(yield* run(discoverScript))).toEqual(started)
+ expect(parseRegistration(yield* run(startScript(version, true)))).toEqual(started)
+ expect(yield* fs.readFileString(path.join(dir, "actions"))).toBe("start\nrestart\n")
+ }),
+)
+
+posix(
+ "finds an existing service through the released CLI",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ssh-discovery-test-" })
+ const expected = {
+ url: "http://0.0.0.0:49374",
+ password: 'private"credential',
+ version: "0.0.0-beta-19059",
+ pid: 1234,
+ }
+ yield* fs.makeDirectory(path.join(dir, ".opencode/bin"), { recursive: true })
+ yield* fs.makeDirectory(path.join(dir, "state/opencode"), { recursive: true })
+ yield* fs.writeFileString(
+ path.join(dir, ".opencode/bin/opencode2"),
+ '#!/bin/sh\n[ "$1 $2" = "service status" ] || exit 66\nprintf "http://0.0.0.0:49374\\n"\n',
+ { mode: 0o755 },
+ )
+ yield* fs.writeFileString(path.join(dir, "state/opencode/service.json"), JSON.stringify(expected, null, 2))
+ yield* fs.writeFileString(
+ path.join(dir, "state/opencode/service-local.json"),
+ JSON.stringify({ ...expected, url: "http://127.0.0.1:7777", password: "other" }),
+ )
+ const child = yield* spawner.spawn(
+ ChildProcess.make("sh", ["-c", discoverScript], {
+ env: { HOME: dir, PATH: "/usr/bin:/bin", XDG_STATE_HOME: path.join(dir, "state") },
+ }),
+ )
+ const output = yield* child.stdout.pipe(Stream.decodeText(), Stream.mkString)
+ expect(yield* child.exitCode).toBe(0)
+ expect(parseRegistration(output)).toEqual(expected)
+ }),
+)
+
+test("ignores stopped services and registrations that do not match the healthy endpoint", () => {
+ const registration = { url: "http://127.0.0.1:1234", password: "secret", version: "2.0.0", pid: 42 }
+ const frame = `OPENCODE_SSH_REGISTRATION_BEGIN\n${JSON.stringify(registration)}\nOPENCODE_SSH_REGISTRATION_END\n`
+ expect(parseRegistration(`OPENCODE_SSH_STATUS=stopped\n${frame}`)).toBeUndefined()
+ expect(parseRegistration(`OPENCODE_SSH_STATUS=http://127.0.0.1:9999\n${frame}`)).toBeUndefined()
+ expect(
+ parseRegistration(
+ `OPENCODE_SSH_STATUS=${registration.url}\nOPENCODE_SSH_REGISTRATION_BEGIN\ninvalid\nOPENCODE_SSH_REGISTRATION_END\n`,
+ ),
+ ).toBeUndefined()
+})
+
+test("rejects unsafe versions in SSH installation paths", () => {
+ expect(() => binaryPath('2.0.0"; whoami')).toThrow()
+})
diff --git a/packages/desktop/src/main/ssh/bootstrap.ts b/packages/desktop/src/main/ssh/bootstrap.ts
new file mode 100644
index 00000000000..6e768e04822
--- /dev/null
+++ b/packages/desktop/src/main/ssh/bootstrap.ts
@@ -0,0 +1,147 @@
+import { Effect, Schema } from "effect"
+import { HttpClient } from "effect/unstable/http"
+import { parseTarget, quote, runSsh, sshArgs, SshFailure } from "./command"
+import { RemoteCli } from "../remote/cli"
+
+// Use commands supported by released V2 CLIs. The registration is the service's
+// complete private discovery contract; no remote Python/Node runtime is needed.
+const registrationScript = `status=$("$cli" service status) || exit 0
+if [ "$status" = stopped ]; then exit 0; fi
+printf 'OPENCODE_SSH_STATUS=%s\\n' "$status"
+for file in "\${XDG_STATE_HOME:-$HOME/.local/state}"/opencode/service*.json; do
+ if [ ! -f "$file" ]; then continue; fi
+ printf 'OPENCODE_SSH_REGISTRATION_BEGIN\\n'
+ cat "$file"
+ printf '\\nOPENCODE_SSH_REGISTRATION_END\\n'
+done
+`
+
+export const discoverScript = `set -eu
+${RemoteCli.discoverScript({ fromPath: true, cache: { directory: ".opencode/desktop-ssh", prefix: "0.0.0-beta-" } })}
+if [ -z "$cli" ]; then exit 0; fi
+${registrationScript}`
+
+export function startScript(version: string, replace = false) {
+ return `set -eu
+cli="${binaryPath(version)}"
+"$cli" service ${replace ? "restart" : "start"}
+${registrationScript}`
+}
+
+const Registration = Schema.fromJsonString(
+ Schema.Struct({
+ url: Schema.String,
+ password: Schema.String,
+ version: Schema.String,
+ pid: Schema.Int.check(Schema.isGreaterThan(0)),
+ }),
+)
+
+export function parseRegistration(output: string) {
+ const status = output
+ .split(/\r?\n/)
+ .findLast((line) => line.startsWith("OPENCODE_SSH_STATUS="))
+ ?.slice("OPENCODE_SSH_STATUS=".length)
+ if (!status) return undefined
+ for (const match of output.matchAll(
+ /OPENCODE_SSH_REGISTRATION_BEGIN\r?\n([\s\S]*?)\r?\nOPENCODE_SSH_REGISTRATION_END/g,
+ )) {
+ const result = Schema.decodeUnknownOption(Registration)(match[1])
+ if (result._tag === "Some" && result.value.url === status) return result.value
+ }
+ return undefined
+}
+
+export function binaryPath(version: string) {
+ return `$HOME/.opencode/desktop-ssh/${RemoteCli.requireVersion(version)}/opencode2`
+}
+
+function connectionAddress(address: string, password: string) {
+ const url = new URL(address)
+ if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "0.0.0.0", "[::]", "[::1]"].includes(url.hostname))
+ throw new SshFailure("service")
+ return {
+ host: url.hostname === "[::1]" ? "[::1]" : "127.0.0.1",
+ port: Number(url.port || 80),
+ password,
+ }
+}
+
+export const bootstrap = Effect.fn("Ssh.bootstrap")(function* (input: {
+ target: ReturnType
+ version: string
+ development?: boolean
+ env: NodeJS.ProcessEnv
+ replace?: boolean
+ stage: (stage: "checking" | "downloading" | "uploading" | "starting") => Effect.Effect
+}) {
+ const run = (script: string) =>
+ runSsh({
+ args: [...sshArgs(input.target), input.target.host, "sh -l -s"],
+ env: input.env,
+ stdin: script,
+ })
+ yield* input.stage("checking")
+ const registered = parseRegistration(yield* run(discoverScript))
+ if (registered && (input.development || registered.version === input.version)) {
+ yield* input.stage("starting")
+ return yield* Effect.try({
+ try: () => connectionAddress(registered.url, registered.password),
+ catch: SshFailure.from,
+ })
+ }
+ if (registered && !input.replace) return yield* Effect.fail(new SshFailure("version", registered.version))
+ const destination = yield* Effect.try({ try: () => binaryPath(input.version), catch: SshFailure.from })
+ const existing = yield* run(RemoteCli.versionScript(`"${destination}"`))
+ const staged = RemoteCli.parseVersion(existing) === input.version
+ // Source worktree versions are unpublished. Use the installer's beta channel
+ // while retaining support for explicitly staged, matching development builds.
+ const version =
+ input.development && !staged ? yield* RemoteCli.latestBeta().pipe(Effect.mapError(SshFailure.from)) : input.version
+ const setup = { version, directory: `.opencode/desktop-ssh/${version}` }
+ if (!staged) {
+ const output = yield* run(RemoteCli.probeScript).pipe(Effect.mapError(() => new SshFailure("platform")))
+ const target = output
+ .split(/\r?\n/)
+ .findLast((line) => line.startsWith("OPENCODE_REMOTE_TARGET="))
+ ?.split("=")[1]
+ const url = yield* Effect.try({ try: () => RemoteCli.archiveUrl(target ?? "", version), catch: SshFailure.from })
+ yield* input.stage("downloading")
+ yield* run(RemoteCli.installScript({ ...setup, source: { type: "download", url } })).pipe(
+ Effect.catch(
+ Effect.fnUntraced(function* (error) {
+ yield* input.stage("uploading")
+ const http = yield* HttpClient.HttpClient
+ const response = yield* http.get(url).pipe(Effect.mapError(SshFailure.from))
+ if (response.status < 200 || response.status >= 300)
+ return yield* Effect.fail(
+ new SshFailure(
+ response.status === 404 ? "unpublished" : "install",
+ JSON.stringify({ version, target, url, status: response.status }),
+ ),
+ )
+ const archive = new Uint8Array(yield* response.arrayBuffer.pipe(Effect.mapError(SshFailure.from)))
+ // The upload uses stdin; the script itself must be the remote command.
+ return yield* runSsh({
+ args: [
+ ...sshArgs(input.target),
+ input.target.host,
+ `sh -c ${quote(RemoteCli.installScript({ ...setup, source: { type: "archive" } }))}`,
+ ],
+ env: input.env,
+ stdin: archive,
+ }).pipe(Effect.mapError(() => new SshFailure("install", error.message)))
+ }),
+ ),
+ )
+ }
+ yield* input.stage("starting")
+ const registration = parseRegistration(yield* run(startScript(version, input.replace)))
+ if (!registration) return yield* Effect.fail(new SshFailure("service"))
+ if (!input.development && registration.version !== input.version)
+ return yield* Effect.fail(new SshFailure("version", registration.version))
+ return yield* Effect.try({
+ try: () => connectionAddress(registration.url, registration.password),
+ catch: SshFailure.from,
+ })
+})
diff --git a/packages/desktop/src/main/ssh/command.test.ts b/packages/desktop/src/main/ssh/command.test.ts
new file mode 100644
index 00000000000..41e748f8f4e
--- /dev/null
+++ b/packages/desktop/src/main/ssh/command.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, test } from "bun:test"
+import { Effect, FileSystem, Path, PlatformError } from "effect"
+import { NodeServices } from "@effect/platform-node"
+import { testEffect } from "../../../../core/test/lib/effect"
+import { parseTarget, quote, sshHosts, sshArgs, tunnelArgs, commandFailureDetail, SshFailure } from "./command"
+
+const it = testEffect(NodeServices.layer)
+
+describe("SSH connection commands", () => {
+ test("classifies a missing SSH executable from the platform error", () => {
+ const error = PlatformError.systemError({
+ _tag: "NotFound",
+ module: "ChildProcessSpawner",
+ method: "spawn",
+ pathOrDescriptor: "ssh",
+ })
+ expect(SshFailure.from(error).code).toBe("ssh-missing")
+ })
+ test("forwarding overrides bootstrap persistence before reusing the control socket", () => {
+ const args = tunnelArgs(
+ {
+ host: "devbox",
+ args: ["-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-o", "ControlPath=/test/socket"],
+ },
+ 1234,
+ { host: "127.0.0.1", port: 5678 },
+ )
+ expect(args.slice(0, 4)).toEqual(["-o", "ControlMaster=no", "-o", "ControlPersist=no"])
+ expect(args).toContain("ControlPath=/test/socket")
+ expect(args.slice(-4)).toEqual(["-L", "127.0.0.1:1234:127.0.0.1:5678", "devbox", "sh -c 'exec cat >/dev/null'"])
+ })
+ test("retains CLI stdout failures without exposing private connection details", () => {
+ expect(
+ commandFailureDetail(1, {
+ stdout:
+ 'OPENCODE_SSH_REGISTRATION_BEGIN\n{"password":"secret"}\nOPENCODE_SSH_REGISTRATION_END\nFailed to read next file',
+ stderr: "",
+ }),
+ ).toBe("Failed to read next file")
+ expect(
+ commandFailureDetail(1, {
+ stdout: 'OPENCODE_SSH_REGISTRATION_BEGIN\n{"password":"secret"}',
+ stderr: "read interrupted",
+ }),
+ ).toBe("read interrupted")
+ expect(commandFailureDetail(1, { stdout: "Server process terminated by SIGKILL\n", stderr: "" })).toBe(
+ "Server process terminated by SIGKILL",
+ )
+ expect(commandFailureDetail(255, { stdout: "", stderr: "" })).toBe('{"exitCode":255}')
+ })
+ test("preserves aliases and connection options without invoking a shell", () => {
+ expect(parseTarget('ssh -p 2222 -i "~/.ssh/work key" -J gateway user@devbox')).toEqual({
+ host: "user@devbox",
+ args: ["-p", "2222", "-i", "~/.ssh/work key", "-J", "gateway"],
+ })
+ expect(parseTarget("devbox")).toEqual({ host: "devbox", args: [] })
+ expect(parseTarget("ssh user@[::1]").host).toBe("user@[::1]")
+ expect(sshArgs(parseTarget("devbox"))).toContain("PermitLocalCommand=no")
+ })
+ test("rejects remote commands, shell syntax, and transport overrides", () => {
+ for (const input of [
+ "",
+ "ssh host whoami",
+ "host;whoami",
+ "ssh user:password@host",
+ "ssh -t host",
+ "ssh -o RemoteCommand=whoami host",
+ "ssh -L 1234:x:80 host",
+ "ssh -p 70000 host",
+ 'ssh -i "key host',
+ "host\nwhoami",
+ ]) {
+ expect(() => parseTarget(input)).toThrow()
+ }
+ expect(quote("a'b")).toBe("'a'\\''b'")
+ })
+ it.live(
+ "discovers Include aliases without wildcards or recursion",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ssh-config-test-" })
+ yield* fs.makeDirectory(path.join(dir, "hosts"))
+ yield* fs.writeFileString(path.join(dir, "config"), "Host work other\nHost * !excluded\nInclude hosts/*\n")
+ yield* fs.writeFileString(path.join(dir, "hosts", "extra"), "Host deploy\nInclude ../config\n")
+ expect(yield* sshHosts(path.join(dir, "config"))).toEqual(["deploy", "other", "work"])
+ }),
+ )
+})
diff --git a/packages/desktop/src/main/ssh/command.ts b/packages/desktop/src/main/ssh/command.ts
new file mode 100644
index 00000000000..85701dd5a6e
--- /dev/null
+++ b/packages/desktop/src/main/ssh/command.ts
@@ -0,0 +1,256 @@
+import { Effect, FileSystem, Path, PlatformError, Schema, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { homedir } from "node:os"
+import { RemoteCli } from "../remote/cli"
+
+export class SshFailure extends Schema.TaggedError()("SshFailure", {
+ code: Schema.Literals([
+ "input",
+ "connection",
+ "platform",
+ "version",
+ "install",
+ "service",
+ "unpublished",
+ "ssh-missing",
+ ]),
+ detail: Schema.String,
+}) {
+ constructor(code: SshFailure["code"], detail = "") {
+ super({ code, detail })
+ }
+
+ override get message() {
+ return this.detail
+ }
+
+ static from(this: void, error: unknown) {
+ if (error instanceof RemoteCli.Failure) return new SshFailure(error.code, error.detail)
+ if (
+ error instanceof PlatformError.PlatformError &&
+ error.reason._tag === "NotFound" &&
+ error.reason.method === "spawn"
+ )
+ return new SshFailure("ssh-missing", error.message)
+ return error instanceof SshFailure
+ ? error
+ : new SshFailure("connection", error instanceof Error ? error.message : String(error))
+ }
+}
+
+export function quote(value: string) {
+ return `'${value.replaceAll("'", "'\\''")}'`
+}
+
+export function parseTarget(input: string) {
+ const tokens: string[] = []
+ const state = { word: "", quote: "", started: false }
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i] ?? ""
+ if (c === "\n" || c === "\r" || c === "\0") throw new SshFailure("input")
+ if (c === "\\" && state.quote !== "'" && i + 1 < input.length && /[\s\\"']/.test(input[i + 1] ?? "")) {
+ state.word += input[++i]
+ state.started = true
+ continue
+ }
+ if (state.quote) {
+ if (c === state.quote) state.quote = ""
+ else state.word += c
+ continue
+ }
+ if (c === "'" || c === '"') {
+ state.quote = c
+ state.started = true
+ continue
+ }
+ if (/\s/.test(c)) {
+ if (state.started) tokens.push(state.word)
+ state.word = ""
+ state.started = false
+ continue
+ }
+ state.word += c
+ state.started = true
+ }
+ if (state.quote) throw new SshFailure("input")
+ if (state.started) tokens.push(state.word)
+ if (tokens[0] === "ssh") tokens.shift()
+ const args: string[] = []
+ const options = new Set([
+ "hostname",
+ "user",
+ "port",
+ "identityfile",
+ "identityagent",
+ "identitiesonly",
+ "proxyjump",
+ "proxycommand",
+ "connecttimeout",
+ "addressfamily",
+ ])
+ while (tokens[0]?.startsWith("-")) {
+ const token = tokens.shift() ?? ""
+ if (["-4", "-6", "-C", "-A", "-a"].includes(token)) {
+ args.push(token)
+ continue
+ }
+ const flag = token.slice(0, 2)
+ if (!["-p", "-l", "-i", "-F", "-J", "-o"].includes(flag)) throw new SshFailure("input")
+ const value = token.length > 2 ? token.slice(2) : tokens.shift()
+ if (!value || value.startsWith("-")) throw new SshFailure("input")
+ if (flag === "-p" && (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535))
+ throw new SshFailure("input")
+ if (flag === "-o" && !options.has((value.split(/[=\s]/)[0] ?? "").toLowerCase())) throw new SshFailure("input")
+ args.push(flag, value)
+ }
+ const host = tokens[0]
+ if (tokens.length !== 1 || !host || !/^[a-zA-Z0-9_@.:[\]%-]+$/.test(host) || host.startsWith("-"))
+ throw new SshFailure("input")
+ if (host.includes("@") && host.slice(0, host.lastIndexOf("@")).includes(":")) throw new SshFailure("input")
+ return { host, args }
+}
+
+export const sshExecutable = () => (process.platform === "win32" ? "ssh.exe" : "ssh")
+
+export function sshArgs(target: ReturnType) {
+ return [
+ "-T",
+ "-o",
+ "ConnectTimeout=10",
+ "-o",
+ "ServerAliveInterval=15",
+ "-o",
+ "ServerAliveCountMax=3",
+ "-o",
+ "RemoteCommand=none",
+ "-o",
+ "RequestTTY=no",
+ "-o",
+ "PermitLocalCommand=no",
+ ...target.args,
+ ]
+}
+
+export function tunnelArgs(
+ target: ReturnType,
+ localPort: number,
+ remote: { host: string; port: number },
+) {
+ // A multiplexed `ssh -N` may exit after handing forwarding to its master.
+ // Keep a session open on stdin instead; the scoped process owns that pipe.
+ return [
+ "-o",
+ "ControlMaster=no",
+ "-o",
+ "ControlPersist=no",
+ ...sshArgs(target),
+ "-o",
+ "ExitOnForwardFailure=yes",
+ "-L",
+ `127.0.0.1:${localPort}:${remote.host}:${remote.port}`,
+ target.host,
+ "sh -c 'exec cat >/dev/null'",
+ ]
+}
+
+export const runSsh = Effect.fn("Ssh.run")(function* (input: {
+ args: string[]
+ env?: NodeJS.ProcessEnv
+ stdin?: string | Uint8Array
+ timeout?: number
+}) {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ return yield* Effect.gen(function* () {
+ const child = yield* spawner.spawn(
+ ChildProcess.make(sshExecutable(), input.args, {
+ env: input.env,
+ extendEnv: true,
+ windowsHide: true,
+ killSignal: "SIGTERM",
+ forceKillAfter: "2 seconds",
+ stdin:
+ input.stdin === undefined
+ ? "ignore"
+ : {
+ stream: Stream.make(
+ typeof input.stdin === "string" ? new TextEncoder().encode(input.stdin) : input.stdin,
+ ),
+ endOnDone: true,
+ },
+ }),
+ )
+ const output = yield* Effect.all(
+ {
+ stdout: child.stdout.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (tail, text) => (tail + text).slice(-1_048_576),
+ ),
+ ),
+ stderr: child.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runFold(
+ () => "",
+ (tail, text) => (tail + text).slice(-16_384),
+ ),
+ ),
+ code: child.exitCode,
+ },
+ { concurrency: "unbounded" },
+ )
+ if (output.code !== 0)
+ return yield* Effect.fail(new SshFailure("connection", commandFailureDetail(output.code, output)))
+ return output.stdout
+ }).pipe(Effect.scoped, Effect.timeout(input.timeout ?? 600_000), Effect.mapError(SshFailure.from))
+})
+
+export function commandFailureDetail(code: number | null, output: { stdout: string; stderr: string }) {
+ // The CLI may report failures on stdout. Never include its private bootstrap
+ // response in diagnostic text, even if shutdown fails after printing it.
+ const stdout = output.stdout
+ .replace(/OPENCODE_SSH_REGISTRATION_BEGIN[\s\S]*?(?:OPENCODE_SSH_REGISTRATION_END|$)/g, "")
+ .trim()
+ return [output.stderr.trim(), stdout].filter(Boolean).join("\n") || JSON.stringify({ exitCode: code })
+}
+
+export const sshHosts = Effect.fn("Ssh.hosts")(function* (
+ filename?: string,
+ seen = new Set(),
+): Effect.fn.Return {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const file = filename ?? path.join(homedir(), ".ssh", "config")
+ if (seen.has(file) || seen.size >= 100) return []
+ seen.add(file)
+ const content = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
+ const lines = content.split(/\r?\n/).map((line) => line.trim().replace(/\s+#.*$/, ""))
+ const hosts = lines.flatMap((line) =>
+ /^host\s/i.test(line)
+ ? line
+ .split(/\s+/)
+ .slice(1)
+ .filter((host) => !/[!*?]/.test(host))
+ : [],
+ )
+ const includes = lines.flatMap((line) => (/^include\s/i.test(line) ? line.split(/\s+/).slice(1) : []))
+ for (const include of includes) {
+ const pattern = include.startsWith("~/")
+ ? path.join(homedir(), include.slice(2))
+ : path.resolve(path.dirname(file), include)
+ const dir = path.dirname(pattern)
+ const match = new RegExp(
+ "^" +
+ path
+ .basename(pattern)
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ .replaceAll("*", ".*")
+ .replaceAll("?", ".") +
+ "$",
+ )
+ const files = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []))
+ for (const name of files.filter((name) => match.test(name)))
+ hosts.push(...(yield* sshHosts(path.join(dir, name), seen)))
+ }
+ return [...new Set(hosts)].sort()
+})
diff --git a/packages/desktop/src/main/ssh/controller.test.ts b/packages/desktop/src/main/ssh/controller.test.ts
new file mode 100644
index 00000000000..d26f776db34
--- /dev/null
+++ b/packages/desktop/src/main/ssh/controller.test.ts
@@ -0,0 +1,260 @@
+import { expect } from "bun:test"
+import { NodeServices, NodeSocketServer } from "@effect/platform-node"
+import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect"
+import { FetchHttpClient } from "effect/unstable/http"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { testEffect } from "../../../../core/test/lib/effect"
+import { createSshController } from "./controller"
+import { quote } from "./command"
+
+const it = testEffect(Layer.merge(NodeServices.layer, FetchHttpClient.layer))
+// The askpass ProxyCommand fixture is a POSIX shell executable.
+const posix = process.platform === "win32" ? it.live.skip : it.live
+
+it.live(
+ "resolution validates a live tunnel and rediscovers changed remote credentials and ports",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const file = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-resolve-test-" }), "registration.json")
+ const state = { password: "first", tunnels: 0 }
+ const remote = () =>
+ Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ fetch: (request) =>
+ new Response(null, {
+ status:
+ request.headers.get("authorization") ===
+ `Basic ${Buffer.from(`opencode:${state.password}`).toString("base64")}`
+ ? 200
+ : 401,
+ }),
+ })
+ const first = remote()
+ yield* Effect.addFinalizer(() => Effect.sync(() => first.stop(true)))
+ yield* fs.writeFileString(
+ file,
+ JSON.stringify({ url: first.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 1 }),
+ )
+ const config = { id: "fixture", target: "fixture", name: "Fixture" }
+ const controller = yield* createSshController({
+ configs: [config],
+ binary: process.execPath,
+ version: "2.0.0",
+ save: () => Effect.void,
+ }).pipe(
+ Effect.provideService(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) => {
+ if (command._tag !== "StandardCommand") return spawner.spawn(command)
+ if (command.args.includes("-L")) state.tunnels++
+ return spawner.spawn(
+ ChildProcess.make(
+ process.execPath,
+ [path.join(import.meta.dirname, "../../../test/ssh/transport.ts"), file, ...command.args],
+ command.options,
+ ),
+ )
+ }),
+ ),
+ )
+ yield* controller.start(config, 1)
+ const initial = yield* controller.resolve(config.id)
+ expect((yield* controller.state()).servers[0]).toMatchObject({ stage: "ready" })
+ expect(initial?.password).toBe("first")
+ expect(yield* controller.resolve(config.id)).toEqual(initial)
+ expect(state.tunnels).toBe(1)
+
+ state.password = "second"
+ yield* fs.writeFileString(
+ file,
+ JSON.stringify({ url: first.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 2 }),
+ )
+ const refreshed = yield* Effect.all([controller.resolve(config.id), controller.resolve(config.id)], {
+ concurrency: "unbounded",
+ })
+ expect(refreshed[0]?.password).toBe("second")
+ expect(refreshed[0]).toEqual(refreshed[1])
+ expect(refreshed[0]?.url).not.toBe(initial?.url)
+ expect(state.tunnels).toBe(2)
+
+ const second = remote()
+ yield* Effect.addFinalizer(() => Effect.sync(() => second.stop(true)))
+ yield* fs.writeFileString(
+ file,
+ JSON.stringify({ url: second.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 3 }),
+ )
+ yield* Effect.promise(() => first.stop(true))
+ const moved = yield* controller.resolve(config.id)
+ expect(moved?.url).not.toBe(refreshed[0]?.url)
+ expect(state.tunnels).toBe(3)
+ expect((yield* controller.state()).servers[0]?.stage).toBe("ready")
+ expect(yield* controller.resolve(config.id)).toEqual(moved)
+ expect(state.tunnels).toBe(3)
+ }).pipe(Effect.timeout("20 seconds")),
+)
+
+posix(
+ "cancelling an authentication attempt restores the previous state and permits another attempt",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const configFile = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-cancel-test-" }), "config")
+ yield* fs.writeFileString(configFile, "")
+ const proxy = path.join(path.dirname(configFile), "proxy")
+ yield* fs.writeFileString(proxy, '#!/bin/sh\n"$SSH_ASKPASS" "Password:" >/dev/null\nexec sleep 30\n', {
+ mode: 0o755,
+ })
+ const config = {
+ id: "fixture",
+ name: "Fixture",
+ target: `ssh -F ${quote(configFile)} -o ${quote(`ProxyCommand=${quote(proxy)}`)} fixture`,
+ }
+ const controller = yield* createSshController({
+ configs: [config],
+ binary: process.execPath,
+ command: [process.execPath, "run", path.resolve("../cli/src/index.ts")],
+ version: "2.0.0",
+ save: () => Effect.die("must not save"),
+ })
+ yield* controller.start({ ...config, background: true })
+ expect(yield* controller.resolve(config.id)).toBeNull()
+ expect((yield* controller.state()).servers[0]?.stage).toBe("authentication")
+ for (const owner of [1, 2]) {
+ const prompted = yield* controller.changes(owner).pipe(
+ Stream.filter((state) => !!state.servers[0]?.prompt),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* controller.start(config, owner)
+ yield* Fiber.join(prompted)
+ const prompt = (yield* controller.state(owner)).servers[0]?.prompt
+ expect((yield* controller.state(owner + 10)).servers[0]?.authenticatingElsewhere).toBe(true)
+ yield* controller.start(config, owner + 10)
+ expect((yield* controller.state(owner)).servers[0]?.prompt).toEqual(prompt)
+ expect((yield* controller.state(owner + 10)).servers[0]?.prompt).toBeUndefined()
+ yield* controller.cancel(config.id, owner + 10)
+ expect((yield* controller.state(owner)).servers[0]?.prompt).toBeDefined()
+ yield* controller.cancel(config.id, owner)
+ const item = (yield* controller.state(owner)).servers[0]
+ expect(item?.stage).toBe("authentication")
+ expect(item?.prompt).toBeUndefined()
+ expect(item?.error).toBeUndefined()
+ expect(item?.config).toEqual(config)
+ expect((yield* controller.state(owner + 10)).servers[0]?.authenticatingElsewhere).toBe(false)
+ expect(yield* controller.resolve(config.id)).toBeNull()
+ }
+ }).pipe(Effect.timeout("20 seconds")),
+)
+
+it.live(
+ "saved hosts do not connect until requested and forgetting never starts SSH",
+ Effect.gen(function* () {
+ const saves: unknown[] = []
+ const config = { id: "fixture", target: "unreachable.invalid", name: "Fixture" }
+ const controller = yield* createSshController({
+ configs: [config],
+ binary: "unused",
+ version: "2.0.0",
+ save: (configs) =>
+ Effect.sync(() => {
+ saves.push(configs)
+ }),
+ })
+ expect(yield* controller.resolve(config.id)).toBeNull()
+ yield* controller.disconnect(config.id)
+ expect((yield* controller.state()).servers[0]?.stage).toBe("disconnected")
+ yield* controller.forget(config.id)
+ expect((yield* controller.state()).servers).toEqual([])
+ expect(saves).toEqual([[]])
+ }),
+)
+
+it.live(
+ "invalid commands fail without persisting an incomplete connection",
+ Effect.gen(function* () {
+ const controller = yield* createSshController({
+ configs: [],
+ binary: "unused",
+ version: "2.0.0",
+ save: () => Effect.die("must not save"),
+ })
+ const settled = yield* controller.changes().pipe(
+ Stream.filter((state) => state.servers[0]?.stage === "failed"),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* controller.start({ id: "fixture", target: "ssh host whoami", name: "" }, 1)
+ yield* Fiber.join(settled)
+ const state = yield* controller.state()
+ expect(state.servers[0]?.error).toBe("input")
+ expect(state.servers[0]?.saved).toBe(false)
+ }),
+)
+
+it.live(
+ "a failed edit does not replace the saved connection",
+ Effect.gen(function* () {
+ const config = { id: "fixture", target: "devbox", name: "Original" }
+ const saves: unknown[] = []
+ const controller = yield* createSshController({
+ configs: [config],
+ binary: "unused",
+ version: "2.0.0",
+ save: (configs) =>
+ Effect.sync(() => {
+ saves.push(configs)
+ }),
+ })
+ const settled = yield* controller.changes().pipe(
+ Stream.filter((state) => state.servers[0]?.stage === "failed"),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* controller.start({ ...config, target: "ssh devbox whoami", name: "Invalid edit" }, 1)
+ yield* Fiber.join(settled)
+ expect((yield* controller.state()).servers[0]?.config).toEqual(config)
+ yield* controller.forget("missing")
+ expect(saves).toEqual([[config]])
+ }),
+)
+
+it.live(
+ "disconnect interrupts a live SSH handshake and releases endpoint waiters",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const config = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-handshake-test-" }), "config")
+ yield* fs.writeFileString(config, "")
+ const connected = yield* Deferred.make()
+ const closed = yield* Deferred.make()
+ const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 })
+ if (server.address._tag !== "TcpAddress") return yield* Effect.die("missing port")
+ yield* server
+ .run((socket) =>
+ socket
+ .run(() => Effect.void, { onOpen: Deferred.succeed(connected, undefined).pipe(Effect.asVoid) })
+ .pipe(Effect.ensuring(Deferred.succeed(closed, undefined)), Effect.ignore),
+ )
+ .pipe(Effect.forkScoped({ startImmediately: true }))
+ const controller = yield* createSshController({
+ configs: [],
+ binary: "unused",
+ version: "2.0.0",
+ save: () => Effect.die("must not save"),
+ })
+ yield* controller.start(
+ { id: "fixture", target: `ssh -F ${quote(config)} -p ${server.address.port} 127.0.0.1`, name: "" },
+ 1,
+ )
+ yield* Deferred.await(connected)
+ const waiting = yield* controller.resolve("fixture").pipe(Effect.forkScoped)
+ yield* controller.disconnect("fixture")
+ yield* Deferred.await(closed)
+ expect(yield* Fiber.join(waiting)).toBeNull()
+ expect((yield* controller.state()).servers[0]?.stage).toBe("disconnected")
+ return undefined
+ }).pipe(Effect.timeout("10 seconds")),
+)
diff --git a/packages/desktop/src/main/ssh/controller.ts b/packages/desktop/src/main/ssh/controller.ts
new file mode 100644
index 00000000000..1aa171c81ee
--- /dev/null
+++ b/packages/desktop/src/main/ssh/controller.ts
@@ -0,0 +1,365 @@
+import { NodeSocketServer } from "@effect/platform-node"
+import {
+ Cause,
+ Clock,
+ Deferred,
+ Effect,
+ Exit,
+ Fiber,
+ FileSystem,
+ Path,
+ PubSub,
+ Ref,
+ Schedule,
+ Scope,
+ Stream,
+} from "effect"
+import { HttpClient } from "effect/unstable/http"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "@opencode/app/ssh"
+import { createAskpass } from "./askpass"
+import { bootstrap } from "./bootstrap"
+import { parseTarget, quote, runSsh, sshArgs, sshExecutable, tunnelArgs, SshFailure } from "./command"
+
+type Connection = {
+ owner?: number
+ before?: SshItem
+ ready: Deferred.Deferred
+ respond?: (id: string, value: string) => Effect.Effect
+}
+type Attempt = Connection & { fiber: Fiber.Fiber }
+
+export const createSshController = Effect.fn("Ssh.controller")(function* (input: {
+ version: string
+ development?: boolean
+ binary: string
+ command?: readonly string[]
+ configs: readonly SshConfig[]
+ save: (configs: readonly SshConfig[]) => Effect.Effect
+}) {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const httpClient = yield* HttpClient.HttpClient
+ const parent = yield* Scope.Scope
+ const lifetime = yield* Scope.fork(parent)
+ const changed = yield* PubSub.unbounded()
+ yield* Scope.addFinalizer(lifetime, PubSub.shutdown(changed))
+
+ const items = new Map(
+ input.configs.map((config) => [config.id, { config, saved: true, stage: "disconnected", detail: "" }]),
+ )
+ const configs = new Map(input.configs.map((config) => [config.id, config]))
+ const attempts = new Map()
+ const paused = new Set(items.keys())
+ const failures = new Map()
+ const lifecycle = { closed: false }
+ const emit = PubSub.publish(changed, undefined).pipe(Effect.asVoid)
+ const state = (owner?: number): Effect.Effect =>
+ Effect.sync(() => ({
+ servers: [...items.values()].map((item) => ({
+ ...item,
+ prompt: attempts.get(item.config.id)?.owner === owner ? item.prompt : undefined,
+ authenticatingElsewhere:
+ item.stage === "authentication" &&
+ attempts.get(item.config.id)?.owner !== undefined &&
+ attempts.get(item.config.id)?.owner !== owner,
+ })),
+ }))
+ const update = Effect.fnUntraced(function* (id: string, value: Partial) {
+ const item = items.get(id)
+ if (!item) return
+ items.set(id, { ...item, ...value })
+ yield* emit
+ })
+ const run = (options: Parameters[0]) =>
+ runSsh(options).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
+
+ const connect = Effect.fn("Ssh.connect")(function* (config: SshConfig, connection: Connection, replace = false) {
+ const target = yield* Effect.try({ try: () => parseTarget(config.target), catch: SshFailure.from })
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "oc-ssh-" })
+ const control = path.join(directory, "s")
+ const helper =
+ input.command && input.command.length > 1 && process.platform !== "win32"
+ ? path.join(directory, "askpass")
+ : input.binary
+ if (helper !== input.binary)
+ yield* fs.writeFileString(helper, `#!/bin/sh\nexec ${input.command?.map(quote).join(" ")} "$@"\n`, {
+ mode: 0o700,
+ })
+ if (process.platform !== "win32") {
+ target.args.unshift("-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-o", `ControlPath=${control}`)
+ // Close only our local SSH master. The remote OpenCode service owns its
+ // own lifetime and must survive disconnect, failure, and app shutdown.
+ yield* Effect.addFinalizer(() =>
+ run({ args: ["-o", `ControlPath=${control}`, "-O", "exit", target.host], timeout: 2000 }).pipe(Effect.ignore),
+ )
+ }
+
+ const authentication = yield* Deferred.make()
+ const askpass = yield* createAskpass({
+ binary: helper,
+ prompt: Effect.fnUntraced(function* (prompt) {
+ if (connection.owner === undefined) {
+ paused.add(config.id)
+ yield* update(config.id, { stage: "authentication" })
+ yield* Deferred.succeed(authentication, undefined)
+ return
+ }
+ yield* update(config.id, { stage: "authentication", prompt })
+ }),
+ clear: (id) =>
+ items.get(config.id)?.prompt?.id === id
+ ? update(config.id, { prompt: undefined, stage: "connecting" })
+ : Effect.void,
+ })
+ connection.respond = askpass.respond
+
+ yield* Effect.gen(function* () {
+ const resolved = yield* run({ args: [...sshArgs(target), "-G", target.host], timeout: 10_000 }).pipe(
+ Effect.orElseSucceed(() => ""),
+ )
+ const fields = new Map(
+ resolved.split(/\r?\n/).map((line) => {
+ const separator = line.indexOf(" ")
+ return [line.slice(0, separator), line.slice(separator + 1)] as const
+ }),
+ )
+ if (fields.has("hostname"))
+ yield* update(config.id, {
+ destination: `${fields.get("user") ?? ""}@${fields.get("hostname")}:${fields.get("port") ?? "22"}`,
+ })
+ const remote = yield* bootstrap({
+ target,
+ version: input.version,
+ development: input.development,
+ env: askpass.env,
+ replace,
+ stage: (stage) => update(config.id, { stage, prompt: undefined }),
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(HttpClient.HttpClient, httpClient),
+ )
+ const port = yield* freePort
+ const http = { url: `http://127.0.0.1:${port}`, password: remote.password }
+ const tunnel = yield* spawner.spawn(
+ ChildProcess.make(sshExecutable(), tunnelArgs(target, port, remote), {
+ env: askpass.env,
+ extendEnv: true,
+ windowsHide: true,
+ stdin: "pipe",
+ stdout: "ignore",
+ killSignal: "SIGTERM",
+ forceKillAfter: "2 seconds",
+ }),
+ )
+ const detail = yield* Ref.make("")
+ const stderr = yield* tunnel.stderr.pipe(
+ Stream.decodeText(),
+ Stream.runForEach((text) => Ref.update(detail, (tail) => (tail + text).slice(-8192))),
+ Effect.forkScoped,
+ )
+ const closed = Effect.gen(function* () {
+ const exitCode = yield* tunnel.exitCode
+ yield* Fiber.join(stderr)
+ return yield* Effect.fail(
+ new SshFailure("connection", (yield* Ref.get(detail)) || JSON.stringify({ exitCode })),
+ )
+ })
+ yield* waitReady(http, () => items.get(config.id)?.stage === "authentication").pipe(
+ Effect.provideService(HttpClient.HttpClient, httpClient),
+ Effect.catch(() =>
+ Ref.get(detail).pipe(Effect.flatMap((detail) => Effect.fail(new SshFailure("service", detail)))),
+ ),
+ Effect.raceFirst(closed),
+ )
+ const saved = new Map(configs).set(config.id, config)
+ yield* input.save([...saved.values()])
+ configs.set(config.id, config)
+ failures.delete(config.id)
+ yield* update(config.id, { http, stage: "ready", saved: true, detail: "", prompt: undefined, error: undefined })
+ yield* Deferred.succeed(connection.ready, http)
+ yield* closed
+ }).pipe(
+ Effect.raceFirst(askpass.closed),
+ Effect.raceFirst(Deferred.await(authentication).pipe(Effect.andThen(Effect.interrupt))),
+ )
+ }, Effect.scoped)
+
+ const start = Effect.fn("Ssh.start")(function* (request: SshStart, owner?: number) {
+ const id = request.id
+ if (lifecycle.closed || !/^[a-zA-Z0-9-]{1,80}$/.test(id)) return
+ const previous = attempts.get(id)
+ // A second window must not replace an interactive attempt while its owner
+ // is connecting or answering a challenge.
+ if (previous?.owner !== undefined && previous.owner !== owner && items.get(id)?.stage !== "ready") return
+ const config = { id, target: request.target.trim(), name: request.name.trim() }
+ const connection: Connection = {
+ owner,
+ before: items.get(id),
+ ready: yield* Deferred.make(),
+ }
+ const admitted = yield* Deferred.make()
+ const fiber = yield* Effect.gen(function* () {
+ yield* Deferred.await(admitted)
+ if (previous) yield* Fiber.interrupt(previous.fiber)
+ yield* connect(config, connection, request.replace)
+ }).pipe(
+ Effect.catchCause(
+ Effect.fnUntraced(function* (cause) {
+ if (Cause.hasInterruptsOnly(cause) || paused.has(id) || attempts.get(id)?.ready !== connection.ready) return
+ const failure = SshFailure.from(Cause.squash(cause))
+ failures.set(id, (failures.get(id) ?? 0) + 1)
+ const code = /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(failure.message)
+ ? "host-key"
+ : /spawn .*ENOENT/.test(failure.message)
+ ? "ssh-missing"
+ : failure.code
+ if (
+ ["version", "input", "unpublished", "platform", "host-key", "ssh-missing"].includes(code) ||
+ /Permission denied/.test(failure.message) ||
+ (failures.get(id) ?? 0) >= 5
+ )
+ paused.add(id)
+ yield* update(id, {
+ stage: code === "version" ? "incompatible" : "failed",
+ error: code,
+ detail: failure.message,
+ prompt: undefined,
+ ...(configs.has(id) ? { config: configs.get(id) } : {}),
+ })
+ }),
+ ),
+ Effect.ensuring(
+ Effect.gen(function* () {
+ yield* Deferred.succeed(connection.ready, null)
+ if (attempts.get(id)?.ready !== connection.ready) return
+ attempts.delete(id)
+ yield* update(id, { prompt: undefined })
+ }),
+ ),
+ Effect.forkIn(lifetime, { uninterruptible: false }),
+ )
+ attempts.set(id, Object.assign(connection, { fiber }))
+ items.set(id, {
+ config,
+ saved: items.get(id)?.saved ?? false,
+ http: items.get(id)?.http,
+ stage: "connecting",
+ detail: "",
+ })
+ paused.delete(id)
+ if (!request.background) failures.delete(id)
+ yield* emit
+ yield* Deferred.succeed(admitted, undefined)
+ }, Effect.uninterruptible)
+
+ const disconnect = Effect.fn("Ssh.disconnect")(function* (id: string) {
+ paused.add(id)
+ const attempt = attempts.get(id)
+ yield* update(id, {
+ stage: "disconnected",
+ prompt: undefined,
+ ...(configs.has(id) ? { config: configs.get(id) } : {}),
+ })
+ if (attempt) yield* Fiber.interrupt(attempt.fiber)
+ })
+ const cancel = Effect.fn("Ssh.cancel")(function* (id: string, owner: number) {
+ const attempt = attempts.get(id)
+ if (!attempt || attempt.owner !== owner) return
+ paused.add(id)
+ // Restore before interrupting: askpass cleanup must not transition the
+ // cancelled attempt back to connecting or expose an expired prompt.
+ const before = attempt.before
+ yield* update(id, {
+ ...before,
+ stage:
+ before?.stage === "authentication" || before?.stage === "failed" || before?.stage === "incompatible"
+ ? before.stage
+ : "disconnected",
+ prompt: undefined,
+ error: before?.error,
+ detail: before?.detail ?? "",
+ })
+ yield* Fiber.interrupt(attempt.fiber)
+ })
+ const close = Effect.gen(function* () {
+ if (lifecycle.closed) return
+ lifecycle.closed = true
+ yield* Effect.forEach([...attempts.keys()], disconnect, { concurrency: "unbounded", discard: true })
+ yield* Scope.close(lifetime, Exit.void)
+ })
+ yield* Effect.addFinalizer(() => close)
+
+ return {
+ state,
+ changes: (owner?: number) => Stream.fromPubSub(changed).pipe(Stream.mapEffect(() => state(owner))),
+ start,
+ resolve: Effect.fn("Ssh.resolve")(function* (id: string) {
+ const item = items.get(id)
+ if (lifecycle.closed || !item || paused.has(id)) return null
+ if (item.stage === "ready" && item.http) {
+ const healthy = yield* checkHealth(item.http).pipe(Effect.provideService(HttpClient.HttpClient, httpClient))
+ if (lifecycle.closed || paused.has(id)) return null
+ // Another window may already have replaced this tunnel during the probe.
+ if (items.get(id) === item) {
+ if (healthy) return item.http
+ yield* start({ ...item.config, background: true })
+ }
+ }
+ if (!attempts.has(id) && items.has(id)) yield* start({ ...item.config, background: true })
+ const attempt = attempts.get(id)
+ return attempt ? yield* Deferred.await(attempt.ready) : null
+ }),
+ respond: Effect.fn("Ssh.respond")(function* (id: string, prompt: string, value: string, owner: number) {
+ const attempt = attempts.get(id)
+ if (attempt?.owner === owner && attempt.respond) yield* attempt.respond(prompt, value)
+ }),
+ disconnect,
+ cancel,
+ forget: Effect.fn("Ssh.forget")(function* (id: string) {
+ yield* disconnect(id)
+ items.delete(id)
+ configs.delete(id)
+ yield* input.save([...configs.values()])
+ yield* emit
+ }),
+ detach: Effect.fn("Ssh.detach")(function* (owner: number) {
+ yield* Effect.forEach(
+ [...attempts].filter(([id, attempt]) => attempt.owner === owner && items.get(id)?.stage !== "ready"),
+ ([id]) => cancel(id, owner),
+ { concurrency: "unbounded", discard: true },
+ )
+ }),
+ close,
+ }
+})
+
+const freePort = Effect.gen(function* () {
+ const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 })
+ if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection"))
+ return server.address.port
+}).pipe(Effect.scoped)
+
+const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authenticating: () => boolean) {
+ const clock = { deadline: (yield* Clock.currentTimeMillis) + 30_000 }
+ yield* Effect.gen(function* () {
+ const now = yield* Clock.currentTimeMillis
+ if (authenticating()) clock.deadline = now + 30_000
+ if (now >= clock.deadline) return yield* Effect.fail(new SshFailure("service"))
+ return yield* checkHealth(http)
+ }).pipe(Effect.repeat({ until: (ready) => ready, schedule: Schedule.spaced(100) }))
+})
+
+const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
+ const client = yield* HttpClient.HttpClient
+ return yield* client
+ .get(`${http.url}/api/health`, {
+ headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
+ })
+ .pipe(
+ Effect.timeout(2000),
+ Effect.map((response) => response.status >= 200 && response.status < 300),
+ Effect.orElseSucceed(() => false),
+ )
+})
diff --git a/packages/desktop/src/main/ssh/service.ts b/packages/desktop/src/main/ssh/service.ts
new file mode 100644
index 00000000000..8d0d1af31ef
--- /dev/null
+++ b/packages/desktop/src/main/ssh/service.ts
@@ -0,0 +1,90 @@
+export * as Ssh from "./service"
+
+import { Context, Effect, Fiber, FileSystem, Layer, Path, Schema, Scope, Stream } from "effect"
+import { NodeChildProcessSpawner } from "@effect/platform-node"
+import { FetchHttpClient } from "effect/unstable/http"
+import { app, shell, type WebContents } from "electron"
+import { homedir } from "node:os"
+import { SshConfig, type SshState } from "@opencode/app/ssh"
+import { SshChanged } from "../../shared/ipc-rpc/events"
+import { DesktopCli } from "../service/desktop-cli"
+import { Shutdown } from "../lifecycle/shutdown"
+import { getStore } from "../storage/store"
+import { emitIpcEvent } from "../ipc-events"
+import { createSshController } from "./controller"
+import { sshHosts, SshFailure } from "./command"
+
+export class Service extends Context.Service>>()(
+ "opencode/desktop/Ssh",
+) {}
+
+const make = Effect.fn("Ssh.make")(function* (cli: DesktopCli.Resolved) {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const scope = yield* Scope.Scope
+ const runFork = Effect.runForkWith(yield* Effect.context())
+ const stored = Schema.decodeUnknownOption(Schema.Array(SshConfig))(getStore().get("ssh.servers"))
+ const controller = yield* createSshController({
+ version: cli.version,
+ development: !app.isPackaged && cli.binary === undefined,
+ binary: cli.binary ?? cli.command[0] ?? "opencode2",
+ command: cli.command,
+ configs: stored._tag === "Some" ? stored.value : [],
+ save: (configs) => Effect.try({ try: () => getStore().set("ssh.servers", configs), catch: SshFailure.from }),
+ })
+ const subscriptions = new Map; remove: () => void }>()
+ const unsubscribeWindow = Effect.fn("Ssh.unsubscribeWindow")(function* (id: number) {
+ const entry = subscriptions.get(id)
+ if (!entry) return
+ subscriptions.delete(id)
+ entry.remove()
+ yield* Fiber.interrupt(entry.fiber)
+ yield* controller.detach(id)
+ })
+ yield* Effect.addFinalizer(() => Effect.forEach([...subscriptions.keys()], unsubscribeWindow, { discard: true }))
+ return {
+ ...controller,
+ subscribeWindow: Effect.fn("Ssh.subscribeWindow")(function* (sender: WebContents) {
+ if (subscriptions.has(sender.id)) return
+ const emit = (state: SshState) =>
+ Effect.sync(() => {
+ if (!sender.isDestroyed()) emitIpcEvent(sender, new SshChanged({ state }))
+ })
+ const fiber = yield* controller
+ .changes(sender.id)
+ .pipe(Stream.runForEach(emit), Effect.forkIn(scope, { startImmediately: true }))
+ // Electron is the imperative boundary; the callback only schedules a
+ // scoped Effect, while controller operations remain Effect-native.
+ const detach = () => {
+ runFork(unsubscribeWindow(sender.id)).pipe(Fiber.runIn(scope))
+ }
+ sender.once("destroyed", detach)
+ subscriptions.set(sender.id, { fiber, remove: () => sender.removeListener("destroyed", detach) })
+ yield* controller.state(sender.id).pipe(Effect.flatMap(emit))
+ }),
+ unsubscribeWindow,
+ hosts: sshHosts,
+ openConfig: Effect.fn("Ssh.openConfig")(function* () {
+ const file = path.join(homedir(), ".ssh", "config")
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true, mode: 0o700 })
+ yield* fs
+ .writeFileString(file, "", { flag: "wx", mode: 0o600 })
+ .pipe(Effect.catch((error) => (error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error))))
+ yield* Effect.tryPromise(() => shell.openPath(file))
+ }),
+ }
+})
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const cli = yield* DesktopCli.Service
+ const resolved = yield* cli.resolve
+ const service = yield* make(resolved)
+ const shutdown = yield* Shutdown.Service
+ const close = service.close
+ const off = yield* shutdown.add(close)
+ yield* Effect.addFinalizer(() => Effect.sync(off).pipe(Effect.andThen(close)))
+ return service
+ }),
+).pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(FetchHttpClient.layer))
diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts
index ff534b26e91..7ac5c5b9268 100644
--- a/packages/desktop/src/main/wsl/runtime.ts
+++ b/packages/desktop/src/main/wsl/runtime.ts
@@ -3,7 +3,7 @@ import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode/app/wsl/types"
import { Effect, FileSystem, Path } from "effect"
import { nativeT } from "../native/translations"
-import { parseCliVersion } from "../service/cli-version"
+import { RemoteCli } from "../remote/cli"
export type WslCommandLine = {
stream: "stdout" | "stderr"
@@ -291,9 +291,10 @@ export const installWslCli = Effect.fn("Wsl.installCli")(function* (
})
export function wslCliInstallCommand(cli: WslCliBuild) {
- const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --"
- if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}`
- return `${installer} --binary "$(wslpath -a ${shellEscape(cli.binary)})"`
+ return RemoteCli.installScript({
+ version: cli.version,
+ source: { type: "installer", binary: cli.binary ? `"$(wslpath -a ${shellEscape(cli.binary)})"` : undefined },
+ })
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise {
@@ -328,21 +329,12 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
export async function resolveWslCli(distro: string, opts?: RunWslOptions) {
- return firstLine(
- (
- await runWslSh(
- 'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi',
- distro,
- opts,
- )
- ).stdout,
- )
+ return firstLine((await runWslSh(RemoteCli.discoverScript(), distro, opts)).stdout)
}
export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) {
- const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
- const output = firstLine(result.stdout)
- return output ? parseCliVersion(output) : null
+ const result = await runWslSh(RemoteCli.versionScript(shellEscape(command)), distro, opts)
+ return RemoteCli.parseVersion(result.stdout)
}
export function openWslTerminal(distro?: string | null) {
diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts
index 0bc37412a83..48fc68ca649 100644
--- a/packages/desktop/src/main/wsl/servers.test.ts
+++ b/packages/desktop/src/main/wsl/servers.test.ts
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test"
import type { WslServerConfig } from "@opencode/app/wsl/types"
-import { Effect } from "effect"
+import { Effect, FileSystem, Path } from "effect"
+import { NodeServices } from "@effect/platform-node"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { testEffect } from "../../../../core/test/lib/effect"
import { wslCliInstallCommand } from "./runtime"
import { createWslServersController } from "./servers"
@@ -8,11 +11,42 @@ type ControllerOptions = Parameters[0]
let persistedServers: WslServerConfig[] = []
-test("passes a local CLI path directly to the V2 installer", () => {
- expect(wslCliInstallCommand({ version: "local", binary: "C:\\build\\opencode2" })).toBe(
- `curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --binary "$(wslpath -a 'C:\\build\\opencode2')"`,
- )
-})
+const it = testEffect(NodeServices.layer)
+// Execute the Linux-side installer fixture locally rather than requiring a WSL distro.
+const posix = process.platform === "win32" ? it.live.skip : it.live
+
+posix(
+ "installs a local build through the managed installer, including shell PATH setup",
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "wsl-cli-install-" })
+ const binary = path.join(dir, "local build ' cli")
+ yield* fs.writeFileString(binary, "#!/bin/sh\nprintf 'OpenCode v0.0.0-dev-16365\\n'\n", { mode: 0o755 })
+ yield* fs.writeFileString(path.join(dir, ".bashrc"), "# existing config\n")
+ yield* fs.writeFileString(path.join(dir, "installer"), yield* fs.readFileString(path.resolve("../../install")))
+ yield* fs.writeFileString(path.join(dir, "curl"), '#!/bin/sh\ncat "$HOME/installer"\n', { mode: 0o755 })
+ yield* fs.writeFileString(
+ path.join(dir, "wslpath"),
+ '#!/bin/sh\n[ "$1" = "-a" ] || exit 1\nprintf "%s" "$2" > "$HOME/wslpath-input"\nprintf "%s\\n" "$LOCAL_BINARY"\n',
+ { mode: 0o755 },
+ )
+ const windows = "C:\\local build's\\opencode2"
+ const command = wslCliInstallCommand({ version: "0.0.0-dev-16365", binary: windows })
+ expect(
+ yield* spawner.exitCode(
+ ChildProcess.make("bash", ["-c", command], {
+ env: { HOME: dir, PATH: `${dir}:/usr/bin:/bin`, LOCAL_BINARY: binary, SHELL: "/bin/bash" },
+ }),
+ ),
+ ).toBe(0)
+ expect(yield* fs.readFileString(path.join(dir, "wslpath-input"))).toBe(windows)
+ expect(yield* fs.readFileString(path.join(dir, ".opencode/bin/opencode2"))).toContain("0.0.0-dev-16365")
+ expect(yield* fs.readDirectory(path.join(dir, ".opencode/bin"))).toEqual(["opencode2"])
+ expect(yield* fs.readFileString(path.join(dir, ".bashrc"))).toContain(`export PATH=${dir}/.opencode/bin:$PATH`)
+ }),
+)
test("installs and verifies the bundled CLI version", async () => {
persistedServers = []
diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts
index a4ef9be06f6..3fe255855ea 100644
--- a/packages/desktop/src/renderer/api-types.ts
+++ b/packages/desktop/src/renderer/api-types.ts
@@ -3,6 +3,7 @@ import type { DesktopMenuAction } from "@opencode/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
+import type { SshPlatform } from "@opencode/app/ssh"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type {
ClipboardImage,
@@ -31,6 +32,7 @@ export type ElectronAPI = {
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
+ sshServers: SshPlatform
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise
getDefaultServerUrl(): Promise
diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts
index ff2ee9ddd45..8859413cb56 100644
--- a/packages/desktop/src/renderer/api.ts
+++ b/packages/desktop/src/renderer/api.ts
@@ -25,6 +25,25 @@ const updaterHandler = (state: UpdaterState) => {
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
+ sshServers: {
+ getState: () => invoke("SshGetState"),
+ subscribe: (callback) => {
+ const off = listen("SshChanged", (event) => callback(event.state))
+ void invoke("SshSubscribe")
+ return () => {
+ off()
+ void invoke("SshUnsubscribe")
+ }
+ },
+ hosts: () => invoke("SshHosts"),
+ start: (input) => invoke("SshStart", input),
+ resolve: (id) => invoke("SshResolve", { id }),
+ respond: (id, prompt, value) => invoke("SshRespond", { id, prompt, value }),
+ disconnect: (id) => invoke("SshDisconnect", { id }),
+ cancel: (id) => invoke("SshCancel", { id }),
+ forget: (id) => invoke("SshForget", { id }),
+ openConfig: () => invoke("SshOpenConfig"),
+ },
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx
index 608af6deef9..41c592fed97 100644
--- a/packages/desktop/src/renderer/desktop-app.tsx
+++ b/packages/desktop/src/renderer/desktop-app.tsx
@@ -13,6 +13,7 @@ import {
useLanguage,
useTabs,
useWslServers,
+ useSsh,
type LayoutRoute,
type UpdaterPlatform,
} from "@opencode/app/desktop"
@@ -30,6 +31,7 @@ import { LoadingSplash } from "./startup/splash"
import { getLastActiveUrl } from "./window/route-storage"
import { DesktopMemoryRouter } from "./window/router"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
+import { createSshConnections } from "./ssh/connections"
const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus })))
@@ -85,9 +87,11 @@ function DesktopWindow(props: {
function ReadyApp() {
const wslServers = useWslServers()
+ const ssh = useSsh()
+ const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
- () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
+ () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
@@ -102,6 +106,7 @@ function DesktopWindow(props: {
})
}
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
+ list.push(...sshConnections({ servers: ssh.servers }, language.t("ssh.label")))
return list
})
const effectiveDefaultServer = createMemo(() =>
diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts
index 3e939440dc2..afb4310125e 100644
--- a/packages/desktop/src/renderer/platform/index.ts
+++ b/packages/desktop/src/renderer/platform/index.ts
@@ -79,6 +79,7 @@ export function createDesktopPlatform(
await api.setDefaultServerUrl(url)
},
wslServers: os === "windows" ? api.wslServers : undefined,
+ sshServers: api.sshServers,
webviewZoom,
windowFullscreen,
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
diff --git a/packages/desktop/src/renderer/ssh/connections.test.ts b/packages/desktop/src/renderer/ssh/connections.test.ts
new file mode 100644
index 00000000000..22c685ac5e8
--- /dev/null
+++ b/packages/desktop/src/renderer/ssh/connections.test.ts
@@ -0,0 +1,85 @@
+import { expect, test } from "bun:test"
+import { createSshConnections } from "./connections"
+import type { SshItem } from "@opencode/app/ssh"
+
+test("SSH progress stays reactive on the same connection until ready or stopped", () => {
+ const connections = createSshConnections({ resolve: async () => null })
+ const config = { id: "progress", target: "ssh devbox", name: "" }
+ const stages: SshItem["stage"][] = ["connecting", "checking", "downloading", "uploading", "starting"]
+ const first = connections({ servers: [{ config, saved: true, stage: "disconnected", detail: "" }] })[0]
+ expect(first?.connecting).toBe(false)
+ for (const stage of stages) {
+ const next = connections({ servers: [{ config, saved: true, stage, detail: "" }] })[0]
+ expect(next).toBe(first)
+ expect(next?.connecting).toBe(true)
+ }
+ const settled: SshItem["stage"][] = ["ready", "authentication", "failed", "incompatible", "disconnected"]
+ for (const stage of settled) {
+ const next = connections({ servers: [{ config, saved: true, stage, detail: "" }] })[0]
+ expect(next).toBe(first)
+ expect(next?.connecting).toBe(false)
+ expect(next?.authenticationRequired).toBe(stage === "authentication")
+ }
+})
+
+test("progress and endpoint changes preserve the connection object used by routes", () => {
+ const connections = createSshConnections({ resolve: async () => null })
+ const config = { id: "fixture", target: "devbox", name: "Devbox" }
+ const first = connections({ servers: [{ config, saved: true, stage: "disconnected", detail: "" }] })[0]
+ const next = connections({
+ servers: [
+ { config, saved: true, stage: "ready", detail: "", http: { url: "http://127.0.0.1:12345", password: "secret" } },
+ ],
+ })[0]
+ expect(next).toBe(first)
+ expect(next?.http.url).toBe("http://127.0.0.1:12345")
+ expect(connections({ servers: [] })).toEqual([])
+})
+
+test("authentication owned by another window is presented as pending", () => {
+ const connections = createSshConnections({ resolve: async () => null })
+ const config = { id: "fixture", target: "devbox", name: "Devbox" }
+ const item = { config, saved: true, stage: "authentication" as const, detail: "", authenticatingElsewhere: true }
+ const connection = connections({ servers: [item] })[0]
+ expect(connection?.connecting).toBe(true)
+ expect(connection?.authenticationRequired).toBe(false)
+ expect(connections({ servers: [{ ...item, authenticatingElsewhere: false }] })[0]).toBe(connection)
+ expect(connection?.connecting).toBe(false)
+ expect(connection?.authenticationRequired).toBe(true)
+})
+
+test("existing unnamed connections show the hostname and preserve identity when renamed", () => {
+ const connections = createSshConnections({ resolve: async () => null })
+ const config = { id: "fixture", target: "ssh -p 2222 anomaly@brendan-box.exe.xyz", name: "" }
+ const item = { config, saved: true, stage: "disconnected" as const, detail: "" }
+ const first = connections({ servers: [item] })[0]
+ expect(first?.host).toBe("brendan-box.exe.xyz")
+ expect(first?.displayName).toBe("brendan-box.exe.xyz")
+ const renamed = connections({ servers: [{ ...item, config: { ...config, name: "Development" } }] })[0]
+ expect(renamed).toBe(first)
+ expect(renamed?.displayName).toBe("Development")
+ expect(renamed?.host).toBe("brendan-box.exe.xyz")
+})
+
+test("aborting reconnect does not wait for a pending IPC promise", async () => {
+ const called = Promise.withResolvers()
+ const pending = Promise.withResolvers()
+ const connections = createSshConnections({
+ resolve: () => {
+ called.resolve()
+ return pending.promise
+ },
+ })
+ const connection = connections({
+ servers: [
+ { config: { id: "fixture", target: "devbox", name: "" }, saved: true, stage: "disconnected", detail: "" },
+ ],
+ })[0]
+ if (!connection) throw new Error("missing connection")
+ const abort = new AbortController()
+ const reconnect = connection.reconnect(abort.signal)
+ await called.promise
+ abort.abort()
+ await expect(reconnect).rejects.toThrow()
+ pending.resolve(null)
+})
diff --git a/packages/desktop/src/renderer/ssh/connections.ts b/packages/desktop/src/renderer/ssh/connections.ts
new file mode 100644
index 00000000000..2e323d99d14
--- /dev/null
+++ b/packages/desktop/src/renderer/ssh/connections.ts
@@ -0,0 +1,70 @@
+import type { SshItem, SshPlatform, SshState } from "@opencode/app/ssh"
+import { isSshConnecting, sshHostname, sshName } from "@opencode/app/ssh"
+import { createStore } from "solid-js/store"
+import { Effect, Schedule } from "effect"
+
+// Routes key on the connection object. Preserve it across progress/endpoint
+// changes so reconnecting never unmounts an open conversation or composer.
+export function createSshConnections(api: Pick, defaultLabel = "SSH") {
+ const entries = new Map>()
+ return (state: SshState | undefined, label = defaultLabel) => {
+ const saved = (state?.servers ?? []).filter((item) => item.saved)
+ const ids = new Set(saved.map((item) => item.config.id))
+ entries.forEach((_, id) => {
+ if (!ids.has(id)) entries.delete(id)
+ })
+ return saved.map((item) => {
+ const existing = entries.get(item.config.id)
+ if (existing) {
+ existing.update(item, label)
+ return existing.server
+ }
+ const entry = connection(item, api, label)
+ entries.set(item.config.id, entry)
+ return entry.server
+ })
+ }
+}
+
+function connection(item: SshItem, api: Pick, label: string) {
+ const [state, setState] = createStore({ current: item, label })
+ return {
+ update: (item: SshItem, label: string) => {
+ setState("current", item)
+ if (label !== state.label) setState("label", label)
+ },
+ server: {
+ type: "ssh" as const,
+ id: item.config.id,
+ get stage() {
+ return state.current.stage
+ },
+ get connecting() {
+ return isSshConnecting(state.current.stage) || !!state.current.authenticatingElsewhere
+ },
+ get authenticationRequired() {
+ return state.current.stage === "authentication" && !state.current.authenticatingElsewhere
+ },
+ get label() {
+ return state.label
+ },
+ get host() {
+ return sshHostname(state.current.config.target)
+ },
+ get displayName() {
+ return sshName(state.current.config)
+ },
+ get http() {
+ return state.current.http ?? { url: "http://127.0.0.1:0" }
+ },
+ reconnect: (signal: AbortSignal) =>
+ Effect.runPromise(
+ Effect.tryPromise(() => api.resolve(item.config.id)).pipe(
+ Effect.repeat({ until: (http) => http !== null, schedule: Schedule.spaced(3000) }),
+ Effect.flatMap((http) => (http === null ? Effect.interrupt : Effect.succeed(http))),
+ ),
+ { signal },
+ ),
+ },
+ }
+}
diff --git a/packages/desktop/src/shared/ipc-rpc.ts b/packages/desktop/src/shared/ipc-rpc.ts
index 235fed1bc1e..e9b7cf6c777 100644
--- a/packages/desktop/src/shared/ipc-rpc.ts
+++ b/packages/desktop/src/shared/ipc-rpc.ts
@@ -7,6 +7,7 @@ import { StorageRpcs } from "./ipc-rpc/storage"
import { UpdaterRpcs } from "./ipc-rpc/updater"
import { WindowRpcs } from "./ipc-rpc/window"
import { WslRpcs } from "./ipc-rpc/wsl"
+import { SshRpcs } from "./ipc-rpc/ssh"
export { AppRpcs } from "./ipc-rpc/app"
export { EventRpcs } from "./ipc-rpc/events"
@@ -16,6 +17,16 @@ export { StorageRpcs } from "./ipc-rpc/storage"
export { UpdaterRpcs } from "./ipc-rpc/updater"
export { WindowRpcs } from "./ipc-rpc/window"
export { WslRpcs } from "./ipc-rpc/wsl"
+export { SshRpcs } from "./ipc-rpc/ssh"
-export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
+export const DesktopRpcs = AppRpcs.merge(
+ StorageRpcs,
+ FileRpcs,
+ WindowRpcs,
+ MenuRpcs,
+ UpdaterRpcs,
+ WslRpcs,
+ SshRpcs,
+ EventRpcs,
+)
export type DesktopRpcClient = RpcClient.FromGroup
diff --git a/packages/desktop/src/shared/ipc-rpc/events.ts b/packages/desktop/src/shared/ipc-rpc/events.ts
index 26c4f6c14e0..b3612749cb9 100644
--- a/packages/desktop/src/shared/ipc-rpc/events.ts
+++ b/packages/desktop/src/shared/ipc-rpc/events.ts
@@ -3,6 +3,9 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
+import { SshState } from "@opencode/app/ssh"
+
+export class SshChanged extends Schema.TaggedClass()("SshChanged", { state: SshState }) {}
export class BrowserPaneEvent extends Schema.TaggedClass()("BrowserPaneEvent", {
bindingID: Schema.String,
@@ -51,6 +54,7 @@ export const DesktopEvent = Schema.Union([
MenuCommandTriggered,
UpdaterStateChanged,
WslServersChanged,
+ SshChanged,
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
diff --git a/packages/desktop/src/shared/ipc-rpc/ssh.ts b/packages/desktop/src/shared/ipc-rpc/ssh.ts
new file mode 100644
index 00000000000..ac07e03b824
--- /dev/null
+++ b/packages/desktop/src/shared/ipc-rpc/ssh.ts
@@ -0,0 +1,17 @@
+import { Schema } from "effect"
+import { Rpc, RpcGroup } from "effect/unstable/rpc"
+import { SshHttp, SshStart, SshState } from "@opencode/app/ssh"
+
+export const SshRpcs = RpcGroup.make(
+ Rpc.make("SshGetState", { success: SshState }),
+ Rpc.make("SshSubscribe"),
+ Rpc.make("SshUnsubscribe"),
+ Rpc.make("SshHosts", { success: Schema.Array(Schema.String) }),
+ Rpc.make("SshStart", { payload: SshStart }),
+ Rpc.make("SshResolve", { payload: { id: Schema.String }, success: Schema.NullOr(SshHttp) }),
+ Rpc.make("SshRespond", { payload: { id: Schema.String, prompt: Schema.String, value: Schema.String } }),
+ Rpc.make("SshDisconnect", { payload: { id: Schema.String } }),
+ Rpc.make("SshCancel", { payload: { id: Schema.String } }),
+ Rpc.make("SshForget", { payload: { id: Schema.String } }),
+ Rpc.make("SshOpenConfig"),
+)
diff --git a/packages/desktop/test/ssh/transport.ts b/packages/desktop/test/ssh/transport.ts
new file mode 100644
index 00000000000..c79b23d4be8
--- /dev/null
+++ b/packages/desktop/test/ssh/transport.ts
@@ -0,0 +1,29 @@
+import { file, serve } from "bun"
+
+// Process-level SSH fixture: discovery reads a fixture registration,
+// and forwarding stays alive independently of the remote HTTP server.
+const [registration, ...args] = process.argv.slice(2)
+if (!registration) throw new Error("Missing fixture registration")
+if (args.includes("-G")) {
+ console.log("hostname fixture\nuser fixture\nport 22")
+ process.exit(0)
+}
+if (args.includes("-O")) process.exit(0)
+const forward = args[args.indexOf("-L") + 1]
+if (args.includes("-L") && forward) {
+ const [, port, host, remotePort] = forward.split(":")
+ const server = serve({
+ hostname: "127.0.0.1",
+ port: Number(port),
+ fetch: (request) => fetch(new Request(`http://${host}:${remotePort}${new URL(request.url).pathname}`, request)),
+ error: () => new Response(null, { status: 502 }),
+ })
+ await Bun.stdin.text()
+ await server.stop(true)
+ process.exit(0)
+}
+await Bun.stdin.text()
+const info = await file(registration).json()
+console.log(
+ `OPENCODE_SSH_STATUS=${info.url}\nOPENCODE_SSH_REGISTRATION_BEGIN\n${JSON.stringify(info)}\nOPENCODE_SSH_REGISTRATION_END`,
+)
diff --git a/packages/storybook/.storybook/mocks/app/context/platform.ts b/packages/storybook/.storybook/mocks/app/context/platform.ts
index d87bc0c37d7..7ed138297bb 100644
--- a/packages/storybook/.storybook/mocks/app/context/platform.ts
+++ b/packages/storybook/.storybook/mocks/app/context/platform.ts
@@ -1,4 +1,5 @@
import type { Platform } from "../../../../../app/src/runtime/platform/platform"
+import { createComponent, createContext, useContext, type ParentProps } from "solid-js"
const value: Platform = {
platform: "web",
@@ -8,6 +9,17 @@ const value: Platform = {
fetch: globalThis.fetch.bind(globalThis),
}
-export function usePlatform() {
- return value
+const Context = createContext(value)
+
+export function PlatformProvider(props: ParentProps<{ value: Platform }>) {
+ return createComponent(Context.Provider, {
+ value: props.value,
+ get children() {
+ return props.children
+ },
+ })
+}
+
+export function usePlatform() {
+ return useContext(Context)
}
diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx
index 50239eb80c7..fdc49ca29ff 100644
--- a/packages/ui/src/icons/icon/icon.tsx
+++ b/packages/ui/src/icons/icon/icon.tsx
@@ -4,6 +4,10 @@ import "./icon.css"
// Consumers center the SVG viewport, so each icon must center its artwork within its viewBox.
const icons = {
+ lock: {
+ viewBox: "0 0 16 16",
+ body: ``,
+ },
flask: {
viewBox: "0 0 16 16",
body: ``,