diff --git a/packages/app/component-tests/ssh-reconnect.spec.ts b/packages/app/component-tests/ssh-reconnect.spec.ts new file mode 100644 index 00000000000..870c3c12812 --- /dev/null +++ b/packages/app/component-tests/ssh-reconnect.spec.ts @@ -0,0 +1,67 @@ +import { expect, story } from "../../storybook/playwright/story" + +story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => { + const component = await mount("app-dialog-ssh--settings-reconnect") + await component.getByRole("button", { name: "More options" }).click() + await page.getByRole("menuitem", { name: "Connect", exact: true }).click() + const dialog = page.getByRole("dialog") + await expect(dialog.getByRole("textbox")).toBeVisible() + await dialog.getByRole("textbox").fill("fixture-password") + await dialog.getByRole("button", { name: "Continue" }).click() + await expect(dialog.getByRole("textbox", { name: "Verification code:" })).toBeVisible() + await dialog.getByRole("textbox").fill("123456") + await dialog.getByRole("button", { name: "Continue" }).click() + await expect(dialog).toHaveCount(0) + await expect(component.getByRole("button", { name: "Authenticate", exact: true })).toHaveCount(0) +}) + +story("cancelling a version mismatch permits reconnecting again", async ({ mount, page }) => { + const component = await mount("app-dialog-ssh--incompatible-session") + await component.getByRole("button", { name: "Reconnect", exact: true }).click() + const dialog = page.getByRole("dialog") + await expect(dialog.getByRole("alert")).toBeVisible() + await dialog.getByRole("button", { name: "Cancel", exact: true }).click() + await expect(dialog).toHaveCount(0) + await component.getByRole("button", { name: "Reconnect", exact: true }).click() + await expect(dialog.getByRole("alert")).toBeVisible() + await dialog.getByRole("button", { name: "Cancel", exact: true }).click() + await expect(dialog).toHaveCount(0) +}) + +story("adding a server keeps all SSH challenges in the original connection dialog", async ({ mount, page }) => { + await mount("app-dialog-ssh--host") + const dialog = page.getByRole("dialog") + await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox") + await dialog.getByRole("button", { name: "Add server", exact: true }).click() + await expect(dialog).toHaveCount(1) + await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeFocused() + await dialog.getByRole("button", { name: "Trust and connect" }).click() + await dialog.getByRole("textbox").fill("fixture-password") + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456") + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await expect(dialog).toHaveCount(0) +}) + +story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => { + const component = await mount("app-dialog-ssh--incompatible-session") + await component.getByRole("button", { name: "Reconnect", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Update and reconnect", exact: true }).click() + await expect(dialog).toHaveCount(1) + await dialog.getByRole("textbox").fill("fixture-password") + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456") + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await expect(dialog).toHaveCount(0) + await expect(component.getByText("Session connected")).toBeVisible() +}) + +story("key-based reconnect completes without opening an authentication dialog", async ({ mount, page }) => { + const component = await mount("app-dialog-ssh--key-reconnect") + await component.getByRole("button", { name: "Reconnect", exact: true }).click() + await expect(component.getByRole("button", { name: "Connecting to SSH server" })).toBeDisabled() + await expect(page.getByRole("dialog")).toHaveCount(0) + await expect(component.getByText("Session connected")).toBeVisible() + await expect(page.getByRole("dialog")).toHaveCount(0) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 508f9f4774c..f4a96037448 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts", "./updater": "./src/shell/updates/types.ts", "./wsl/types": "./src/servers/wsl/types.ts", + "./ssh": "./src/servers/ssh/types.ts", "./vite": "./vite.js", "./index.css": "./src/index.css" }, diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f2a443ae777..8e0286a7522 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -17,6 +17,8 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry" import { SettingsProvider } from "@/settings/model" import { TabsProvider } from "@/shell/tabs/tabs" import { WslServersProvider } from "@/servers/wsl/context" +import { SshProvider } from "@/servers/ssh/context" +import { SshRestore } from "@/servers/ssh/restore" import { ErrorPage } from "@/shell/errors/error" import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes" @@ -81,7 +83,9 @@ export function AppBaseProviders( - {props.children} + + {props.children} + @@ -109,6 +113,7 @@ export function AppInterface(props: { + {props.children} {rootProps.children} diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index 71d33fe3fa6..c94e71bc3cc 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -37,6 +37,7 @@ export type ComposerEditorView = { agent?: ComposerSelectControl variant?: ComposerSelectControl submit: { + available?: Accessor stopping: Accessor working?: Accessor queue?: ComposerQueue @@ -333,6 +334,7 @@ export function createComposerEditor(input: { draft.removeAttachment(id) }, canSubmit() { + if (input.view.submit.available?.() === false) return false if (input.view.draftOnly) return false const persisted = draft.state if (state.mode === "shell") { @@ -365,6 +367,7 @@ export function createComposerEditor(input: { dispatch({ type: "mode.shell" }) }, submit(options?: { alternate?: boolean }) { + if (input.view.submit.available?.() === false) return if (input.view.draftOnly) return input.view.submit.onSubmit(options) dispatch({ type: "popover.close" }) diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 7f5f0c485f1..422559c4689 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -11,7 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" import { usePlatform } from "@/runtime/platform/platform" import { useWorkspaceLocation } from "@/workspaces/location" -import { useData } from "@/runtime/server/current" +import { useData, useServer } from "@/runtime/server/current" import { createSessionTabs } from "@/session/helpers" import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "@/runtime/server/errors" @@ -30,6 +30,8 @@ export type ComposerModel = ComposerEditorModel & { export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel { const sdk = useWorkspaceLocation() const data = useData() + const server = useServer() + const available = () => server.conn.type !== "ssh" || server.ctx.sdk.connection.status() === "connected" const files = useFile() const layout = useLayout() const comments = useComments() @@ -394,10 +396,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? keybind: () => command.keybindParts("model.variant.cycle"), }, submit: { + available, stopping, working: adapter.working, queue: options?.queue, onSubmit: (submitOptions) => { + if (!available()) return const queue = options?.queue // Confirming an edit re-admits the queued prompt instead of sending // the composer value as a new prompt. Enter keeps it queued in diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 1cea7f62691..ce023807e67 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -20,4 +20,5 @@ export { createDraftStore } from "./runtime/persistence/drafts" export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace" export { flushPersisted } from "./runtime/persistence/persist" export { useWslServers } from "./servers/wsl/context" +export { useSsh } from "./servers/ssh/context" export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types" diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 9149d679f13..5c3f2468d42 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -15,6 +15,7 @@ import { Persistence } from "@/runtime/persistence/schema" import type { HomeController } from "../model" import { useGlobal } from "@/runtime/server/runtime" import { SessionTransfer } from "@opencode/schema/session-transfer" +import { useSshAuthenticate } from "@/servers/ssh/authenticate" export const HomeServersSchema = Schema.Struct({ collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)), @@ -28,6 +29,7 @@ export function createHomeProjectsController(home: HomeController) { const openSettings = useSettingsCommand() const serverManagement = useServerActionsController() const global = useGlobal() + const authenticate = useSshAuthenticate() const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} }) const [state] = createResource( () => ready.promise ?? Promise.resolve(), @@ -42,6 +44,15 @@ export function createHomeProjectsController(home: HomeController) { return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) } + function choose(conn: ServerConnection.Any) { + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), + }) + } + return { copy: { language, @@ -71,15 +82,25 @@ export function createHomeProjectsController(home: HomeController) { void dialog.show(() => ) }) }, - focus: home.selection.focusServer, + authenticate: (conn: ServerConnection.Any) => authenticate(conn), + focus: (conn: ServerConnection.Any) => { + if (authenticate(conn, () => home.selection.focusServer(conn))) return + home.selection.focusServer(conn) + }, }, project: { list: home.project.list, recentlyClosed: home.project.recentlyClosed, homedir: home.project.homedir, - select: home.project.select, + select: (conn: ServerConnection.Any, directory: string) => { + if (authenticate(conn, () => home.project.select(conn, directory))) return + home.project.select(conn, directory) + }, add: home.project.add, - openNewSession: home.project.openProjectNewSession, + openNewSession: (conn: ServerConnection.Any, directory: string) => { + if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return + home.project.openProjectNewSession(conn, directory) + }, canImportSession: !!platform.openAttachmentPickerDialog, importSession: (conn: ServerConnection.Any, project: LocalProject) => { if (!platform.openAttachmentPickerDialog) return @@ -125,13 +146,9 @@ export function createHomeProjectsController(home: HomeController) { .forEach((directory) => notification.project.markViewed(directory)) }, choose: (conn: ServerConnection.Any) => { + if (authenticate(conn, () => choose(conn))) return if (home.server.health(conn)?.healthy === false) return - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - multiple: true, - onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), - }) + choose(conn) }, close: (conn: ServerConnection.Any, directory: string) => { const next = closeHomeProject( diff --git a/packages/app/src/home/projects/region.tsx b/packages/app/src/home/projects/region.tsx index e0c997f18bb..13c3e078d02 100644 --- a/packages/app/src/home/projects/region.tsx +++ b/packages/app/src/home/projects/region.tsx @@ -26,6 +26,7 @@ export function HomeProjects(props: { onWheel={props.scroll.viewport.containWheel} onChooseProject={props.projects.project.choose} onFocusServer={props.projects.server.focus} + onAuthenticateServer={props.projects.server.authenticate} onToggleCollapsed={props.projects.server.toggleCollapsed} onEditServer={props.projects.server.edit} onSetDefaultServer={props.projects.server.setDefault} diff --git a/packages/app/src/home/projects/view.css b/packages/app/src/home/projects/view.css new file mode 100644 index 00000000000..759816f76cc --- /dev/null +++ b/packages/app/src/home/projects/view.css @@ -0,0 +1,59 @@ +/* Home server/project rows keep the label underneath the hover actions. + The actions carry a tab-style background with a fade on the left, and the + label fades out where it slides underneath. Mirrors tab-nav.css. */ +[data-home-row] { + --home-row-surface: var(--v2-background-bg-base); + --home-row-background: color-mix( + in srgb, + var(--home-row-surface) var(--home-row-opacity, 100%), + var(--v2-background-bg-base) + ); + background: var(--home-row-background); +} + +[data-home-row]:is(:hover, :focus-within, [data-dragging="true"], :has([data-menu="true"])) { + --home-row-surface: var(--v2-background-bg-layer-01); +} + +[data-home-row][data-selected] { + --home-row-surface: var(--v2-background-bg-layer-03); +} + +[data-home-row][data-dimmed="true"] { + --home-row-opacity: 60%; +} + +/* Keep the background outside the button's disabled-content opacity. */ +[data-home-row] > button { + background: transparent; +} + +[data-home-row] [data-slot="home-row-actions"] { + background: linear-gradient(to right, transparent, var(--home-row-background) 8px); +} + +[data-home-row]:dir(rtl) [data-slot="home-row-actions"] { + background: linear-gradient(to left, transparent, var(--home-row-background) 8px); +} + +[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])) [data-slot="home-row-label"] { + -webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); +} + +[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])):dir(rtl) [data-slot="home-row-label"] { + -webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); +} + +@media (hover: none) { + [data-home-row] [data-slot="home-row-label"] { + -webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + } + + [data-home-row]:dir(rtl) [data-slot="home-row-label"] { + -webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px)); + } +} diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx index 8576b120066..ba9b38c53cc 100644 --- a/packages/app/src/home/projects/view.tsx +++ b/packages/app/src/home/projects/view.tsx @@ -10,6 +10,8 @@ import { ScrollView } from "@opencode/ui/scroll-view" import { ProjectAvatar } from "@opencode/ui/project-avatar" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" +import { Button } from "@opencode/ui/button" +import { Spinner } from "@opencode/ui/spinner" import { Menu } from "@opencode/ui/menu" import { Tooltip } from "@opencode/ui/tooltip" import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout" @@ -21,6 +23,7 @@ import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu import { ServerHealthIndicator } from "@/servers/registry/row" import { type ServerHealth } from "@/runtime/server/health" import { fileManagerApp } from "@/home/projects/file-manager" +import "./view.css" const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" @@ -46,6 +49,7 @@ export type HomeProjectsViewProps = { onWheel: (event: WheelEvent) => void onChooseProject: (server: ServerConnection.Any) => void onFocusServer: (server: ServerConnection.Any) => void + onAuthenticateServer?: (server: ServerConnection.Any) => void onToggleCollapsed: (server: ServerConnection.Any) => void onEditServer: (server: ServerConnection.Http) => void onSetDefaultServer: (server: ServerConnection.Any | undefined) => void @@ -122,6 +126,10 @@ export function HomeProjectsView(props: HomeProjectsViewProps) { props.onFocusServer(server) setState("open", false) }} + onAuthenticateServer={(server) => { + setState("open", false) + props.onAuthenticateServer?.(server) + }} onChooseProject={(server) => { setState("open", false) props.onChooseProject(server) @@ -196,7 +204,12 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) { 1} + when={ + props.servers.length > 1 || + props.servers.some( + (server) => server.type === "ssh" && (server.authenticationRequired || server.connecting), + ) + } fallback={ {(server) => ( @@ -231,6 +244,8 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) { const healthy = () => !!props.serverHealth(item)?.healthy const hasProjects = () => projects().length > 0 const collapsed = () => props.collapsed(item) + const authentication = () => item.type === "ssh" && item.authenticationRequired + const connecting = () => item.type === "ssh" && item.connecting return (
- + +
+
+ +
+ +
@@ -314,6 +348,7 @@ function HomeServerRow(props: { health: ServerHealth | undefined }) { const healthy = () => !!props.health?.healthy + const authentication = () => props.server.type === "ssh" && props.server.authenticationRequired const incompatible = () => !!props.health?.incompatible const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0 const contextMenuID = () => serverContextMenuID(props.server) @@ -326,16 +361,23 @@ function HomeServerRow(props: { appearance="standard" placement="top" class="flex h-7 w-full min-w-0" - inactive={!incompatible()} - value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })} + inactive={!incompatible() && !authentication()} + value={ + authentication() + ? props.language.t("ssh.stage.authentication") + : props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" }) + } > -
+
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) => ( +
+
+ +
+ {state.label} +
+ )} +
+
+ ), +} 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) => ( + + )} + + +
+ ) +} 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 ( + + + + {state.prompted || props.config + ? language.t("ssh.connectTo", { host: sshName(state) }) + : language.t("ssh.add")} + + + + +
+ +
+ + setState("target", event.currentTarget.value)} + onKeyDown={keyDown} + /> +
+
+ + setState("name", event.currentTarget.value)} + onKeyDown={keyDown} + /> +
+
+ + {(prompt) => ( +
+
+                  {prompt.text}
+                
+ + + queueMicrotask(() => { + if (element.isConnected) element.focus() + }) + } + type="password" + appearance="large" + class="!w-full self-stretch" + autofocus + value={state.response} + autocomplete="off" + spellcheck={false} + disabled={waiting()} + onInput={(event) => setState("response", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + respond() + } + }} + /> + +
+ )} +
+ + {(error) => ( + + {error()} + + )} + +
+
+ + + start(item()?.stage === "incompatible")} + > + {busy() + ? language.t("ssh.stage.connecting") + : item()?.stage === "incompatible" + ? language.t("ssh.update") + : props.config + ? language.t("ssh.connect") + : language.t("dialog.server.add.button")} + + } + > + {(prompt) => ( + + )} + + +
+ ) +} 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) => ( + + } + aria-label={language.t("common.moreOptions")} + /> + + + + {language.t("ssh.server.menu.label")} + + ssh.connect(item().config)}> + {language.t(item().stage === "authentication" ? "ssh.authenticate" : "ssh.connect")} + + + + props.domain.defaults.set(key())}> + {language.t("dialog.server.menu.default")} + + + + props.domain.defaults.set(null)}> + {language.t("dialog.server.menu.defaultRemove")} + + + + void props.domain.connection.remove(key())}> + {language.t("dialog.server.menu.delete")} + + + + + + )} + + ) +} 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: ``,