mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e36f7120 |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 { SshServersProvider } from "@/servers/ssh/context"
|
||||
import { SshRestore } from "@/servers/ssh/restore"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
|
||||
@@ -80,9 +82,11 @@ export function AppBaseProviders(
|
||||
>
|
||||
<QueryProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
<SshServersProvider>
|
||||
<DialogProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</DialogProvider>
|
||||
</SshServersProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -109,6 +113,7 @@ export function AppInterface(props: {
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ComposerEditorView = {
|
||||
agent?: ComposerSelectControl
|
||||
variant?: ComposerSelectControl
|
||||
submit: {
|
||||
available?: Accessor<boolean>
|
||||
stopping: Accessor<boolean>
|
||||
working?: Accessor<boolean>
|
||||
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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,4 +10,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 { useSshServers } from "./servers/ssh/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -365,7 +365,10 @@ function HomeServerRow(props: {
|
||||
/>
|
||||
</span>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
<ServerHealthIndicator
|
||||
health={props.health}
|
||||
connecting={props.server.type === "ssh" && props.server.connecting}
|
||||
/>
|
||||
</div>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>
|
||||
|
||||
@@ -2,6 +2,47 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"ssh.label": "SSH",
|
||||
"ssh.offline": "Not connected to {{host}}. Your draft is preserved; remote work may still be running.",
|
||||
"ssh.placeholder": "ssh user@example.com",
|
||||
"ssh.add": "Add SSH server",
|
||||
"ssh.server.menu.label": "SSH server",
|
||||
"ssh.target": "Host or SSH command",
|
||||
"ssh.connect": "Connect",
|
||||
"ssh.connectTo": "Connect to {{host}}",
|
||||
"ssh.authenticate": "SSH authentication",
|
||||
"ssh.trust": "Trust and connect",
|
||||
"ssh.continue": "Continue",
|
||||
"ssh.retry": "Retry",
|
||||
"ssh.update": "Update and reconnect",
|
||||
"ssh.openProject": "Open project",
|
||||
"ssh.project": "Open project on {{host}}",
|
||||
"ssh.disconnect": "Disconnect",
|
||||
"ssh.forget": "Forget connection",
|
||||
"ssh.stage.disconnected": "Disconnected. The remote server is left running.",
|
||||
"ssh.stage.connecting": "Connecting over SSH…",
|
||||
"ssh.stage.checking": "Checking OpenCode…",
|
||||
"ssh.stage.downloading": "Downloading server…",
|
||||
"ssh.stage.uploading": "Uploading server…",
|
||||
"ssh.stage.starting": "Connecting to OpenCode…",
|
||||
"ssh.stage.ready": "Connected",
|
||||
"ssh.stage.authentication": "Authentication required",
|
||||
"ssh.stage.incompatible": "Server update required",
|
||||
"ssh.stage.failed": "Connection failed",
|
||||
"ssh.error.input":
|
||||
"Enter a host or SSH connection command. Remote commands and unsupported SSH options aren’t allowed.",
|
||||
"ssh.error.connection": "Could not establish the SSH connection. Check your network and SSH configuration.",
|
||||
"ssh.error.platform":
|
||||
"This remote platform is not supported. Automatic setup currently requires Linux or macOS on x64 or arm64.",
|
||||
"ssh.error.version": "The remote service must match this Desktop version before connecting.",
|
||||
"ssh.error.install":
|
||||
"Could not install the remote server. Check connectivity, disk space, and that tar is installed.",
|
||||
"ssh.error.unpublished":
|
||||
"This Desktop version has no published remote server. For development builds, install and start V2 on the host, then retry.",
|
||||
"ssh.error.service": "SSH connected, but the OpenCode server did not become ready.",
|
||||
"ssh.error.host-key":
|
||||
"The host’s identity could not be verified. Verify its fingerprint before updating your SSH known hosts.",
|
||||
"ssh.error.ssh-missing": "OpenSSH was not found. Install an OpenSSH client and ensure ssh is available on PATH.",
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "@/shell/commands/desktop-menu"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { SshPlatform } from "@/servers/ssh/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
|
||||
@@ -85,6 +86,7 @@ type PlatformBase = {
|
||||
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
sshServers?: SshPlatform
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/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,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode-ai/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,45 @@ export function useCheckServerHealth() {
|
||||
}
|
||||
|
||||
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
return createServerHealth(servers, enabled, useCheckServerHealth())
|
||||
}
|
||||
|
||||
export function createServerHealth(
|
||||
servers: Accessor<ServerConnection.Any[]>,
|
||||
enabled: Accessor<boolean>,
|
||||
check: (http: ServerConnection.HttpBase) => Promise<ServerHealth>,
|
||||
) {
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
const endpoints = new Map<ServerConnection.Key, string>()
|
||||
|
||||
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 }))
|
||||
for (const conn of list) {
|
||||
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<string, ServerHealth> = {}
|
||||
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)
|
||||
const result = await check(conn.http)
|
||||
results[conn.key] = result
|
||||
if (!dead) setStatus(conn.key, reconcile(result))
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
|
||||
@@ -24,6 +24,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 +160,12 @@ export namespace ServerConnection {
|
||||
// Remote server desktop can SSH into
|
||||
export type Ssh = {
|
||||
type: "ssh"
|
||||
connecting?: 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<HttpBase>
|
||||
} & Base
|
||||
|
||||
export type Any =
|
||||
@@ -178,7 +182,7 @@ export namespace ServerConnection {
|
||||
return Key.make("sidecar")
|
||||
}
|
||||
case "ssh":
|
||||
return Key.make(`ssh:${conn.host}`)
|
||||
return Key.make(`ssh:${conn.id ?? conn.host}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,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 platform.sshServers?.forget(key.slice(4))
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
|
||||
@@ -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 <SshMenu id={props.server.id} domain={props.domain} />
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { For } from "solid-js"
|
||||
import { ServerHealthIndicator } from "./row"
|
||||
import type { ServerHealth } from "@/runtime/server/health"
|
||||
|
||||
const states: { label: string; connecting?: boolean; health?: ServerHealth }[] = [
|
||||
{ label: "Connecting (previous health check failed)", connecting: true, health: { healthy: false } },
|
||||
{ label: "Tunnel ready, checking its new endpoint", health: { healthy: false, checking: true } },
|
||||
{ label: "Connected", health: { healthy: true } },
|
||||
{ label: "Failed", health: { healthy: false } },
|
||||
{ label: "Incompatible", health: { healthy: false, incompatible: true } },
|
||||
{ label: "Not checked" },
|
||||
]
|
||||
|
||||
export default { title: "App/Servers/Health indicator", id: "app-server-health" }
|
||||
export const States = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={states}>
|
||||
{(state) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex size-4 shrink-0 items-center justify-center">
|
||||
<ServerHealthIndicator health={state.health} connecting={state.connecting} />
|
||||
</div>
|
||||
<span>{state.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import {
|
||||
children,
|
||||
@@ -102,22 +104,36 @@ export function ServerRow(props: ServerRowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
export function ServerHealthIndicator(props: { health?: ServerHealth; connecting?: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Show
|
||||
when={props.health?.incompatible}
|
||||
when={props.connecting || props.health?.checking}
|
||||
fallback={
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={props.health?.incompatible}
|
||||
fallback={
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
|
||||
<span
|
||||
role="status"
|
||||
aria-label={language.t("ssh.stage.connecting")}
|
||||
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
|
||||
>
|
||||
<Spinner class="size-3 shrink-0" />
|
||||
</span>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSshServers } from "./context"
|
||||
import { DialogSsh } from "./dialog"
|
||||
import { sshName } from "./name"
|
||||
|
||||
export function SshBanner() {
|
||||
const ssh = useSshServers()
|
||||
const route = useCurrentRoute()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
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.data?.servers.find((item) => `ssh:${item.config.id}` === key && item.stage !== "ready")
|
||||
})
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(item) => (
|
||||
<div class="ssh-banner" role="status" aria-live="polite">
|
||||
<span>
|
||||
{language.t("ssh.offline", { host: sshName(item().config) })} {language.t(`ssh.stage.${item().stage}`)}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void dialog.push(() => <DialogSsh config={item().config} connect />)}
|
||||
>
|
||||
{language.t(item().stage === "authentication" ? "ssh.authenticate" : "ssh.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { SshState } from "./types"
|
||||
|
||||
const key = ["platform", "sshServers"] as const
|
||||
export const { use: useSshServers, provider: SshServersProvider } = createSimpleContext({
|
||||
name: "SshServers",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const client = useQueryClient()
|
||||
const query = useQuery(() =>
|
||||
queryOptions<SshState>({
|
||||
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 query
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { SshServersProvider } from "./context"
|
||||
import { DialogSsh } from "./dialog"
|
||||
import type { SshItem, SshPlatform, SshState } from "./types"
|
||||
|
||||
function Fixture(props: { initial?: "connecting" | "password" | "confirmation" | "failure"; responseDelay?: number }) {
|
||||
const state: { item?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = { step: 0 }
|
||||
onCleanup(() => clearTimeout(state.timer))
|
||||
const listeners = new Set<(state: SshState) => void>()
|
||||
const snapshot = (): SshState => ({ servers: state.item ? [state.item] : [] })
|
||||
const update = (changes: Partial<SshItem>) => {
|
||||
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.step = props.initial === "password" ? 1 : 0
|
||||
state.item = {
|
||||
config: input,
|
||||
saved: false,
|
||||
stage: "connecting",
|
||||
detail: "",
|
||||
destination: "brendon@dev.example.com:22",
|
||||
}
|
||||
if (props.initial === "connecting") 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 })
|
||||
},
|
||||
forget: async () => {
|
||||
state.item = undefined
|
||||
listeners.forEach((listener) => listener(snapshot()))
|
||||
},
|
||||
openConfig: async () => {},
|
||||
}
|
||||
return (
|
||||
<PlatformProvider
|
||||
value={{
|
||||
platform: "desktop",
|
||||
windowID: "ssh-story",
|
||||
sshServers: api,
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SshServersProvider>
|
||||
<Open initial={props.initial} />
|
||||
</SshServersProvider>
|
||||
</QueryClientProvider>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function Open(props: { initial?: string }) {
|
||||
const dialog = useDialog()
|
||||
const open = () =>
|
||||
dialog.show(() => (
|
||||
<DialogSsh
|
||||
config={props.initial ? { id: "story", target: "devbox", name: "Development" } : undefined}
|
||||
connect={!!props.initial}
|
||||
/>
|
||||
))
|
||||
onMount(open)
|
||||
return <Button onClick={open}>Open SSH connection</Button>
|
||||
}
|
||||
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const Host = { render: () => <Fixture /> }
|
||||
export const Connecting = { render: () => <Fixture initial="connecting" /> }
|
||||
export const Password = { render: () => <Fixture initial="password" /> }
|
||||
export const SlowPassword = { render: () => <Fixture initial="password" responseDelay={5000} /> }
|
||||
export const Confirmation = { render: () => <Fixture initial="confirmation" /> }
|
||||
export const Failure = { render: () => <Fixture initial="failure" /> }
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
|
||||
import { Divider } from "@opencode-ai/ui/divider"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEffect, createMemo, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { useSshServers } 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; openProject?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const ssh = useSshServers()
|
||||
// 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: false,
|
||||
prompted: false,
|
||||
response: "",
|
||||
answered: "",
|
||||
submitting: false,
|
||||
complete: false,
|
||||
error: false,
|
||||
})
|
||||
let task: Fiber.Fiber<void> | undefined
|
||||
const runAction = (effect: Effect.Effect<unknown, unknown>) => {
|
||||
setState({ submitting: true, error: false })
|
||||
task = Effect.runFork(
|
||||
effect.pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catch(() => Effect.sync(() => setState("error", true))),
|
||||
Effect.ensuring(Effect.sync(() => setState("submitting", false))),
|
||||
),
|
||||
)
|
||||
}
|
||||
const item = createMemo(() => ssh.data?.servers.find((item) => item.config.id === id))
|
||||
const error = createMemo(() => {
|
||||
if (state.error) return language.t("common.requestFailed")
|
||||
const error = item()?.error
|
||||
return error ? language.t(`ssh.error.${error}`) : undefined
|
||||
})
|
||||
const busy = createMemo(
|
||||
() => state.submitting || (state.started && !state.error && isSshConnecting(item()?.stage ?? "connecting")),
|
||||
)
|
||||
const prompt = createMemo<SshItem["prompt"]>((previous) => item()?.prompt ?? (busy() ? previous : undefined))
|
||||
const waiting = () => busy() || (!state.error && state.answered === prompt()?.id)
|
||||
const start = (replace = false) => {
|
||||
const api = platform.sshServers
|
||||
if (!api || busy() || !state.target.trim()) return
|
||||
setState({ started: true, prompted: !!prompt() })
|
||||
runAction(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.tryPromise(() => api.start({ id, target: state.target, name: state.name, replace }))
|
||||
yield* Effect.tryPromise(() => ssh.refetch())
|
||||
}),
|
||||
)
|
||||
}
|
||||
const respond = () => {
|
||||
const current = item()?.prompt
|
||||
const api = platform.sshServers
|
||||
if (!current || waiting() || !api || (!current.confirm && !state.response)) return
|
||||
setState("answered", current.id)
|
||||
runAction(Effect.tryPromise(() => api.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" || state.submitting || state.complete) return
|
||||
setState("complete", true)
|
||||
dialog.close()
|
||||
if (openProject) queueMicrotask(() => openProject(id))
|
||||
})
|
||||
onMount(() => {
|
||||
if (props.connect) start()
|
||||
})
|
||||
onCleanup(() => {
|
||||
const api = platform.sshServers
|
||||
const cancel = state.started && !state.complete
|
||||
const forget = !props.config && !item()?.saved
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
if (task) yield* Fiber.interrupt(task)
|
||||
if (!cancel || !api) return
|
||||
yield* Effect.tryPromise(() => api.disconnect(id))
|
||||
if (forget) yield* Effect.tryPromise(() => api.forget(id))
|
||||
}).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
if (prompt()) return
|
||||
start(item()?.stage === "incompatible")
|
||||
}
|
||||
return (
|
||||
<Dialog fit class="settings-server-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>
|
||||
{state.prompted || props.config
|
||||
? language.t("ssh.connectTo", { host: sshName(state) })
|
||||
: language.t("ssh.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<Show when={!state.prompted || (!!error() && !prompt())}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-target">
|
||||
{language.t("ssh.target")}
|
||||
</label>
|
||||
<TextInput
|
||||
id="ssh-target"
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
dir="ltr"
|
||||
value={state.target}
|
||||
autofocus
|
||||
placeholder={language.t("ssh.placeholder")}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
disabled={busy() || !!prompt()}
|
||||
invalid={!!error()}
|
||||
onInput={(event) => setState("target", event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-name">
|
||||
{language.t("dialog.server.add.name")}
|
||||
</label>
|
||||
<TextInput
|
||||
id="ssh-name"
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
dir="auto"
|
||||
value={state.name}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={busy() || !!prompt()}
|
||||
onInput={(event) => setState("name", event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt()} keyed>
|
||||
{(prompt) => (
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<pre id="ssh-prompt" class="ssh-prompt" dir="auto">
|
||||
{prompt.text}
|
||||
</pre>
|
||||
<Show when={!prompt.confirm}>
|
||||
<TextInput
|
||||
id="ssh-response"
|
||||
aria-labelledby="ssh-prompt"
|
||||
ref={(element) =>
|
||||
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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
{(error) => (
|
||||
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
|
||||
{error()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
cancelButton = element
|
||||
}}
|
||||
variant="neutral"
|
||||
onClick={() => dialog.close()}
|
||||
>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Show
|
||||
when={prompt()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="contrast"
|
||||
disabled={busy() || !state.target.trim()}
|
||||
onClick={() => 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")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(prompt) => (
|
||||
<Button variant="contrast" disabled={waiting() || (!prompt().confirm && !state.response)} onClick={respond}>
|
||||
{waiting()
|
||||
? language.t("ssh.stage.connecting")
|
||||
: language.t(prompt().confirm ? "ssh.trust" : "ssh.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
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 { useSshServers } from "./context"
|
||||
import { DialogSsh } from "./dialog"
|
||||
|
||||
export function SshMenu(props: { id: string; domain: ServerActionsController }) {
|
||||
const ssh = useSshServers()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const item = () => ssh.data?.servers.find((item) => item.config.id === props.id)
|
||||
const key = () => ServerConnection.Key.make(`ssh:${props.id}`)
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(item) => (
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("ssh.server.menu.label")}</Menu.GroupLabel>
|
||||
<Show when={item().stage !== "ready"}>
|
||||
<Menu.Item onSelect={() => void dialog.push(() => <DialogSsh config={item().config} connect />)}>
|
||||
{language.t(item().stage === "authentication" ? "ssh.authenticate" : "ssh.connect")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key()}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(key())}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key()}>
|
||||
<Menu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => void props.domain.connection.remove(key())}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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<SshConfig, "name" | "target">) {
|
||||
return config.name || sshHostname(config.target)
|
||||
}
|
||||
@@ -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<void> | undefined
|
||||
}) {
|
||||
const restored = new Set<string>()
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useSshServers } from "./context"
|
||||
import { createSshRestore } from "./restore-state"
|
||||
|
||||
export function SshRestore() {
|
||||
const platform = usePlatform()
|
||||
const ssh = useSshServers()
|
||||
createSshRestore({
|
||||
state: () => ssh.data,
|
||||
start: (input) => platform.sshServers?.start(input),
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 { useSshServers } from "./context"
|
||||
import { SshMenu } from "./menu"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
|
||||
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
|
||||
const ssh = useSshServers()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<For
|
||||
each={ssh.data?.servers.filter(
|
||||
(item) =>
|
||||
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 (
|
||||
<div class="settings-servers-row">
|
||||
<div class="settings-servers-lead">
|
||||
<ServerHealthIndicator health={indicator()} connecting={isSshConnecting(item.stage)} />
|
||||
<div class="settings-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<bdi class="settings-servers-name truncate" dir={item.config.name ? "auto" : "ltr"}>
|
||||
{sshName(item.config)}
|
||||
</bdi>
|
||||
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
|
||||
{language.t("ssh.label")}
|
||||
</span>
|
||||
</span>
|
||||
<Show when={health()?.version}>
|
||||
{(version) => <span class="settings-servers-meta">v{version()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-servers-actions">
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Badge>{language.t("dialog.server.status.default")}</Badge>
|
||||
</Show>
|
||||
<SshMenu id={item.config.id} domain={props.domain} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.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;
|
||||
}
|
||||
.ssh-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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),
|
||||
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<SshState>
|
||||
subscribe(callback: (state: SshState) => void): () => void
|
||||
hosts(): Promise<readonly string[]>
|
||||
start(input: SshStart): Promise<void>
|
||||
resolve(id: string): Promise<SshHttp | null>
|
||||
respond(id: string, prompt: string, value: string): Promise<void>
|
||||
disconnect(id: string): Promise<void>
|
||||
forget(id: string): Promise<void>
|
||||
openConfig(): Promise<void>
|
||||
}
|
||||
@@ -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 (
|
||||
<Show
|
||||
when={platform.wslServers}
|
||||
when={platform.wslServers || platform.sshServers}
|
||||
fallback={
|
||||
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -44,7 +45,12 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
|
||||
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
|
||||
<Show when={platform.sshServers}>
|
||||
<Menu.Item onSelect={() => void dialog.push(() => <DialogSsh />)}>{language.t("ssh.add")}</Menu.Item>
|
||||
</Show>
|
||||
<Show when={platform.wslServers}>
|
||||
<Menu.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -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 { useSshServers } 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 = useSshServers()
|
||||
|
||||
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 = () => {
|
||||
|
||||
<div class="settings-tab-body settings-servers">
|
||||
<Show
|
||||
when={filtered().length > 0 || wslServers().length > 0}
|
||||
when={filtered().length > 0 || wslServers().length > 0 || ssh.data?.servers.some((item) => item.saved)}
|
||||
fallback={
|
||||
<div class="settings-servers-status">
|
||||
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
|
||||
@@ -100,6 +103,7 @@ export const SettingsServers: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<SshServerSettings filter={store.filter} domain={controller} />
|
||||
<WslServerSettings domain={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
|
||||
@@ -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-ai/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(() => <DialogSsh openProject />),
|
||||
})
|
||||
if (platform.platform !== "desktop" || !platform.exportDebugLogs) return commands
|
||||
commands.push({
|
||||
id: "logs.export",
|
||||
|
||||
@@ -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 { SshBanner } from "@/servers/ssh/banner"
|
||||
|
||||
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
|
||||
@@ -98,6 +99,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col">
|
||||
<SshBanner />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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<ComposerPersistedState>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
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"
|
||||
|
||||
function fixture() {
|
||||
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
|
||||
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<ServerHealth>()
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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<SshState>({
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,11 @@ import { Npm } from "@opencode-ai/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"),
|
||||
|
||||
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
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),
|
||||
SshForget: ({ id }) => ssh.forget(id).pipe(Effect.orDie),
|
||||
SshOpenConfig: () => ssh.openConfig().pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"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()
|
||||
})
|
||||
|
||||
it.live(
|
||||
"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<typeof RemoteCli.installScript>[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"])
|
||||
}),
|
||||
)
|
||||
@@ -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<Failure>()("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<Source, { type: "installer" }>) {
|
||||
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
|
||||
})
|
||||
@@ -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<string, string>,
|
||||
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<void>()
|
||||
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"}')
|
||||
}),
|
||||
)
|
||||
@@ -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<void>
|
||||
clear: (id: string) => Effect.Effect<void>
|
||||
}) {
|
||||
const token = randomUUID()
|
||||
const pending = new Map<string, Deferred.Deferred<string>>()
|
||||
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<string, SshFailure>()
|
||||
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<string>()
|
||||
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)
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
|
||||
it.live(
|
||||
"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")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"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()
|
||||
})
|
||||
@@ -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<typeof parseTarget>
|
||||
version: string
|
||||
development?: boolean
|
||||
env: NodeJS.ProcessEnv
|
||||
replace?: boolean
|
||||
stage: (stage: "checking" | "downloading" | "uploading" | "starting") => Effect.Effect<void>
|
||||
}) {
|
||||
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,
|
||||
})
|
||||
})
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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>()("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<typeof parseTarget>) {
|
||||
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<typeof parseTarget>,
|
||||
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<string>(),
|
||||
): Effect.fn.Return<string[], never, FileSystem.FileSystem | Path.Path> {
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
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 { testEffect } from "../../../../core/test/lib/effect"
|
||||
import { createSshController } from "./controller"
|
||||
import { quote } from "./command"
|
||||
|
||||
const it = testEffect(Layer.merge(NodeServices.layer, FetchHttpClient.layer))
|
||||
|
||||
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<void>()
|
||||
const closed = yield* Deferred.make<void>()
|
||||
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")),
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
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-ai/app/ssh"
|
||||
import { createAskpass } from "./askpass"
|
||||
import { bootstrap } from "./bootstrap"
|
||||
import { parseTarget, quote, runSsh, sshArgs, sshExecutable, tunnelArgs, SshFailure } from "./command"
|
||||
|
||||
type Connection = {
|
||||
owner?: number
|
||||
ready: Deferred.Deferred<SshHttp | null>
|
||||
respond?: (id: string, value: string) => Effect.Effect<void>
|
||||
}
|
||||
type Attempt = Connection & { fiber: Fiber.Fiber<void> }
|
||||
|
||||
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<void, SshFailure>
|
||||
}) {
|
||||
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<void>()
|
||||
yield* Scope.addFinalizer(lifetime, PubSub.shutdown(changed))
|
||||
|
||||
const items = new Map<string, SshItem>(
|
||||
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<string, Attempt>()
|
||||
const paused = new Set(items.keys())
|
||||
const failures = new Map<string, number>()
|
||||
const lifecycle = { closed: false }
|
||||
const emit = PubSub.publish(changed, undefined).pipe(Effect.asVoid)
|
||||
const state = (owner?: number): Effect.Effect<SshState> =>
|
||||
Effect.sync(() => ({
|
||||
servers: [...items.values()].map((item) => ({
|
||||
...item,
|
||||
prompt: attempts.get(item.config.id)?.owner === owner ? item.prompt : undefined,
|
||||
})),
|
||||
}))
|
||||
const update = Effect.fnUntraced(function* (id: string, value: Partial<SshItem>) {
|
||||
const item = items.get(id)
|
||||
if (!item) return
|
||||
items.set(id, { ...item, ...value })
|
||||
yield* emit
|
||||
})
|
||||
const run = (options: Parameters<typeof runSsh>[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<void>()
|
||||
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)
|
||||
const config = { id, target: request.target.trim(), name: request.name.trim() }
|
||||
const connection: Connection = { owner, ready: yield* Deferred.make<SshHttp | null>() }
|
||||
const admitted = yield* Deferred.make<void>()
|
||||
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)
|
||||
if (items.get(id)?.prompt) 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 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) return item.http
|
||||
if (!attempts.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,
|
||||
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]) => disconnect(id),
|
||||
{ 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 client = yield* HttpClient.HttpClient
|
||||
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* 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),
|
||||
)
|
||||
}).pipe(Effect.repeat({ until: (ready) => ready, schedule: Schedule.spaced(100) }))
|
||||
})
|
||||
@@ -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-ai/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<Service, Effect.Success<ReturnType<typeof make>>>()(
|
||||
"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<number, { fiber: Fiber.Fiber<void>; 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))
|
||||
@@ -3,7 +3,7 @@ import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode-ai/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<WslDistroProbe> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { WslServerConfig } from "@opencode-ai/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,40 @@ type ControllerOptions = Parameters<typeof createWslServersController>[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)
|
||||
|
||||
it.live(
|
||||
"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 = []
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type { SshPlatform } from "@opencode-ai/app/ssh"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -24,6 +25,7 @@ export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
sshServers: SshPlatform
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
getDefaultServerUrl(): Promise<string | null>
|
||||
|
||||
@@ -25,6 +25,24 @@ 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 }),
|
||||
forget: (id) => invoke("SshForget", { id }),
|
||||
openConfig: () => invoke("SshOpenConfig"),
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLanguage,
|
||||
useTabs,
|
||||
useWslServers,
|
||||
useSshServers,
|
||||
type LayoutRoute,
|
||||
type UpdaterPlatform,
|
||||
} from "@opencode-ai/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,12 @@ function DesktopWindow(props: {
|
||||
|
||||
function ReadyApp() {
|
||||
const wslServers = useWslServers()
|
||||
const sshServers = useSshServers()
|
||||
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 && !sshServers.isLoading,
|
||||
)
|
||||
const servers = createMemo(() => {
|
||||
const data = initializationData(sidecar)
|
||||
@@ -102,6 +107,7 @@ function DesktopWindow(props: {
|
||||
})
|
||||
}
|
||||
list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label")))
|
||||
list.push(...sshConnections(sshServers.data, language.t("ssh.label")))
|
||||
return list
|
||||
})
|
||||
const effectiveDefaultServer = createMemo(() =>
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createDesktopPlatform(
|
||||
await api.setDefaultServerUrl(url)
|
||||
},
|
||||
wslServers: os === "windows" ? api.wslServers : undefined,
|
||||
sshServers: api.sshServers,
|
||||
webviewZoom,
|
||||
windowFullscreen,
|
||||
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSshConnections } from "./connections"
|
||||
import type { SshItem } from "@opencode-ai/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)
|
||||
}
|
||||
})
|
||||
|
||||
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("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<void>()
|
||||
const pending = Promise.withResolvers<null>()
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SshItem, SshPlatform, SshState } from "@opencode-ai/app/ssh"
|
||||
import { isSshConnecting, sshHostname, sshName } from "@opencode-ai/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<SshPlatform, "resolve">, defaultLabel = "SSH") {
|
||||
const entries = new Map<string, ReturnType<typeof connection>>()
|
||||
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<SshPlatform, "resolve">, 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 connecting() {
|
||||
return isSshConnecting(state.current.stage)
|
||||
},
|
||||
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 },
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<typeof DesktopRpcs, RpcClientError.RpcClientError>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
import { SshState } from "@opencode-ai/app/ssh"
|
||||
|
||||
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
|
||||
|
||||
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
|
||||
urls: Schema.Array(Schema.String),
|
||||
@@ -44,6 +47,7 @@ export const DesktopEvent = Schema.Union([
|
||||
MenuCommandTriggered,
|
||||
UpdaterStateChanged,
|
||||
WslServersChanged,
|
||||
SshChanged,
|
||||
WindowFullscreenChanged,
|
||||
WindowPinchZoomChanged,
|
||||
WindowZoomChanged,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { SshHttp, SshStart, SshState } from "@opencode-ai/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("SshForget", { payload: { id: Schema.String } }),
|
||||
Rpc.make("SshOpenConfig"),
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
const Context = createContext<Platform>(value)
|
||||
|
||||
export function PlatformProvider(props: ParentProps<{ value: Platform }>) {
|
||||
return createComponent(Context.Provider, {
|
||||
value: props.value,
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlatform() {
|
||||
return value
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user