Compare commits

..
154 changed files with 3135 additions and 5039 deletions
@@ -1,67 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--settings-reconnect")
await component.getByRole("button", { name: "More options" }).click()
await page.getByRole("menuitem", { name: "Connect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toBeVisible()
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue" }).click()
await expect(dialog.getByRole("textbox", { name: "Verification code:" })).toBeVisible()
await dialog.getByRole("textbox").fill("123456")
await dialog.getByRole("button", { name: "Continue" }).click()
await expect(dialog).toHaveCount(0)
await expect(component.getByRole("button", { name: "Authenticate", exact: true })).toHaveCount(0)
})
story("cancelling a version mismatch permits reconnecting again", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
story("adding a server keeps all SSH challenges in the original connection dialog", async ({ mount, page }) => {
await mount("app-dialog-ssh--host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog).toHaveCount(1)
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeFocused()
await dialog.getByRole("button", { name: "Trust and connect" }).click()
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("button", { name: "Update and reconnect", exact: true }).click()
await expect(dialog).toHaveCount(1)
await dialog.getByRole("textbox").fill("fixture-password")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await dialog.getByRole("textbox", { name: "Verification code:" }).fill("123456")
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(component.getByText("Session connected")).toBeVisible()
})
story("key-based reconnect completes without opening an authentication dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--key-reconnect")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(component.getByRole("button", { name: "Connecting to SSH server" })).toBeDisabled()
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(component.getByText("Session connected")).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
})
@@ -1,6 +1,25 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -342,6 +342,7 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -602,6 +602,7 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
+1
View File
@@ -39,6 +39,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
+1
View File
@@ -207,6 +207,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
-1
View File
@@ -10,7 +10,6 @@
"./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"
},
+1 -6
View File
@@ -17,8 +17,6 @@ import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { SshProvider } from "@/servers/ssh/context"
import { SshRestore } from "@/servers/ssh/restore"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
@@ -83,9 +81,7 @@ export function AppBaseProviders(
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<SshProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</SshProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
@@ -113,7 +109,6 @@ export function AppInterface(props: {
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
@@ -37,7 +37,6 @@ export type ComposerEditorView = {
agent?: ComposerSelectControl
variant?: ComposerSelectControl
submit: {
available?: Accessor<boolean>
stopping: Accessor<boolean>
working?: Accessor<boolean>
queue?: ComposerQueue
@@ -334,7 +333,6 @@ 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") {
@@ -367,7 +365,6 @@ 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" })
+1 -5
View File
@@ -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, useServer } from "@/runtime/server/current"
import { useData } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
@@ -30,8 +30,6 @@ 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()
@@ -396,12 +394,10 @@ 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
+68 -38
View File
@@ -1,4 +1,5 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -9,6 +10,7 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -31,6 +33,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -43,15 +46,31 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { model?: ModelKey; variant?: string } | undefined
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -62,7 +81,8 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -74,7 +94,9 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent: recentModels,
list: models.list,
@@ -83,19 +105,24 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -104,38 +131,41 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
},
selected() {
return prompt.model.current()?.variant
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
},
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const variants = this.list()
@@ -143,13 +173,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
},
} satisfies ModelSelection
}
return selection
return selection satisfies ModelSelection
}
+152 -6
View File
@@ -91,6 +91,8 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -99,12 +101,16 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -122,6 +128,146 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+38 -23
View File
@@ -11,6 +11,7 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -88,7 +89,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -124,9 +125,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -315,8 +319,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -328,7 +334,33 @@ async function sendCommand(
})
}
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -336,24 +368,7 @@ async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
await applySelection(session, value.selection, track)
}
const admission = {
-1
View File
@@ -20,5 +20,4 @@ export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { useSsh } from "./servers/ssh/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
+9 -26
View File
@@ -15,7 +15,6 @@ import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { useSshAuthenticate } from "@/servers/ssh/authenticate"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -29,7 +28,6 @@ export function createHomeProjectsController(home: HomeController) {
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = useSshAuthenticate()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
@@ -44,15 +42,6 @@ export function createHomeProjectsController(home: HomeController) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function choose(conn: ServerConnection.Any) {
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
})
}
return {
copy: {
language,
@@ -82,25 +71,15 @@ export function createHomeProjectsController(home: HomeController) {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => home.selection.focusServer(conn))) return
home.selection.focusServer(conn)
},
focus: home.selection.focusServer,
},
project: {
list: home.project.list,
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.select(conn, directory))) return
home.project.select(conn, directory)
},
select: home.project.select,
add: home.project.add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
home.project.openProjectNewSession(conn, directory)
},
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
@@ -146,9 +125,13 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, () => choose(conn))) return
if (home.server.health(conn)?.healthy === false) return
choose(conn)
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)),
})
},
close: (conn: ServerConnection.Any, directory: string) => {
const next = closeHomeProject(
@@ -26,7 +26,6 @@ export function HomeProjects(props: {
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
onFocusServer={props.projects.server.focus}
onAuthenticateServer={props.projects.server.authenticate}
onToggleCollapsed={props.projects.server.toggleCollapsed}
onEditServer={props.projects.server.edit}
onSetDefaultServer={props.projects.server.setDefault}
-59
View File
@@ -1,59 +0,0 @@
/* Home server/project rows keep the label underneath the hover actions.
The actions carry a tab-style background with a fade on the left, and the
label fades out where it slides underneath. Mirrors tab-nav.css. */
[data-home-row] {
--home-row-surface: var(--v2-background-bg-base);
--home-row-background: color-mix(
in srgb,
var(--home-row-surface) var(--home-row-opacity, 100%),
var(--v2-background-bg-base)
);
background: var(--home-row-background);
}
[data-home-row]:is(:hover, :focus-within, [data-dragging="true"], :has([data-menu="true"])) {
--home-row-surface: var(--v2-background-bg-layer-01);
}
[data-home-row][data-selected] {
--home-row-surface: var(--v2-background-bg-layer-03);
}
[data-home-row][data-dimmed="true"] {
--home-row-opacity: 60%;
}
/* Keep the background outside the button's disabled-content opacity. */
[data-home-row] > button {
background: transparent;
}
[data-home-row] [data-slot="home-row-actions"] {
background: linear-gradient(to right, transparent, var(--home-row-background) 8px);
}
[data-home-row]:dir(rtl) [data-slot="home-row-actions"] {
background: linear-gradient(to left, transparent, var(--home-row-background) 8px);
}
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
[data-home-row]:is(:hover, :focus-within, :has([data-menu="true"])):dir(rtl) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
@media (hover: none) {
[data-home-row] [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to right, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
[data-home-row]:dir(rtl) [data-slot="home-row-label"] {
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
mask-image: linear-gradient(to left, black 0, black calc(100% - 44px), transparent calc(100% - 36px));
}
}
+16 -74
View File
@@ -10,8 +10,6 @@ import { ScrollView } from "@opencode/ui/scroll-view"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
@@ -23,7 +21,6 @@ import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu
import { ServerHealthIndicator } from "@/servers/registry/row"
import { type ServerHealth } from "@/runtime/server/health"
import { fileManagerApp } from "@/home/projects/file-manager"
import "./view.css"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
@@ -49,7 +46,6 @@ export type HomeProjectsViewProps = {
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
onAuthenticateServer?: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
@@ -126,10 +122,6 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
props.onFocusServer(server)
setState("open", false)
}}
onAuthenticateServer={(server) => {
setState("open", false)
props.onAuthenticateServer?.(server)
}}
onChooseProject={(server) => {
setState("open", false)
props.onChooseProject(server)
@@ -204,12 +196,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
</HomeProjectNavButton>
</Show>
<Show
when={
props.servers.length > 1 ||
props.servers.some(
(server) => server.type === "ssh" && (server.authenticationRequired || server.connecting),
)
}
when={props.servers.length > 1}
fallback={
<Show when={props.servers[0]}>
{(server) => (
@@ -244,8 +231,6 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
const authentication = () => item.type === "ssh" && item.authenticationRequired
const connecting = () => item.type === "ssh" && item.connecting
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
@@ -256,26 +241,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={authentication() || connecting()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<div class="px-1.5 py-1">
<Button
data-action="home-server-authenticate"
class="w-full"
size="small"
variant="neutral"
disabled={connecting()}
aria-busy={!!connecting()}
onClick={() => props.onAuthenticateServer?.(item)}
>
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
</Button>
</div>
</Show>
<Show when={healthy() && !authentication() && !connecting() && hasProjects() && !collapsed()}>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} {...contextMenuProps} server={item} items={projects()} />
</Show>
@@ -348,7 +314,6 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const authentication = () => props.server.type === "ssh" && props.server.authenticationRequired
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
@@ -361,23 +326,16 @@ function HomeServerRow(props: {
appearance="standard"
placement="top"
class="flex h-7 w-full min-w-0"
inactive={!incompatible() && !authentication()}
value={
authentication()
? props.language.t("ssh.stage.authentication")
: props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })
}
inactive={!incompatible()}
value={props.language.t("server.row.incompatible", { version: props.health?.version ?? "1" })}
>
<div
class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]"
data-home-row
data-dimmed={!healthy() && !incompatible()}
data-selected={props.selected ? "" : undefined}
>
<div class="group/server relative flex h-7 w-full min-w-0 items-center rounded-[6px]">
<HomeProjectNavButton
type="button"
class="pr-16"
classList={{ "opacity-60": !healthy() && !incompatible() }}
data-selected={props.selected ? "" : undefined}
disabled={!healthy() && !authentication()}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
@@ -411,18 +369,10 @@ function HomeServerRow(props: {
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator
health={props.health}
connecting={props.server.type === "ssh" && props.server.connecting}
authenticationRequired={authentication()}
/>
<ServerHealthIndicator health={props.health} />
</div>
<span
data-slot="home-row-label"
class="flex min-w-0 flex-1 items-center gap-1"
classList={{ "opacity-60": !healthy() && !incompatible() }}
>
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>
{props.server.displayName ?? new URL(props.server.http.url).host}
</span>
<Show when={props.server.label}>
@@ -440,9 +390,8 @@ function HomeServerRow(props: {
</span>
</HomeProjectNavButton>
<div
data-slot="home-row-actions"
class={`
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
@@ -469,7 +418,7 @@ function HomeServerRow(props: {
size="small"
icon={<Icon name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false && !authentication()}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</Tooltip>
@@ -653,10 +602,6 @@ function HomeProjectRow(
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
data-home-row
data-dimmed={serverUnreachable()}
data-dragging={sortable.isDragSource()}
data-selected={props.selected ? "" : undefined}
onContextMenu={(event) => {
event.preventDefault()
props.onSetContextMenuOpen(contextMenuID(), true)
@@ -665,7 +610,7 @@ function HomeProjectRow(
<HomeProjectNavButton
type="button"
data-component="home-project-row"
class="disabled:opacity-60"
class="pr-16 disabled:opacity-60"
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
}}
@@ -702,14 +647,11 @@ function HomeProjectRow(
}}
>
<HomeProjectAvatar project={props.project} />
<span data-slot="home-row-label" class={HOME_PROJECT_NAV_LABEL}>
{displayName(props.project)}
</span>
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</HomeProjectNavButton>
<div
data-slot="home-row-actions"
class={`
hover-reveal absolute bottom-0 right-1 top-0 flex items-center gap-1 rounded-r-[6px] pl-2
hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1
group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100
`}
data-menu={props.contextMenuOpen(contextMenuID())}
@@ -110,6 +110,7 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
@@ -0,0 +1,28 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
setStore("variant", { [key]: value ?? "default" })
return
}
setStore("variant", key, value)
setStore("variant", key, value ?? "default")
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
+144 -83
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,6 +16,7 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -24,11 +25,15 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -78,6 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -98,16 +104,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -176,13 +175,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -192,25 +195,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
})
},
move(direction: 1 | -1) {
@@ -230,8 +232,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -243,26 +252,41 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
}
const selected = () => scope()?.variant
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const snapshot = () => {
const model = current()
const selected = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...scope(),
agent: agent.current()?.name,
...next,
} satisfies State
@@ -274,22 +298,59 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const model = {
ready: models.ready,
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -298,21 +359,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
},
visible(item: ModelKey) {
return models.visible(item)
@@ -324,16 +382,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -341,21 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const items = this.list()
@@ -363,8 +409,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
@@ -383,20 +429,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
handoff.set(key, committed)
if (dir === sdk().directory) {
setSaved("session", session, next)
setSaved("session", session, committed)
}
setStore("promoting", next)
setStore("promoting", committed)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -412,4 +472,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
test("cycles from configured last variant to default", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
expect(value).toBeUndefined()
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,4 +83,22 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+5 -17
View File
@@ -16,6 +16,7 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -24,29 +25,16 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
}
-48
View File
@@ -2,54 +2,6 @@ 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.action.authenticate": "Authenticate",
"ssh.session.disconnected": "SSH connection inactive",
"ssh.session.connecting": "Connecting to SSH server",
"ssh.session.reconnectDescription":
"Reconnect to view this session and continue working. Your remote session is preserved.",
"ssh.session.reconnect": "Reconnect",
"ssh.authenticationRequired": "Authentication required for {{host}}",
"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 arent 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 hosts 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",
@@ -230,3 +230,72 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+84 -21
View File
@@ -28,23 +28,77 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
const url = URL.createObjectURL(blob)
urls.set(id, url)
held.set(id, blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -60,24 +114,25 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver): DraftStore {
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = urls.get(id)
if (existing) return existing
const existing = retained.get(id)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
return { id, url: blobUrl(id, blob, grace) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -145,8 +200,8 @@ export function createDraftStore(driver: Driver): DraftStore {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -192,8 +247,7 @@ export function createDraftStore(driver: Driver): DraftStore {
remember(chunks, next, source.chunk)
return
}
held.set(next, blob)
blobUrl(next, blob)
blobUrl(next, blob, grace)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -225,14 +279,19 @@ export function createDraftStore(driver: Driver): DraftStore {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) return
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
}
return {
getItem: async (key) => {
@@ -241,6 +300,8 @@ export function createDraftStore(driver: Driver): DraftStore {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
@@ -248,6 +309,7 @@ export function createDraftStore(driver: Driver): DraftStore {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -360,7 +422,8 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -4,7 +4,6 @@ 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"
import type { BrowserPanePlatform } from "./browser-pane"
@@ -87,7 +86,6 @@ type PlatformBase = {
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform
sshServers?: SshPlatform
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
+2 -12
View File
@@ -1,7 +1,7 @@
import type { OpenCodeEvent } from "@opencode/client/promise"
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, on, onCleanup } from "solid-js"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
@@ -74,18 +74,8 @@ 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 === "ssh" || (server.type === "sidecar" && server.variant === "base") ? server.reconnect : undefined
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
const connection = createClientConnection(transport.api, {
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
+8 -56
View File
@@ -5,7 +5,7 @@ import { ClientError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean }
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -142,73 +142,25 @@ export function useCheckServerHealth() {
}
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
return createServerHealth(servers, enabled, useCheckServerHealth())
}
export function createServerHealth(
servers: Accessor<ServerConnection.Any[]>,
enabled: Accessor<boolean>,
check: (http: ServerConnection.HttpBase) => Promise<ServerHealth>,
) {
const checkServerHealth = useCheckServerHealth()
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
}
// Snapshot transport fields synchronously so a newly established SSH tunnel
// invalidates both the old result and any probe still using the old endpoint.
const list = servers().map((conn) => ({
key: ServerConnection.key(conn),
type: conn.type,
http: conn.http,
stage: conn.type === "ssh" ? conn.stage : undefined,
}))
for (const conn of list) {
if (conn.stage && conn.stage !== "ready") {
endpoints.delete(conn.key)
setStatus(
conn.key,
reconcile(
conn.stage === "failed"
? { healthy: false }
: conn.stage === "incompatible"
? { healthy: false, incompatible: true }
: undefined,
),
)
continue
}
const endpoint = cacheKey(conn.http)
if (conn.type === "ssh" && endpoints.get(conn.key) !== endpoint) {
setStatus(conn.key, reconcile({ healthy: false, checking: true }))
}
endpoints.set(conn.key, endpoint)
}
for (const key of endpoints.keys()) {
if (!list.some((conn) => conn.key === key)) endpoints.delete(key)
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth | undefined> = {}
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
if (conn.stage && conn.stage !== "ready") {
results[conn.key] =
conn.stage === "failed"
? { healthy: false }
: conn.stage === "incompatible"
? { healthy: false, incompatible: true }
: undefined
return
}
const result = await check(conn.http)
results[conn.key] = result
if (!dead) setStatus(conn.key, reconcile(result))
const key = ServerConnection.key(conn)
const result = await checkServerHealth(conn.http)
results[key] = result
if (!dead) setStatus(key, result)
}),
)
if (dead) return
+1 -8
View File
@@ -5,7 +5,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import { pathKey } from "@/workspaces/path-key"
import { ServerScope } from "@/runtime/server/scope"
import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistence"
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
@@ -25,7 +24,6 @@ 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(/\/+$/, "")
}
@@ -161,14 +159,9 @@ export namespace ServerConnection {
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
stage?: SshItem["stage"]
connecting?: boolean
authenticationRequired?: boolean
id?: string
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
reconnect?: (signal: AbortSignal) => Promise<HttpBase>
} & Base
export type Any =
@@ -185,7 +178,7 @@ export namespace ServerConnection {
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.id ?? conn.host}`)
return Key.make(`ssh:${conn.host}`)
}
}
@@ -8,7 +8,6 @@ import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { type ServerHealth } from "@/runtime/server/health"
import { showToast } from "@/shell/notifications/toast"
import { useSsh } from "../ssh/context"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
@@ -51,7 +50,6 @@ function useDefaultServer() {
export function useServerActionsController() {
const server = useServers()
const ssh = useSsh()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -60,7 +58,6 @@ export function useServerActionsController() {
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
if (key.startsWith("ssh:")) await ssh.forget(key.slice(4))
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
@@ -5,7 +5,6 @@ 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
@@ -16,7 +15,6 @@ 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}
@@ -1,44 +0,0 @@
import { For } from "solid-js"
import { ServerHealthIndicator } from "./row"
import type { ServerHealth } from "@/runtime/server/health"
const states: { label: string; connecting?: boolean; authenticationRequired?: boolean; health?: ServerHealth }[] = [
{
label: "Authentication required (overrides failed health)",
authenticationRequired: true,
health: { healthy: false },
},
{
label: "Authentication required (overrides pending health)",
authenticationRequired: true,
health: { healthy: false, checking: true },
},
{ 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}
authenticationRequired={state.authenticationRequired}
/>
</div>
<span>{state.label}</span>
</div>
)}
</For>
</div>
),
}
+11 -44
View File
@@ -1,7 +1,5 @@
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Spinner } from "@opencode/ui/spinner"
import { useLanguage } from "@/runtime/i18n/language"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
@@ -104,53 +102,22 @@ export function ServerRow(props: ServerRowProps) {
)
}
export function ServerHealthIndicator(props: {
health?: ServerHealth
connecting?: boolean
authenticationRequired?: boolean
}) {
const language = useLanguage()
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
return (
<Show
when={props.authenticationRequired}
when={props.health?.incompatible}
fallback={
<Show
when={props.connecting || props.health?.checking}
fallback={
<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>
}
>
<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>
<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,
}}
/>
}
>
<span
role="status"
aria-label={language.t("ssh.stage.authentication")}
class="inline-flex h-3.5 w-1.5 shrink-0 items-center justify-center text-v2-icon-icon-muted"
>
<Icon name="lock" size="small" class="shrink-0" />
</span>
<Icon name="warning" size="small" class="shrink-0 text-icon-warning-base" />
</Show>
)
}
@@ -1,13 +0,0 @@
import { useSsh } from "./context"
import type { ServerConnection } from "@/runtime/server/registry"
export function useSshAuthenticate() {
const ssh = useSsh()
return (server: ServerConnection.Any, onConnected?: () => void) => {
if (server.type !== "ssh" || !server.authenticationRequired) return false
const item = ssh.servers.find((item) => item.config.id === server.id)
if (!item) return false
ssh.connect(item.config, { onConnected })
return true
}
}
@@ -1,25 +0,0 @@
import { createEffect } from "solid-js"
import type { SshItem } from "./types"
// Offer authentication once per selected tab. Cancelling must not immediately
// reopen the prompt; background hosts never open a dialog here.
export function createSshAuthentication(input: {
selection: () => string | undefined
item: () => SshItem | undefined
busy: () => boolean
open: (item: SshItem) => void
}) {
const state = { selection: undefined as string | undefined, offered: false }
createEffect(() => {
const selection = input.selection()
if (state.selection !== selection) {
state.selection = selection
state.offered = false
}
const item = input.item()
if (!selection || state.offered || item?.stage !== "authentication" || item.authenticatingElsewhere || input.busy())
return
state.offered = true
input.open(item)
})
}
@@ -1,59 +0,0 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useCurrentRoute } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useSsh } from "./context"
import { createSshAuthentication } from "./authentication-state"
import { SshConnectionPanel } from "./connection-panel"
export function SshAuthentication(props: ParentProps) {
const ssh = useSsh()
const route = useCurrentRoute()
const tabs = useTabs()
const dialog = useDialog()
const item = createMemo(() => {
const current = route()
const key =
current.type === "session"
? current.server
: current.type === "draft"
? tabs.store.find((tab) => tab.type === "draft" && tab.draftID === current.draftID)?.server
: undefined
return ssh.servers.find((item) => `ssh:${item.config.id}` === key && item.stage !== "ready")
})
createSshAuthentication({
selection: () => {
const current = route()
if (current.type === "session") return `${current.server}:${current.sessionId}`
if (current.type === "draft") return current.draftID
return undefined
},
item,
busy: () => !!dialog.active,
open: (item) => ssh.connect(item.config),
})
return (
<div class="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Keep the route mounted so reconnecting preserves its draft and local UI state. */}
<div
class="flex size-full min-h-0 min-w-0 flex-col"
classList={{ invisible: !!item() }}
inert={!!item()}
aria-hidden={item() ? true : undefined}
>
{props.children}
</div>
<Show when={item()}>
{(item) => (
<div class="absolute inset-0">
<SshConnectionPanel
item={item()}
pending={ssh.pending(item().config.id)}
onReconnect={() => ssh.connect(item().config)}
/>
</div>
)}
</Show>
</div>
)
}
@@ -1,47 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Spinner } from "@opencode/ui/spinner"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
import type { SshItem } from "./types"
export function SshConnectionPanel(props: { item: SshItem; pending?: boolean; onReconnect: () => void }) {
const language = useLanguage()
const connecting = () => props.pending || isSshConnecting(props.item.stage)
return (
<section
data-component="ssh-connection-panel"
class="flex h-full min-h-0 flex-col items-center justify-center gap-4 overflow-y-auto bg-v2-background-bg-base px-6 py-8 text-center"
>
<Icon name="lock" size="large" class="text-v2-icon-icon-muted" />
<div class="flex max-w-sm flex-col items-center gap-2" role="status" aria-live="polite">
<h2 class="text-16-medium text-v2-text-text-base">{language.t("ssh.session.disconnected")}</h2>
<bdi dir="auto" class="max-w-full break-all text-13-regular text-v2-text-text-muted">
{sshName(props.item.config)}
</bdi>
<p class="text-13-regular text-v2-text-text-muted">{language.t("ssh.session.reconnectDescription")}</p>
</div>
<Show when={props.item.error}>
{(error) => (
<p role="alert" class="max-w-sm text-13-regular text-v2-text-text-muted">
{language.t(`ssh.error.${error()}`)}
</p>
)}
</Show>
<Button variant="neutral" disabled={connecting()} aria-busy={connecting()} onClick={props.onReconnect}>
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{language.t(
connecting()
? "ssh.session.connecting"
: props.item.stage === "authentication"
? "ssh.action.authenticate"
: "ssh.session.reconnect",
)}
</Button>
</section>
)
}
-69
View File
@@ -1,69 +0,0 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup, untrack, type ParentProps } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { createSshController } from "./controller"
import { DialogSsh } from "./dialog"
import type { SshState } from "./types"
const key = ["platform", "sshServers"] as const
const context = createSimpleContext({
name: "Ssh",
init: () => {
const platform = usePlatform()
const client = useQueryClient()
const language = useLanguage()
const query = useQuery(() =>
queryOptions<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 {
get servers() {
return query.data?.servers ?? []
},
get loading() {
return query.isLoading
},
...createSshController({
items: () => query.data?.servers ?? [],
api: platform.sshServers,
refresh: () => query.refetch({ throwOnError: true }),
error: () => showToast({ variant: "error", title: language.t("common.requestFailed") }),
}),
}
},
})
export const useSsh = () => context.use()
export function SshProvider(props: ParentProps) {
return (
<context.provider>
<SshDialogs />
{props.children}
</context.provider>
)
}
function SshDialogs() {
const ssh = useSsh()
// Capture an owner inside the SSH context, independent of transient rows and menus.
const dialog = useDialog()
createEffect(() => {
const item = ssh.dialog.next()
if (!item || dialog.active) return
ssh.dialog.opened(item.config.id)
untrack(() => void dialog.push(() => <DialogSsh config={item.config} promptOnly />))
})
return null
}
-153
View File
@@ -1,153 +0,0 @@
import { Effect, Fiber } from "effect"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { SshConfig, SshItem, SshPlatform } from "./types"
import { isSshConnecting } from "./status"
export function createSshController(input: {
items: () => readonly SshItem[]
api: Pick<SshPlatform, "start" | "respond" | "cancel" | "disconnect" | "forget"> | undefined
refresh: () => Promise<unknown>
error: () => void
}) {
const [attempts, setAttempts] = createStore<
Record<
string,
| {
active: boolean
submitting: boolean
prompted: boolean
answered?: string
error: boolean
onConnected?: () => void
}
| undefined
>
>({})
const tasks = new Map<string, Fiber.Fiber<void>>()
const item = (id: string) => input.items().find((item) => item.config.id === id)
const settle = (id: string) => {
const attempt = attempts[id]
if (!attempt?.active) return
const onConnected = attempt.onConnected
setAttempts(id, { active: false, onConnected: undefined })
if (item(id)?.stage === "ready" && onConnected) queueMicrotask(onConnected)
}
const run = (id: string, effect: Effect.Effect<unknown, unknown>) => {
setAttempts(id, { submitting: true, error: false })
tasks.set(
id,
Effect.runFork(
effect.pipe(
Effect.asVoid,
Effect.catch(() =>
Effect.sync(() => {
setAttempts(id, "error", true)
if (!attempts[id]?.prompted) input.error()
}),
),
Effect.ensuring(
Effect.sync(() => {
tasks.delete(id)
setAttempts(id, "submitting", false)
}),
),
),
),
)
}
onCleanup(() => {
Effect.runFork(Effect.forEach([...tasks.values()], Fiber.interrupt, { discard: true }))
})
createEffect(() => {
for (const item of input.items()) {
const attempt = attempts[item.config.id]
if (!attempt?.active || attempt.submitting) continue
if (
item.stage === "ready" ||
item.stage === "failed" ||
item.stage === "disconnected" ||
item.authenticatingElsewhere ||
(attempt.prompted && item.stage === "authentication" && !item.prompt)
) {
settle(item.config.id)
}
}
})
return {
item,
submitting: (id: string) => !!attempts[id]?.submitting,
error: (id: string) => !!attempts[id]?.error,
answered: (id: string) =>
!!item(id)?.prompt && !attempts[id]?.error && attempts[id]?.answered === item(id)?.prompt?.id,
pending: (id: string) =>
!!attempts[id]?.submitting ||
!!item(id)?.authenticatingElsewhere ||
isSshConnecting(item(id)?.stage ?? "disconnected"),
dialog: {
next: () =>
input.items().find((item) => {
const attempt = attempts[item.config.id]
return (
attempt?.active &&
!attempt.submitting &&
!attempt.prompted &&
!attempt.error &&
(item.prompt || item.stage === "incompatible")
)
}),
opened: (id: string) => setAttempts(id, "prompted", true),
},
connect: (config: SshConfig, options?: { dialog?: boolean; replace?: boolean; onConnected?: () => void }) => {
const api = input.api
if (!api || item(config.id)?.authenticatingElsewhere) return
if (
attempts[config.id]?.submitting ||
(attempts[config.id]?.active && !attempts[config.id]?.error && !options?.replace)
)
return
setAttempts(config.id, {
active: true,
submitting: true,
prompted: !!options?.dialog,
answered: undefined,
error: false,
onConnected: options?.onConnected ?? (options?.replace ? attempts[config.id]?.onConnected : undefined),
})
run(
config.id,
Effect.gen(function* () {
yield* Effect.tryPromise(() => api.start({ ...config, replace: options?.replace }))
// Observe admission before treating an older disconnected snapshot as cancellation.
yield* Effect.tryPromise(input.refresh)
}),
)
},
respond: (id: string, prompt: string, value: string) => {
const api = input.api
if (!api || item(id)?.prompt?.id !== prompt || attempts[id]?.submitting) return
if (attempts[id]?.answered === prompt && !attempts[id]?.error) return
setAttempts(id, "answered", prompt)
run(
id,
Effect.tryPromise(() => api.respond(id, prompt, value)),
)
},
cancel: (id: string) => {
const task = tasks.get(id)
const api = input.api
Effect.runFork(
Effect.gen(function* () {
if (task) yield* Fiber.interrupt(task)
setAttempts(id, undefined)
if (!api) return
yield* Effect.tryPromise(() => api.cancel(id))
if (!item(id)?.saved) yield* Effect.tryPromise(() => api.forget(id))
}).pipe(Effect.ignore),
)
},
restore: (config: SshConfig) => input.api?.start({ ...config, background: true }),
disconnect: (id: string) => input.api?.disconnect(id),
forget: (id: string) => input.api?.forget(id),
}
}
@@ -1,289 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { onCleanup, onMount, Show } from "solid-js"
import { PlatformProvider } from "@/runtime/platform/platform"
import { SshProvider, useSsh } from "./context"
import { useSshAuthenticate } from "./authenticate"
import { HomeProjectsView } from "@/home/projects/view"
import { ServerConnection } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { DialogSsh } from "./dialog"
import type { SshItem, SshPlatform, SshState } from "./types"
import { SshConnectionPanel } from "./connection-panel"
import { SshServerSettings } from "./settings"
function Fixture(props: {
session?: boolean
settings?: boolean
incompatible?: boolean
keyOnly?: boolean
connectionDelay?: number
initial?: "connecting" | "password" | "confirmation" | "failure" | "required"
responseDelay?: number
}) {
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
step: 0,
item:
props.initial === "required"
? {
config: { id: "story", target: "ssh linuxbook", name: "" },
stage: props.session || props.settings ? "disconnected" : "authentication",
saved: true,
detail: "",
}
: undefined,
}
onCleanup(() => clearTimeout(state.timer))
const listeners = new Set<(state: SshState) => void>()
const snapshot = (): SshState => ({ servers: state.item ? [state.item] : [] })
const update = (changes: Partial<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.before = state.item
state.step = props.initial === "password" || props.initial === "required" ? 1 : 0
state.item = {
config: input,
saved: props.initial === "required",
stage: "connecting",
detail: "",
destination: "brendon@dev.example.com:22",
}
if (props.initial === "connecting") return
if (props.incompatible && !input.replace) {
update({ stage: "incompatible", error: "version" })
return
}
if (props.connectionDelay) {
update({ stage: "connecting" })
state.timer = setTimeout(
() => update(props.keyOnly ? { stage: "ready" } : { stage: "authentication", prompt: prompts[state.step] }),
props.connectionDelay,
)
return
}
update(
props.initial === "failure"
? {
stage: "failed",
error: "connection",
detail: "ssh: connect to host dev.example.com port 22: Connection refused",
}
: { stage: "authentication", prompt: prompts[state.step] },
)
},
respond: async () => {
const next = () => {
state.step += 1
update(
state.step < prompts.length
? { stage: "authentication", prompt: prompts[state.step] }
: { stage: "ready", prompt: undefined },
)
}
if (!props.responseDelay) return next()
update({ stage: "connecting", prompt: undefined })
state.timer = setTimeout(next, props.responseDelay)
},
resolve: async () => null,
disconnect: async () => {
clearTimeout(state.timer)
update({ stage: "disconnected", prompt: undefined })
},
cancel: async () => {
if (state.item?.stage === "incompatible") return
clearTimeout(state.timer)
update({ ...state.before, stage: state.before?.stage ?? "disconnected", prompt: undefined })
},
forget: async () => {
state.item = undefined
listeners.forEach((listener) => listener(snapshot()))
},
openConfig: async () => {},
}
return (
<PlatformProvider
value={{
platform: "desktop",
windowID: "ssh-story",
sshServers: api,
openExternal() {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
}}
>
<QueryClientProvider client={new QueryClient()}>
<SshProvider>
{props.settings ? (
<AuthenticationSettings />
) : props.session ? (
<AuthenticationSession />
) : props.initial === "required" ? (
<AuthenticationHome />
) : (
<Open initial={props.initial} />
)}
</SshProvider>
</QueryClientProvider>
</PlatformProvider>
)
}
function AuthenticationSettings() {
return (
<div class="settings-servers" style={{ width: "min(100%, 480px)" }}>
<SshServerSettings
filter=""
domain={{
collection: { items: () => [], health: () => ({}) },
defaults: { available: () => false, key: () => null, set: async () => {} },
connection: {
canRemove: () => false,
remove: async () => {},
canHide: () => false,
isHidden: () => false,
setHidden: () => {},
},
}}
/>
</div>
)
}
function AuthenticationSession() {
const ssh = useSsh()
return (
<div style={{ height: "70vh" }}>
<Show when={ssh.servers[0]}>
{(item) => (
<Show when={item().stage !== "ready"} fallback={<div>Session connected</div>}>
<SshConnectionPanel
item={item()}
pending={ssh.pending(item().config.id)}
onReconnect={() => ssh.connect(item().config)}
/>
</Show>
)}
</Show>
</div>
)
}
function AuthenticationHome() {
const language = useLanguage()
const ssh = useSsh()
const authenticate = useSshAuthenticate()
const server: ServerConnection.Ssh = {
type: "ssh",
id: "story",
host: "linuxbook",
displayName: "linuxbook",
label: "SSH",
http: { url: "http://127.0.0.1:0" },
get authenticationRequired() {
return ssh.servers[0]?.stage === "authentication"
},
get connecting() {
return ssh.servers[0]?.stage === "connecting"
},
}
const projects = [{ worktree: "/home/user/project", expanded: true }]
return (
<div style={{ width: "min(100%, 340px)" }}>
<HomeProjectsView
dropdown
language={language}
servers={[server]}
projects={projects}
recentlyClosed={[]}
selection={{ server: ServerConnection.key(server) }}
homedir="/home/user"
serverHealth={() => ({ healthy: ssh.servers[0]?.stage === "ready" })}
projectsForServer={() => projects}
collapsed={() => false}
canDefaultServer={false}
defaultServerKey={null}
canRevealProject={() => false}
unseenCount={() => 0}
onWheel={() => {}}
onChooseProject={(server) => {
authenticate(server)
}}
onFocusServer={(server) => {
authenticate(server)
}}
onAuthenticateServer={(server) => {
authenticate(server)
}}
onToggleCollapsed={() => {}}
onEditServer={() => {}}
onSetDefaultServer={() => {}}
canRemoveServer={() => false}
onRemoveServer={() => {}}
canHideServer={() => false}
onHideServer={() => {}}
onMoveProject={() => {}}
onSelectProject={() => {}}
onAddProjects={() => {}}
onOpenProjectNewSession={() => {}}
canImportSession={false}
onImportSession={() => {}}
onEditProject={() => {}}
onRevealProject={() => {}}
onClearNotifications={() => {}}
onCloseProject={() => {}}
onOpenSettings={() => {}}
onOpenHelp={() => {}}
/>
</div>
)
}
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 AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
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" /> }
-246
View File
@@ -1,246 +0,0 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { Divider } from "@opencode/ui/divider"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useSsh } from "./context"
import type { SshConfig, SshItem } from "./types"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
import "@/settings/settings.css"
import "./ssh.css"
export function useOpenSshProject() {
const servers = useServers()
const picker = useDirectoryPicker()
const tabs = useTabs()
const language = useLanguage()
return (id: string) => {
const server = servers.list.find((server) => server.type === "ssh" && server.id === id)
if (!server) return
picker({
server,
title: language.t("ssh.project", { host: server.displayName || (server.type === "ssh" ? server.host : "") }),
onSelect: (value) => {
const directory = Array.isArray(value) ? value[0] : value
if (!directory) return
const key = ServerConnection.key(server)
servers.projects.forServer(key).open(directory)
void tabs.newDraft({ server: key, directory })
},
})
}
}
export function DialogSsh(props: {
config?: SshConfig
connect?: boolean
promptOnly?: boolean
openProject?: boolean
onConnected?: () => void
}) {
const dialog = useDialog()
const language = useLanguage()
const ssh = useSsh()
// Entry-point behavior is fixed for the lifetime of this dialog. Settings
// connects without needing the project/tab contexts used by the palette.
const openProject = props.openProject ? useOpenSshProject() : undefined
const id = props.config?.id ?? crypto.randomUUID()
let cancelButton: HTMLButtonElement | undefined
const [state, setState] = createStore({
target: props.config?.target ?? "",
name: props.config?.name ?? "",
started: !!props.promptOnly,
prompted: !!props.promptOnly,
response: "",
complete: false,
})
const item = createMemo(() => ssh.item(id))
const error = createMemo(() => {
if (ssh.error(id)) return language.t("common.requestFailed")
const error = item()?.error
return error ? language.t(`ssh.error.${error}`) : undefined
})
const busy = createMemo(
() => ssh.submitting(id) || (state.started && !ssh.error(id) && isSshConnecting(item()?.stage ?? "connecting")),
)
const prompt = createMemo<SshItem["prompt"]>((previous) => item()?.prompt ?? (busy() ? previous : undefined))
const waiting = () => busy() || ssh.answered(id)
const start = (replace = false) => {
if (busy() || !state.target.trim()) return
setState({ started: true, prompted: !!prompt() })
ssh.connect({ id, target: state.target, name: state.name }, { dialog: true, replace })
}
const respond = () => {
const current = item()?.prompt
if (!current || waiting() || (!current.confirm && !state.response)) return
ssh.respond(id, current.id, current.confirm ? "yes" : state.response)
}
createEffect(() => {
prompt()?.id
setState("response", "")
if (prompt()) setState("prompted", true)
// Never let a focused Continue button become Trust between SSH challenges.
if (prompt()?.confirm) queueMicrotask(() => cancelButton?.focus())
})
createEffect(() => {
if (!state.started || item()?.stage !== "ready" || ssh.submitting(id) || state.complete) return
setState("complete", true)
dialog.close()
if (openProject) queueMicrotask(() => openProject(id))
if (props.onConnected) queueMicrotask(props.onConnected)
})
onMount(() => {
if (props.connect) start()
})
onCleanup(() => {
if (state.started && !state.complete) ssh.cancel(id)
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
if (prompt()) return
start(item()?.stage === "incompatible")
}
return (
<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={!props.promptOnly && (!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>
)
}
-56
View File
@@ -1,56 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { ServerActionsController } from "@/servers/registry/controller"
import { ServerConnection } from "@/runtime/server/registry"
import { useSsh } from "./context"
export function SshMenu(props: { id: string; domain: ServerActionsController }) {
const ssh = useSsh()
const language = useLanguage()
const item = () => ssh.item(props.id)
const key = () => ServerConnection.Key.make(`ssh:${props.id}`)
return (
<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 disabled={ssh.pending(props.id)} onSelect={() => ssh.connect(item().config)}>
{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>
)
}
-22
View File
@@ -1,22 +0,0 @@
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")
})
-16
View File
@@ -1,16 +0,0 @@
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)
}
@@ -1,17 +0,0 @@
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 })
}
})
}
-11
View File
@@ -1,11 +0,0 @@
import { useSsh } from "./context"
import { createSshRestore } from "./restore-state"
export function SshRestore() {
const ssh = useSsh()
createSshRestore({
state: () => ({ servers: ssh.servers }),
start: ssh.restore,
})
return null
}
-87
View File
@@ -1,87 +0,0 @@
import { For, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { ServerCollectionController } from "@/servers/registry/controller"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { ServerConnection } from "@/runtime/server/registry"
import { useSsh } from "./context"
import { SshMenu } from "./menu"
import { Badge } from "@opencode/ui/badge"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { sshName } from "./name"
import { isSshConnecting } from "./status"
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
const ssh = useSsh()
const language = useLanguage()
return (
<For
each={ssh.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)}
authenticationRequired={item.stage === "authentication"}
/>
<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={item.stage === "authentication"}
fallback={
<Show when={health()?.version}>
{(version) => <span class="settings-servers-meta">v{version()}</span>}
</Show>
}
>
<span class="settings-servers-meta">{language.t("ssh.stage.authentication")}</span>
</Show>
</div>
</div>
<div class="settings-servers-actions">
<Show when={item.stage === "authentication" || ssh.pending(item.config.id)}>
<Button
size="small"
variant="ghost-muted"
disabled={ssh.pending(item.config.id)}
aria-busy={ssh.pending(item.config.id)}
onClick={() => ssh.connect(item.config)}
>
<Show when={ssh.pending(item.config.id)}>
<Spinner class="size-3.5" />
</Show>
{language.t(ssh.pending(item.config.id) ? "ssh.session.connecting" : "ssh.action.authenticate")}
</Button>
</Show>
<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>
)
}
-10
View File
@@ -1,10 +0,0 @@
.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;
}
-11
View File
@@ -1,11 +0,0 @@
import type { SshItem } from "./types"
export function isSshConnecting(stage: SshItem["stage"]) {
return (
stage === "connecting" ||
stage === "checking" ||
stage === "downloading" ||
stage === "uploading" ||
stage === "starting"
)
}
-72
View File
@@ -1,72 +0,0 @@
import { Schema } from "effect"
export { sshHostname, sshName } from "./name"
export { isSshConnecting } from "./status"
export const SshConfig = Schema.Struct({ id: Schema.String, target: Schema.String, name: Schema.String })
export type SshConfig = typeof SshConfig.Type
export const SshHttp = Schema.Struct({ url: Schema.String, password: Schema.String })
export type SshHttp = typeof SshHttp.Type
export const SshStage = Schema.Literals([
"disconnected",
"connecting",
"checking",
"downloading",
"uploading",
"starting",
"ready",
"authentication",
"incompatible",
"failed",
])
export const SshPrompt = Schema.Struct({
id: Schema.String,
text: Schema.String,
confirm: Schema.Boolean,
})
export const SshItem = Schema.Struct({
config: SshConfig,
saved: Schema.Boolean,
destination: Schema.optional(Schema.String),
stage: SshStage,
http: Schema.optional(SshHttp),
prompt: Schema.optional(SshPrompt),
authenticatingElsewhere: Schema.optional(Schema.Boolean),
detail: Schema.String,
error: Schema.optional(
Schema.Literals([
"connection",
"input",
"platform",
"version",
"install",
"service",
"host-key",
"ssh-missing",
"unpublished",
]),
),
})
export type SshItem = typeof SshItem.Type
export const SshState = Schema.Struct({ servers: Schema.Array(SshItem) })
export type SshState = typeof SshState.Type
export const SshStart = Schema.Struct({
id: Schema.String,
target: Schema.String,
name: Schema.String,
replace: Schema.optional(Schema.Boolean),
background: Schema.optional(Schema.Boolean),
})
export type SshStart = typeof SshStart.Type
export type SshPlatform = {
getState(): Promise<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>
cancel(id: string): Promise<void>
forget(id: string): Promise<void>
openConfig(): Promise<void>
}
+2 -8
View File
@@ -16,7 +16,6 @@ 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"
@@ -31,7 +30,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
}
return (
<Show
when={platform.wslServers || platform.sshServers}
when={platform.wslServers}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
@@ -45,12 +44,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</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.Item onSelect={openAddWsl}>{language.t("wsl.server.add")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
+2 -6
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,14 +62,10 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
})
createEffect(
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,8 +54,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
variant: { current: () => "high" },
},
},
{
@@ -77,8 +76,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
variant: { current: () => model.variant },
},
},
{
@@ -93,54 +91,26 @@ describe("syncPromptModel", () => {
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
syncPromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
set: (value) => calls.push(value),
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
})
})
@@ -12,10 +12,8 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -51,18 +49,3 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
@@ -63,6 +63,36 @@ describe("SerializeAddon", () => {
}
})
describe("scrollback option", () => {
test("reads only the requested tail and restores the cursor on its screen row", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
await writeAndWait(term, "\x1b[2A\x1b[3G")
expect(term.buffer.normal.length).toBe(30)
expect([term.buffer.normal.cursorX, term.buffer.normal.cursorY]).toEqual([2, 2])
const reads = spyOn(term.buffer.normal, "getLine")
const serialized = addon.serialize({ scrollback: 3 })
expect(new Set(reads.mock.calls.map((args) => args[0]))).toEqual(new Set([22, 23, 24, 25, 26, 27, 28, 29]))
reads.mockRestore()
const restored = createTerminal(20, 5)
await writeAndWait(restored.term, serialized)
expect(restored.term.getScrollbackLength()).toBe(3)
for (let row = 0; row < 8; row++) {
expect(restored.term.buffer.normal.getLine(row)?.translateToString(true)).toBe(`line ${22 + row}`)
}
expect([restored.term.buffer.normal.cursorX, restored.term.buffer.normal.cursorY]).toEqual([2, 2])
})
test("serializes the whole buffer when it has fewer rows than requested", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n"))
expect(addon.serialize({ scrollback: 100 })).toBe(addon.serialize())
})
})
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
@@ -481,12 +481,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
// CUP addresses the screen and ghostty-web reports cursorY relative to the screen, so the
// serialized range start must not shift the row. The cursor line sits in the screen region
// at the bottom of the buffer, after any scrollback rows.
content += `\u001b[${this._buffer.cursorY + 1};${this._buffer.cursorX + 1}H`
const line = this._buffer.getLine(absoluteCursorRow)
const line = this._buffer.getLine(this._buffer.length - this._terminal.rows + this._buffer.cursorY)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
@@ -20,6 +20,12 @@ import { terminalWriter } from "@/session/terminal/writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
// Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the
// result is written to localStorage or desktop state for every terminal in the workspace.
// Persisting the most recent 2k scrollback rows keeps restore fidelity for the history users
// actually scroll back through while capping teardown cost and snapshot size; the live
// terminal keeps its full 10k scrollback while mounted.
const persistedScrollbackRows = 2_000
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
autoFocus?: boolean
@@ -152,7 +158,7 @@ const persistTerminal = (input: {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
return input.addon.serialize({ scrollback: persistedScrollbackRows })
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""
@@ -13,8 +13,6 @@ import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { SettingsList } from "@/settings/list"
import { SshServerSettings } from "@/servers/ssh/settings"
import { useSsh } from "@/servers/ssh/context"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import "@/settings/settings.css"
@@ -24,14 +22,13 @@ export const SettingsServers: Component = () => {
const controller = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const ssh = useSsh()
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.collection.items().filter((item) => !isWslServer(item) && item.type !== "ssh")
const items = controller.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
@@ -92,7 +89,7 @@ export const SettingsServers: Component = () => {
<div class="settings-tab-body settings-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
when={filtered().length > 0 || wslServers().length > 0}
fallback={
<div class="settings-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
@@ -103,7 +100,6 @@ export const SettingsServers: Component = () => {
}
>
<SettingsList>
<SshServerSettings filter={store.filter} domain={controller} />
<WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
@@ -1,24 +1,14 @@
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
export function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.sshServers)
commands.push({
id: "server.ssh.add",
title: language.t("ssh.add"),
category: language.t("command.category.server"),
onSelect: () => void dialog.push(() => <DialogSsh openProject />),
})
if (platform.platform !== "desktop" || !platform.exportDebugLogs) return commands
commands.push({
id: "logs.export",
+2 -3
View File
@@ -8,7 +8,6 @@ import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { SshAuthentication } from "@/servers/ssh/authentication"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -98,9 +97,9 @@ export default function Layout(props: ParentProps) {
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
}}
>
<SshAuthentication>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
</SshAuthentication>
</div>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
@@ -0,0 +1,459 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
@@ -1,100 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSshAuthentication } from "../src/servers/ssh/authentication-state"
import type { SshItem } from "../src/servers/ssh/types"
test("background authentication stays quiet; selecting a tab prompts once and cancellation is respected", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ selection?: string; busy: boolean; item: SshItem }>({
busy: false,
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "authentication", saved: true, detail: "" },
})
createSshAuthentication({
selection: () => state.selection,
item: () => state.item,
busy: () => state.busy,
open: (item) => {
opened.push(item.config.id)
setState("busy", true)
},
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("selection", "session-1")
expect(opened).toEqual(["host"])
fixture.setState("busy", false)
fixture.setState("item", "detail", "new status")
expect(opened).toHaveLength(1)
fixture.setState("selection", undefined)
fixture.setState("selection", "session-1")
expect(opened).toHaveLength(2)
} finally {
fixture.dispose()
}
})
test("a selected tab waits for authentication and other dialogs before offering a prompt", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ busy: boolean; item: SshItem }>({
busy: true,
item: { config: { id: "host", target: "linuxbook", name: "" }, stage: "connecting", saved: true, detail: "" },
})
createSshAuthentication({
selection: () => "draft-1",
item: () => state.item,
busy: () => state.busy,
open: (item) => {
opened.push(item.config.id)
},
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("item", "stage", "authentication")
expect(opened).toEqual([])
fixture.setState("busy", false)
expect(opened).toEqual(["host"])
fixture.setState("item", "stage", "connecting")
fixture.setState("item", "stage", "authentication")
expect(opened).toHaveLength(1)
} finally {
fixture.dispose()
}
})
test("selecting a tab waits for another window to release authentication", () => {
const opened: string[] = []
const fixture = createRoot((dispose) => {
const [state, setState] = createStore<{ item: SshItem }>({
item: {
config: { id: "host", target: "linuxbook", name: "" },
stage: "authentication",
authenticatingElsewhere: true,
saved: true,
detail: "",
},
})
createSshAuthentication({
selection: () => "session-1",
item: () => state.item,
busy: () => false,
open: (item) => opened.push(item.config.id),
})
return { dispose, setState }
})
try {
expect(opened).toEqual([])
fixture.setState("item", "detail", "still waiting in another window")
expect(opened).toEqual([])
fixture.setState("item", "authenticatingElsewhere", false)
expect(opened).toEqual(["host"])
} finally {
fixture.dispose()
}
})
@@ -1,38 +0,0 @@
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()
})
})
@@ -1,257 +0,0 @@
import { expect, test } from "bun:test"
import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSshController } from "../src/servers/ssh/controller"
import type { SshItem } from "../src/servers/ssh/types"
function fixture() {
return createRoot((dispose) => {
const config = { id: "host", target: "ssh linuxbook", name: "" }
const [state, setState] = createStore<{ items: SshItem[]; busy: boolean }>({
items: [{ config, stage: "disconnected", saved: true, detail: "" }],
busy: false,
})
const calls = { starts: 0, responses: 0, cancels: 0, forgets: 0, prompts: 0, errors: 0, connected: 0 }
const admission = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<void>()
const response = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const ssh = createSshController({
items: () => state.items,
api: {
start: () => {
calls.starts++
return admission.promise
},
respond: () => {
calls.responses++
return calls.responses === 1 ? response.promise : Promise.resolve()
},
cancel: async () => {
calls.cancels++
setState("items", 0, { stage: "disconnected", prompt: undefined })
cancelled.resolve()
},
forget: async () => {
calls.forgets++
setState("items", [])
},
disconnect: async () => {},
},
refresh: () => refresh.promise,
error: () => calls.errors++,
})
createEffect(() => {
const item = ssh.dialog.next()
if (!item || state.busy) return
ssh.dialog.opened(item.config.id)
calls.prompts++
})
const admitted = async () => {
admission.resolve()
refresh.resolve()
await refresh.promise
await Promise.resolve()
await Promise.resolve()
}
return { dispose, config, setState, calls, admission, refresh, response, cancelled, admitted, ssh }
})
}
test("key-based reconnect stays pending through admission and refetch without opening a dialog", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.ssh.connect(app.config)
expect(app.ssh.pending("host")).toBe(true)
app.admission.resolve()
await app.admission.promise
await Promise.resolve()
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.starts).toBe(1)
app.setState("items", 0, "stage", "connecting")
await app.admitted()
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.prompts).toBe(0)
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
expect(app.ssh.pending("host")).toBe(false)
expect(app.calls.connected).toBe(1)
} finally {
app.dispose()
}
})
test("reconnect waits for an available dialog and opens only once across SSH challenges", async () => {
const app = fixture()
try {
app.setState("busy", true)
app.ssh.connect(app.config)
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
expect(app.calls.prompts).toBe(0)
app.setState("busy", false)
expect(app.calls.prompts).toBe(1)
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
expect(app.calls.prompts).toBe(1)
expect(app.calls.starts).toBe(1)
} finally {
app.dispose()
}
})
test("a connection form owns its challenges and reports request failures inline", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { dialog: true })
app.admission.reject(new Error("IPC unavailable"))
await app.admission.promise.catch(() => {})
await Promise.resolve()
expect(app.ssh.error("host")).toBe(true)
expect(app.ssh.submitting("host")).toBe(false)
expect(app.calls.errors).toBe(0)
expect(app.calls.prompts).toBe(0)
} finally {
app.dispose()
}
})
test("responding suppresses duplicate submissions while allowing the next SSH challenge", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
app.ssh.respond("host", "expired", "ignored")
app.ssh.respond("host", "password", "secret")
app.ssh.respond("host", "password", "secret")
expect(app.calls.responses).toBe(1)
expect(app.ssh.submitting("host")).toBe(true)
app.response.resolve()
await app.response.promise
await Promise.resolve()
expect(app.ssh.answered("host")).toBe(true)
app.ssh.respond("host", "password", "secret")
expect(app.calls.responses).toBe(1)
app.setState("items", 0, "prompt", { id: "otp", text: "Code:", confirm: false })
expect(app.ssh.answered("host")).toBe(false)
app.ssh.respond("host", "otp", "123456")
expect(app.calls.responses).toBe(2)
} finally {
app.dispose()
}
})
test("failed reconnect becomes retryable without opening a connection form", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "connecting")
await app.admitted()
app.setState("items", 0, "stage", "failed")
expect(app.ssh.pending("host")).toBe(false)
expect(app.calls.prompts).toBe(0)
app.ssh.connect(app.config)
expect(app.calls.starts).toBe(2)
} finally {
app.dispose()
}
})
test("a failed response can be retried without losing the reconnect continuation", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.setState("items", 0, { stage: "authentication", prompt: { id: "password", text: "Password:", confirm: false } })
await app.admitted()
app.ssh.respond("host", "password", "secret")
app.response.reject(new Error("IPC unavailable"))
await app.response.promise.catch(() => {})
await Promise.resolve()
expect(app.ssh.error("host")).toBe(true)
expect(app.ssh.answered("host")).toBe(false)
expect(app.calls.errors).toBe(0)
app.ssh.respond("host", "password", "secret")
await Promise.resolve()
expect(app.calls.responses).toBe(2)
expect(app.ssh.error("host")).toBe(false)
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
expect(app.calls.connected).toBe(1)
} finally {
app.dispose()
}
})
test("cancelling a version-mismatch dialog allows another reconnect", async () => {
const app = fixture()
try {
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
expect(app.calls.prompts).toBe(1)
app.ssh.cancel("host")
await app.cancelled.promise
app.ssh.connect(app.config)
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
expect(app.calls.starts).toBe(2)
expect(app.calls.prompts).toBe(2)
expect(app.calls.forgets).toBe(0)
} finally {
app.dispose()
}
})
test("updating from the authentication dialog preserves the continuation and invokes it once", async () => {
const app = fixture()
try {
app.ssh.connect(app.config, { onConnected: () => app.calls.connected++ })
app.setState("items", 0, "stage", "incompatible")
await app.admitted()
app.ssh.connect(app.config, { dialog: true, replace: true })
app.setState("items", 0, "stage", "connecting")
await app.admitted()
app.setState("items", 0, "stage", "ready")
await Promise.resolve()
app.setState("items", 0, "detail", "updated")
await Promise.resolve()
expect(app.calls.connected).toBe(1)
expect(app.calls.prompts).toBe(1)
} finally {
app.dispose()
}
})
test("cancelling an unsaved connection interrupts admission and forgets it", async () => {
const app = fixture()
try {
app.setState("items", 0, "saved", false)
app.ssh.connect(app.config, { dialog: true })
app.ssh.cancel("host")
await app.cancelled.promise
await Promise.resolve()
expect(app.calls.cancels).toBe(1)
expect(app.calls.forgets).toBe(1)
expect(app.ssh.item("host")).toBeUndefined()
expect(app.ssh.submitting("host")).toBe(false)
app.admission.resolve()
} finally {
app.dispose()
}
})
test("another window's authentication stays pending without starting a competing attempt", () => {
const app = fixture()
try {
app.setState("items", 0, { stage: "authentication", authenticatingElsewhere: true })
app.ssh.connect(app.config)
expect(app.ssh.pending("host")).toBe(true)
expect(app.calls.starts).toBe(0)
app.setState("items", 0, "authenticatingElsewhere", false)
app.ssh.connect(app.config)
expect(app.calls.starts).toBe(1)
} finally {
app.dispose()
}
})
@@ -1,130 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerHealth, type ServerHealth } from "../src/runtime/server/health"
import { ServerConnection } from "../src/runtime/server/registry"
import type { SshItem } from "../src/servers/ssh/types"
test("SSH health is checked only with an active tunnel, and cancellation clears stale failures", async () => {
const requests: ReturnType<typeof Promise.withResolvers<ServerHealth>>[] = []
const app = createRoot((dispose) => {
const [state, setState] = createStore<{ stage: SshItem["stage"] }>({ stage: "disconnected" })
const connection: ServerConnection.Ssh = {
type: "ssh",
id: "fixture",
host: "devbox",
http: { url: "http://127.0.0.1:12345" },
get stage() {
return state.stage
},
}
const health = createServerHealth(
() => [connection],
() => true,
() => {
const request = Promise.withResolvers<ServerHealth>()
requests.push(request)
return request.promise
},
)
return { dispose, setState, health: () => health[ServerConnection.key(connection)] }
})
try {
expect(app.health()).toBeUndefined()
app.setState("stage", "connecting")
app.setState("stage", "authentication")
app.setState("stage", "disconnected")
expect(requests).toHaveLength(0)
expect(app.health()).toBeUndefined()
app.setState("stage", "ready")
expect(requests).toHaveLength(1)
expect(app.health()?.checking).toBe(true)
app.setState("stage", "authentication")
requests[0]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toBeUndefined()
app.setState("stage", "failed")
expect(app.health()?.healthy).toBe(false)
app.setState("stage", "authentication")
expect(app.health()).toBeUndefined()
app.setState("stage", "ready")
requests[1]?.resolve({ healthy: false })
await Promise.resolve()
expect(app.health()).toEqual({ healthy: false })
} finally {
app.dispose()
}
})
function fixture() {
const requests: ReturnType<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()
}
})
@@ -1,68 +0,0 @@
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()
}
})
-5
View File
@@ -15,11 +15,6 @@ import { Npm } from "@opencode/util/npm"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
}
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
-36
View File
@@ -1,36 +0,0 @@
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)
-40
View File
@@ -1,40 +0,0 @@
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),
)
+18
View File
@@ -986,6 +986,15 @@ export type SessionLogOutput =
| undefined
readonly text: string
readonly recent: string
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
| undefined
}
}
| {
@@ -1000,6 +1009,15 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly inputID?: SessionMessage.ID | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
| undefined
}
}
| {
+56 -1
View File
@@ -523,6 +523,8 @@ export type SessionMessageCompactionFailed = {
status: "failed"
reason: "auto" | "manual"
error: SessionStructuredError
cost?: MoneyUSD
tokens?: TokenUsageInfo
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
@@ -808,7 +810,14 @@ export type SessionCompactionFailed = {
type: "session.compaction.failed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
data: {
sessionID: string
reason: "auto" | "manual"
error: SessionStructuredError
inputID?: string
cost?: MoneyUSD
tokens?: TokenUsageInfo
}
}
export type SessionRevertCleared = {
@@ -1738,6 +1747,8 @@ export type SessionMessageCompactionCompleted = {
summary: string
recent: string
providerContext?: SessionProviderContext
cost?: MoneyUSD
tokens?: TokenUsageInfo
}
export type SessionCompactionEnded = {
@@ -1755,6 +1766,8 @@ export type SessionCompactionEnded = {
providerContext?: SessionProviderContext
text: string
recent: string
cost?: MoneyUSD
tokens?: TokenUsageInfo
}
}
@@ -3111,6 +3124,13 @@ export type SessionImportInput = {
}
readonly messages: JsonValue
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
| {
readonly type: "compaction"
@@ -3120,6 +3140,13 @@ export type SessionImportInput = {
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
)
>
@@ -3402,6 +3429,13 @@ export type SessionImportInput = {
}
readonly messages: JsonValue
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
| {
readonly type: "compaction"
@@ -3411,6 +3445,13 @@ export type SessionImportInput = {
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
)
>
@@ -3693,6 +3734,13 @@ export type SessionImportInput = {
}
readonly messages: JsonValue
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
| {
readonly type: "compaction"
@@ -3702,6 +3750,13 @@ export type SessionImportInput = {
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
)
>
+8
View File
@@ -1083,8 +1083,11 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
cost: event.data.cost,
tokens: event.data.tokens,
})
return
}
@@ -1095,8 +1098,11 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
cost: event.data.cost,
tokens: event.data.tokens,
time: { created: event.created },
})
})
@@ -1116,6 +1122,8 @@ export function createData(config: CreateDataInput) {
message: "Compaction failed before recording an error",
},
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
cost: event.data.cost,
tokens: event.data.tokens,
time: current?.type === "compaction" ? current.time : { created: event.created },
}
if (current?.type === "compaction") {
+35 -2
View File
@@ -100,13 +100,46 @@ test.each(["started", "cancelled", "failed"])(
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
const model = { providerID: "demo", id: "model" }
const providerState = { responseId: "summary-response" }
const tokens = { input: 10, output: 4, reasoning: 0, cache: { read: 3, write: 0 } }
const providerContext = {
version: 1 as const,
provenance: {
providerID: "demo",
provider: "demo",
modelID: "model",
route: "demo-responses",
protocol: "demo",
endpoint: "digest",
},
messages: [],
}
fixture.emit({
...event,
type: "session.compaction.ended",
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
data: {
sessionID,
reason: "manual",
model,
providerState,
providerContext,
text: "Summary",
recent: "Recent",
cost: 0.01,
tokens,
},
})
// The live fold carries the provider window and request usage so the label matches a reloaded session.
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
{
type: "compaction",
status: "completed",
summary: "Summary",
model,
providerState,
providerContext,
cost: 0.01,
tokens,
},
])
}
},
+7 -8
View File
@@ -383,12 +383,7 @@ export const layer = Layer.effect(
},
}),
})
const failed = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly reason: SessionMessage.Compaction["reason"]
readonly error: SessionError.Error
readonly inputID?: SessionMessage.ID
}) {
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
@@ -504,11 +499,12 @@ export const layer = Layer.effect(
)
}),
)
if (result.usage)
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
if (usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction" as const,
...SessionUsage.record(result.usage, context.model.cost),
...usage,
})
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
@@ -517,6 +513,7 @@ export const layer = Layer.effect(
text: "",
recent: "",
providerContext: SessionProviderContext.encode(provenance, result.replacement),
...usage,
})
return { status: "completed" as const }
}),
@@ -662,6 +659,7 @@ export const layer = Layer.effect(
reason: input.reason,
error,
inputID: input.inputID,
...usage,
})
}
yield* bus.publish(SessionEvent.Compaction.Ended, {
@@ -671,6 +669,7 @@ export const layer = Layer.effect(
providerState,
text: summary,
recent: history.recent,
...usage,
})
return { status: "completed" as const }
})
@@ -415,6 +415,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
cost: event.data.cost,
tokens: event.data.tokens,
})
return
}
@@ -430,6 +432,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
cost: event.data.cost,
tokens: event.data.tokens,
time: { created },
}),
)
@@ -444,6 +448,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
cost: event.data.cost,
tokens: event.data.tokens,
time: current?.time ?? { created },
})
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
@@ -401,8 +401,16 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
// The compaction message carries its own request usage so clients can show what compacting cost.
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" },
{
type: "compaction",
reason: "manual",
summary: "## Objective\n- manual summary",
recent: "",
cost: 0.0000233,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
},
])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
@@ -240,6 +240,8 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
return yield* Effect.die("Missing native checkpoint")
expect(last.summary).toBe("")
expect(last.recent).toBe("")
// Provider compaction has no summary, so the request usage is the only visible cost of the operation.
expect(last.tokens).toMatchObject({ input: 20, output: 4 })
return last.providerContext
})
return {
@@ -61,8 +61,6 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
"output" in result ? result.output.filter((item) => item.type === "chunk") : [],
)
expect(chunks.length).toBeGreaterThan(0)
// Resource resolution must not depend on which lazy entry owns DesktopPaths.
expect(chunks.every((chunk) => !chunk.fileName.includes("/"))).toBe(true)
const imports = chunks.flatMap((chunk) => [...chunk.imports, ...chunk.dynamicImports])
const modules = chunks.flatMap((chunk) => Object.keys(chunk.modules))
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
-3
View File
@@ -45,9 +45,6 @@ export default defineConfig(({ command }) => ({
// corrupt bundled TypeScript, while an output banner places the shim safely.
output: {
format: "es",
// DesktopPaths resolves resources from the main output directory,
// including when the lazy desktop entry shares it with other chunks.
chunkFileNames: "[name]-[hash].js",
banner: `
// -- CommonJS Shims --
import __cjs_mod__ from 'node:module';
-32
View File
@@ -1,32 +0,0 @@
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
})
runIpc().pipe(
Effect.provide(Ipc.layer),
Effect.provide(BackgroundService.layer),
Effect.provide(DesktopCli.layer),
Effect.provide(UpdaterLive.layer),
Effect.provide(DesktopInitialization.layer),
Effect.provide(ApplicationLifecycle.layer),
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
Effect.scoped,
NodeRuntime.runMain,
)
+33 -2
View File
@@ -1,3 +1,34 @@
export {}
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { app } from "electron"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
await import("./desktop")
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
})
runIpc().pipe(
Effect.provide(Ipc.layer),
Effect.provide(BackgroundService.layer),
Effect.provide(DesktopCli.layer),
Effect.provide(UpdaterLive.layer),
Effect.provide(DesktopInitialization.layer),
Effect.provide(ApplicationLifecycle.layer),
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
Effect.scoped,
NodeRuntime.runMain,
)
@@ -1,25 +0,0 @@
import { Effect } from "effect"
import { SshRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Ssh } from "../ssh/service"
import { sender } from "./context"
export const sshHandlers = SshRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const ssh = yield* Ssh.Service
return SshRpcs.of({
SshGetState: (_args, context) => ssh.state(sender(handoff, context).id),
SshSubscribe: (_args, context) => ssh.subscribeWindow(sender(handoff, context)),
SshUnsubscribe: (_args, context) => ssh.unsubscribeWindow(sender(handoff, context).id),
SshHosts: () => ssh.hosts(),
SshStart: (input, context) => ssh.start(input, input.background ? undefined : sender(handoff, context).id),
SshResolve: ({ id }) => ssh.resolve(id),
SshRespond: ({ id, prompt, value }, context) => ssh.respond(id, prompt, value, sender(handoff, context).id),
SshDisconnect: ({ id }) => ssh.disconnect(id),
SshCancel: ({ id }, context) => ssh.cancel(id, sender(handoff, context).id),
SshForget: ({ id }) => ssh.forget(id).pipe(Effect.orDie),
SshOpenConfig: () => ssh.openConfig().pipe(Effect.orDie),
})
}),
)
+1 -4
View File
@@ -14,8 +14,6 @@ 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"
@@ -25,7 +23,7 @@ import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
@@ -34,7 +32,6 @@ const handlers = Layer.mergeAll(
menuHandlers,
updaterHandlers,
wslHandlers,
sshHandlers,
eventHandlers,
)
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
@@ -1,113 +0,0 @@
import { expect, test } from "bun:test"
import { Effect, FileSystem, Path } from "effect"
import { NodeServices } from "@effect/platform-node"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "../../../../core/test/lib/effect"
import { RemoteCli } from "./cli"
const it = testEffect(NodeServices.layer)
// These scripts execute on the POSIX remote host, not the Windows desktop.
const posix = process.platform === "win32" ? it.live.skip : it.live
it.live(
"resolves the beta channel and rejects unavailable or invalid metadata",
Effect.gen(function* () {
for (const response of [
Response.json({ version: "0.0.0-beta-19059" }),
Response.json({ version: "2.0.0-local-123" }),
Response.json({ version: "0.0.0-beta-19059" }, { status: 503 }),
]) {
const result = yield* RemoteCli.latestBeta().pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => {
expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
),
Effect.result,
)
if (response.status === 200 && result._tag === "Success") expect(result.success).toBe("0.0.0-beta-19059")
else expect(result._tag).toBe("Failure")
}
}),
)
posix(
"discovers the managed CLI by default and uses PATH only when requested",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "remote-cli-" })
const home = path.join(dir, "home with ' quotes")
yield* fs.makeDirectory(path.join(home, ".opencode/bin"), { recursive: true })
yield* fs.makeDirectory(path.join(dir, "bin"))
const managed = path.join(home, ".opencode/bin/opencode2")
const external = path.join(dir, "bin/opencode2")
yield* fs.writeFileString(managed, "#!/bin/sh\nprintf 'OpenCode v2.0.0\\n'\n", { mode: 0o755 })
yield* fs.writeFileString(external, "#!/bin/sh\nprintf 'OpenCode v2.1.0\\n'\n", { mode: 0o755 })
const run = (script: string) =>
spawner.string(
ChildProcess.make("sh", ["-c", script], {
env: { HOME: home, PATH: `${path.join(dir, "bin")}:/usr/bin:/bin` },
}),
)
expect((yield* run(RemoteCli.discoverScript())).trim()).toBe(managed)
expect((yield* run(RemoteCli.discoverScript({ fromPath: true }))).trim()).toBe(external)
expect(RemoteCli.parseVersion(yield* run(RemoteCli.versionScript(RemoteCli.quote(managed))))).toBe("2.0.0")
yield* fs.remove(managed)
expect((yield* run(RemoteCli.discoverScript())).trim()).toBe("")
expect(RemoteCli.parseVersion(yield* run(RemoteCli.versionScript(RemoteCli.quote(managed))))).toBeNull()
}),
)
test("pins platform-specific artifacts and rejects unsafe inputs", () => {
expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
"https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
)
expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
})
posix(
"downloads or uploads the same archive into managed and version-specific locations",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "remote-install-" })
yield* fs.makeDirectory(path.join(dir, "package/bin"), { recursive: true })
yield* fs.writeFileString(path.join(dir, "package/bin/opencode2"), "#!/bin/sh\nprintf 'OpenCode v2.0.0\\n'\n", {
mode: 0o755,
})
const archive = path.join(dir, "archive.tgz")
expect(
yield* spawner.exitCode(ChildProcess.make("tar", ["-czf", archive, "-C", dir, "package"], { extendEnv: true })),
).toBe(0)
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response(Bun.file(archive)),
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true)))
const run = (input: Parameters<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"])
}),
)
-126
View File
@@ -1,126 +0,0 @@
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
})
@@ -1,90 +0,0 @@
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"}')
}),
)
-81
View File
@@ -1,81 +0,0 @@
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)
}),
}
})
@@ -1,106 +0,0 @@
import { expect, test } from "bun:test"
import { Effect, FileSystem, Path, Stream } from "effect"
import { NodeServices } from "@effect/platform-node"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { testEffect } from "../../../../core/test/lib/effect"
import { binaryPath, discoverScript, startScript, parseRegistration } from "./bootstrap"
const it = testEffect(NodeServices.layer)
// Bootstrap runs on the POSIX SSH host; these fixtures execute its shell locally.
const posix = process.platform === "win32" ? it.live.skip : it.live
posix(
"starts a staged CLI, rediscovers it, and restarts only for an explicit update",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ssh-beta-test-" })
const version = "0.0.0-beta-19059"
const bin = path.join(dir, ".opencode/desktop-ssh", version)
yield* fs.makeDirectory(bin, { recursive: true })
yield* fs.writeFileString(
path.join(bin, "opencode2"),
`#!/bin/sh
set -eu
case "$1 $2" in
"service start"|"service restart")
printf '%s\\n' "$2" >> "$HOME/actions"
mkdir -p "$XDG_STATE_HOME/opencode"
printf '%s' '{"url":"http://127.0.0.1:12345","password":"fixture","version":"${version}","pid":1234}' > "$XDG_STATE_HOME/opencode/service.json"
;;
"service status")
if [ -f "$XDG_STATE_HOME/opencode/service.json" ]; then printf 'http://127.0.0.1:12345\\n'; else printf 'stopped\\n'; fi
;;
*) exit 66 ;;
esac
`,
{ mode: 0o755 },
)
const run = (script: string) =>
spawner.string(
ChildProcess.make("sh", ["-c", script], {
env: { HOME: dir, PATH: "/usr/bin:/bin", XDG_STATE_HOME: path.join(dir, "state") },
}),
)
expect(parseRegistration(yield* run(discoverScript))).toBeUndefined()
const started = parseRegistration(yield* run(startScript(version)))
expect(started?.version).toBe(version)
expect(started?.url).toBe("http://127.0.0.1:12345")
expect(parseRegistration(yield* run(discoverScript))).toEqual(started)
expect(parseRegistration(yield* run(startScript(version, true)))).toEqual(started)
expect(yield* fs.readFileString(path.join(dir, "actions"))).toBe("start\nrestart\n")
}),
)
posix(
"finds an existing service through the released CLI",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ssh-discovery-test-" })
const expected = {
url: "http://0.0.0.0:49374",
password: 'private"credential',
version: "0.0.0-beta-19059",
pid: 1234,
}
yield* fs.makeDirectory(path.join(dir, ".opencode/bin"), { recursive: true })
yield* fs.makeDirectory(path.join(dir, "state/opencode"), { recursive: true })
yield* fs.writeFileString(
path.join(dir, ".opencode/bin/opencode2"),
'#!/bin/sh\n[ "$1 $2" = "service status" ] || exit 66\nprintf "http://0.0.0.0:49374\\n"\n',
{ mode: 0o755 },
)
yield* fs.writeFileString(path.join(dir, "state/opencode/service.json"), JSON.stringify(expected, null, 2))
yield* fs.writeFileString(
path.join(dir, "state/opencode/service-local.json"),
JSON.stringify({ ...expected, url: "http://127.0.0.1:7777", password: "other" }),
)
const child = yield* spawner.spawn(
ChildProcess.make("sh", ["-c", discoverScript], {
env: { HOME: dir, PATH: "/usr/bin:/bin", XDG_STATE_HOME: path.join(dir, "state") },
}),
)
const output = yield* child.stdout.pipe(Stream.decodeText(), Stream.mkString)
expect(yield* child.exitCode).toBe(0)
expect(parseRegistration(output)).toEqual(expected)
}),
)
test("ignores stopped services and registrations that do not match the healthy endpoint", () => {
const registration = { url: "http://127.0.0.1:1234", password: "secret", version: "2.0.0", pid: 42 }
const frame = `OPENCODE_SSH_REGISTRATION_BEGIN\n${JSON.stringify(registration)}\nOPENCODE_SSH_REGISTRATION_END\n`
expect(parseRegistration(`OPENCODE_SSH_STATUS=stopped\n${frame}`)).toBeUndefined()
expect(parseRegistration(`OPENCODE_SSH_STATUS=http://127.0.0.1:9999\n${frame}`)).toBeUndefined()
expect(
parseRegistration(
`OPENCODE_SSH_STATUS=${registration.url}\nOPENCODE_SSH_REGISTRATION_BEGIN\ninvalid\nOPENCODE_SSH_REGISTRATION_END\n`,
),
).toBeUndefined()
})
test("rejects unsafe versions in SSH installation paths", () => {
expect(() => binaryPath('2.0.0"; whoami')).toThrow()
})
-147
View File
@@ -1,147 +0,0 @@
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,
})
})
@@ -1,89 +0,0 @@
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"])
}),
)
})
-256
View File
@@ -1,256 +0,0 @@
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()
})
@@ -1,260 +0,0 @@
import { expect } from "bun:test"
import { NodeServices, NodeSocketServer } from "@effect/platform-node"
import { Deferred, Effect, Fiber, FileSystem, Layer, Path, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { testEffect } from "../../../../core/test/lib/effect"
import { createSshController } from "./controller"
import { quote } from "./command"
const it = testEffect(Layer.merge(NodeServices.layer, FetchHttpClient.layer))
// The askpass ProxyCommand fixture is a POSIX shell executable.
const posix = process.platform === "win32" ? it.live.skip : it.live
it.live(
"resolution validates a live tunnel and rediscovers changed remote credentials and ports",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const file = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-resolve-test-" }), "registration.json")
const state = { password: "first", tunnels: 0 }
const remote = () =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) =>
new Response(null, {
status:
request.headers.get("authorization") ===
`Basic ${Buffer.from(`opencode:${state.password}`).toString("base64")}`
? 200
: 401,
}),
})
const first = remote()
yield* Effect.addFinalizer(() => Effect.sync(() => first.stop(true)))
yield* fs.writeFileString(
file,
JSON.stringify({ url: first.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 1 }),
)
const config = { id: "fixture", target: "fixture", name: "Fixture" }
const controller = yield* createSshController({
configs: [config],
binary: process.execPath,
version: "2.0.0",
save: () => Effect.void,
}).pipe(
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
if (command._tag !== "StandardCommand") return spawner.spawn(command)
if (command.args.includes("-L")) state.tunnels++
return spawner.spawn(
ChildProcess.make(
process.execPath,
[path.join(import.meta.dirname, "../../../test/ssh/transport.ts"), file, ...command.args],
command.options,
),
)
}),
),
)
yield* controller.start(config, 1)
const initial = yield* controller.resolve(config.id)
expect((yield* controller.state()).servers[0]).toMatchObject({ stage: "ready" })
expect(initial?.password).toBe("first")
expect(yield* controller.resolve(config.id)).toEqual(initial)
expect(state.tunnels).toBe(1)
state.password = "second"
yield* fs.writeFileString(
file,
JSON.stringify({ url: first.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 2 }),
)
const refreshed = yield* Effect.all([controller.resolve(config.id), controller.resolve(config.id)], {
concurrency: "unbounded",
})
expect(refreshed[0]?.password).toBe("second")
expect(refreshed[0]).toEqual(refreshed[1])
expect(refreshed[0]?.url).not.toBe(initial?.url)
expect(state.tunnels).toBe(2)
const second = remote()
yield* Effect.addFinalizer(() => Effect.sync(() => second.stop(true)))
yield* fs.writeFileString(
file,
JSON.stringify({ url: second.url.href.replace(/\/$/, ""), password: state.password, version: "2.0.0", pid: 3 }),
)
yield* Effect.promise(() => first.stop(true))
const moved = yield* controller.resolve(config.id)
expect(moved?.url).not.toBe(refreshed[0]?.url)
expect(state.tunnels).toBe(3)
expect((yield* controller.state()).servers[0]?.stage).toBe("ready")
expect(yield* controller.resolve(config.id)).toEqual(moved)
expect(state.tunnels).toBe(3)
}).pipe(Effect.timeout("20 seconds")),
)
posix(
"cancelling an authentication attempt restores the previous state and permits another attempt",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const configFile = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-cancel-test-" }), "config")
yield* fs.writeFileString(configFile, "")
const proxy = path.join(path.dirname(configFile), "proxy")
yield* fs.writeFileString(proxy, '#!/bin/sh\n"$SSH_ASKPASS" "Password:" >/dev/null\nexec sleep 30\n', {
mode: 0o755,
})
const config = {
id: "fixture",
name: "Fixture",
target: `ssh -F ${quote(configFile)} -o ${quote(`ProxyCommand=${quote(proxy)}`)} fixture`,
}
const controller = yield* createSshController({
configs: [config],
binary: process.execPath,
command: [process.execPath, "run", path.resolve("../cli/src/index.ts")],
version: "2.0.0",
save: () => Effect.die("must not save"),
})
yield* controller.start({ ...config, background: true })
expect(yield* controller.resolve(config.id)).toBeNull()
expect((yield* controller.state()).servers[0]?.stage).toBe("authentication")
for (const owner of [1, 2]) {
const prompted = yield* controller.changes(owner).pipe(
Stream.filter((state) => !!state.servers[0]?.prompt),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* controller.start(config, owner)
yield* Fiber.join(prompted)
const prompt = (yield* controller.state(owner)).servers[0]?.prompt
expect((yield* controller.state(owner + 10)).servers[0]?.authenticatingElsewhere).toBe(true)
yield* controller.start(config, owner + 10)
expect((yield* controller.state(owner)).servers[0]?.prompt).toEqual(prompt)
expect((yield* controller.state(owner + 10)).servers[0]?.prompt).toBeUndefined()
yield* controller.cancel(config.id, owner + 10)
expect((yield* controller.state(owner)).servers[0]?.prompt).toBeDefined()
yield* controller.cancel(config.id, owner)
const item = (yield* controller.state(owner)).servers[0]
expect(item?.stage).toBe("authentication")
expect(item?.prompt).toBeUndefined()
expect(item?.error).toBeUndefined()
expect(item?.config).toEqual(config)
expect((yield* controller.state(owner + 10)).servers[0]?.authenticatingElsewhere).toBe(false)
expect(yield* controller.resolve(config.id)).toBeNull()
}
}).pipe(Effect.timeout("20 seconds")),
)
it.live(
"saved hosts do not connect until requested and forgetting never starts SSH",
Effect.gen(function* () {
const saves: unknown[] = []
const config = { id: "fixture", target: "unreachable.invalid", name: "Fixture" }
const controller = yield* createSshController({
configs: [config],
binary: "unused",
version: "2.0.0",
save: (configs) =>
Effect.sync(() => {
saves.push(configs)
}),
})
expect(yield* controller.resolve(config.id)).toBeNull()
yield* controller.disconnect(config.id)
expect((yield* controller.state()).servers[0]?.stage).toBe("disconnected")
yield* controller.forget(config.id)
expect((yield* controller.state()).servers).toEqual([])
expect(saves).toEqual([[]])
}),
)
it.live(
"invalid commands fail without persisting an incomplete connection",
Effect.gen(function* () {
const controller = yield* createSshController({
configs: [],
binary: "unused",
version: "2.0.0",
save: () => Effect.die("must not save"),
})
const settled = yield* controller.changes().pipe(
Stream.filter((state) => state.servers[0]?.stage === "failed"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* controller.start({ id: "fixture", target: "ssh host whoami", name: "" }, 1)
yield* Fiber.join(settled)
const state = yield* controller.state()
expect(state.servers[0]?.error).toBe("input")
expect(state.servers[0]?.saved).toBe(false)
}),
)
it.live(
"a failed edit does not replace the saved connection",
Effect.gen(function* () {
const config = { id: "fixture", target: "devbox", name: "Original" }
const saves: unknown[] = []
const controller = yield* createSshController({
configs: [config],
binary: "unused",
version: "2.0.0",
save: (configs) =>
Effect.sync(() => {
saves.push(configs)
}),
})
const settled = yield* controller.changes().pipe(
Stream.filter((state) => state.servers[0]?.stage === "failed"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* controller.start({ ...config, target: "ssh devbox whoami", name: "Invalid edit" }, 1)
yield* Fiber.join(settled)
expect((yield* controller.state()).servers[0]?.config).toEqual(config)
yield* controller.forget("missing")
expect(saves).toEqual([[config]])
}),
)
it.live(
"disconnect interrupts a live SSH handshake and releases endpoint waiters",
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const config = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "ssh-handshake-test-" }), "config")
yield* fs.writeFileString(config, "")
const connected = yield* Deferred.make<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")),
)
-365
View File
@@ -1,365 +0,0 @@
import { NodeSocketServer } from "@effect/platform-node"
import {
Cause,
Clock,
Deferred,
Effect,
Exit,
Fiber,
FileSystem,
Path,
PubSub,
Ref,
Schedule,
Scope,
Stream,
} from "effect"
import { HttpClient } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { SshConfig, SshHttp, SshItem, SshStart, SshState } from "@opencode/app/ssh"
import { createAskpass } from "./askpass"
import { bootstrap } from "./bootstrap"
import { parseTarget, quote, runSsh, sshArgs, sshExecutable, tunnelArgs, SshFailure } from "./command"
type Connection = {
owner?: number
before?: SshItem
ready: Deferred.Deferred<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,
authenticatingElsewhere:
item.stage === "authentication" &&
attempts.get(item.config.id)?.owner !== undefined &&
attempts.get(item.config.id)?.owner !== owner,
})),
}))
const update = Effect.fnUntraced(function* (id: string, value: Partial<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)
// A second window must not replace an interactive attempt while its owner
// is connecting or answering a challenge.
if (previous?.owner !== undefined && previous.owner !== owner && items.get(id)?.stage !== "ready") return
const config = { id, target: request.target.trim(), name: request.name.trim() }
const connection: Connection = {
owner,
before: items.get(id),
ready: yield* Deferred.make<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)
yield* update(id, { prompt: undefined })
}),
),
Effect.forkIn(lifetime, { uninterruptible: false }),
)
attempts.set(id, Object.assign(connection, { fiber }))
items.set(id, {
config,
saved: items.get(id)?.saved ?? false,
http: items.get(id)?.http,
stage: "connecting",
detail: "",
})
paused.delete(id)
if (!request.background) failures.delete(id)
yield* emit
yield* Deferred.succeed(admitted, undefined)
}, Effect.uninterruptible)
const disconnect = Effect.fn("Ssh.disconnect")(function* (id: string) {
paused.add(id)
const attempt = attempts.get(id)
yield* update(id, {
stage: "disconnected",
prompt: undefined,
...(configs.has(id) ? { config: configs.get(id) } : {}),
})
if (attempt) yield* Fiber.interrupt(attempt.fiber)
})
const cancel = Effect.fn("Ssh.cancel")(function* (id: string, owner: number) {
const attempt = attempts.get(id)
if (!attempt || attempt.owner !== owner) return
paused.add(id)
// Restore before interrupting: askpass cleanup must not transition the
// cancelled attempt back to connecting or expose an expired prompt.
const before = attempt.before
yield* update(id, {
...before,
stage:
before?.stage === "authentication" || before?.stage === "failed" || before?.stage === "incompatible"
? before.stage
: "disconnected",
prompt: undefined,
error: before?.error,
detail: before?.detail ?? "",
})
yield* Fiber.interrupt(attempt.fiber)
})
const close = Effect.gen(function* () {
if (lifecycle.closed) return
lifecycle.closed = true
yield* Effect.forEach([...attempts.keys()], disconnect, { concurrency: "unbounded", discard: true })
yield* Scope.close(lifetime, Exit.void)
})
yield* Effect.addFinalizer(() => close)
return {
state,
changes: (owner?: number) => Stream.fromPubSub(changed).pipe(Stream.mapEffect(() => state(owner))),
start,
resolve: Effect.fn("Ssh.resolve")(function* (id: string) {
const item = items.get(id)
if (lifecycle.closed || !item || paused.has(id)) return null
if (item.stage === "ready" && item.http) {
const healthy = yield* checkHealth(item.http).pipe(Effect.provideService(HttpClient.HttpClient, httpClient))
if (lifecycle.closed || paused.has(id)) return null
// Another window may already have replaced this tunnel during the probe.
if (items.get(id) === item) {
if (healthy) return item.http
yield* start({ ...item.config, background: true })
}
}
if (!attempts.has(id) && items.has(id)) yield* start({ ...item.config, background: true })
const attempt = attempts.get(id)
return attempt ? yield* Deferred.await(attempt.ready) : null
}),
respond: Effect.fn("Ssh.respond")(function* (id: string, prompt: string, value: string, owner: number) {
const attempt = attempts.get(id)
if (attempt?.owner === owner && attempt.respond) yield* attempt.respond(prompt, value)
}),
disconnect,
cancel,
forget: Effect.fn("Ssh.forget")(function* (id: string) {
yield* disconnect(id)
items.delete(id)
configs.delete(id)
yield* input.save([...configs.values()])
yield* emit
}),
detach: Effect.fn("Ssh.detach")(function* (owner: number) {
yield* Effect.forEach(
[...attempts].filter(([id, attempt]) => attempt.owner === owner && items.get(id)?.stage !== "ready"),
([id]) => cancel(id, owner),
{ concurrency: "unbounded", discard: true },
)
}),
close,
}
})
const freePort = Effect.gen(function* () {
const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 })
if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection"))
return server.address.port
}).pipe(Effect.scoped)
const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authenticating: () => boolean) {
const clock = { deadline: (yield* Clock.currentTimeMillis) + 30_000 }
yield* Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
if (authenticating()) clock.deadline = now + 30_000
if (now >= clock.deadline) return yield* Effect.fail(new SshFailure("service"))
return yield* checkHealth(http)
}).pipe(Effect.repeat({ until: (ready) => ready, schedule: Schedule.spaced(100) }))
})
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
const client = yield* HttpClient.HttpClient
return yield* client
.get(`${http.url}/api/health`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
})
.pipe(
Effect.timeout(2000),
Effect.map((response) => response.status >= 200 && response.status < 300),
Effect.orElseSucceed(() => false),
)
})
-90
View File
@@ -1,90 +0,0 @@
export * as Ssh from "./service"
import { Context, Effect, Fiber, FileSystem, Layer, Path, Schema, Scope, Stream } from "effect"
import { NodeChildProcessSpawner } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { app, shell, type WebContents } from "electron"
import { homedir } from "node:os"
import { SshConfig, type SshState } from "@opencode/app/ssh"
import { SshChanged } from "../../shared/ipc-rpc/events"
import { DesktopCli } from "../service/desktop-cli"
import { Shutdown } from "../lifecycle/shutdown"
import { getStore } from "../storage/store"
import { emitIpcEvent } from "../ipc-events"
import { createSshController } from "./controller"
import { sshHosts, SshFailure } from "./command"
export class Service extends Context.Service<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))
@@ -9,7 +9,8 @@ afterEach(async () => {
})
// Drives the updater the way the app does: start or check, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
// operations in order; downloads record whether a differential download was allowed and installs record the staged
// version they would apply.
function setup(input?: {
currentVersion?: string
ready?: { version: string }
@@ -29,10 +30,11 @@ function setup(input?: {
},
catch: (error) => error,
}),
stageUpdate: Effect.tryPromise(async () => {
calls.push("download")
await input?.stage?.()
}),
stageUpdate: (options) =>
Effect.tryPromise(async () => {
calls.push(options.differential ? "download" : "download:full")
await input?.stage?.()
}),
installAndRestart: Effect.suspend(() => {
calls.push(`install:${ready?.version}`)
return Effect.tryPromise({
@@ -76,6 +78,7 @@ describe("updater", () => {
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
@@ -90,15 +93,37 @@ describe("updater", () => {
expect(app.getReady()).toBeUndefined()
})
test("revalidates a persisted target through the updater cache on launch", async () => {
test("revalidates a persisted target through the updater cache on launch without a differential download", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.calls).toEqual(["check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("keeps differential downloads after the persisted target was installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" }, latest: () => "3.0.0" })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
expect(app.getReady()).toEqual({ version: "3.0.0" })
})
test("downloads newer releases in full once one is staged", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.updater.start()
latest = "3.0.0"
await app.updater.check()
expect(app.calls).toEqual(["check", "download", "check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
})
test("concurrent checks share one platform check", async () => {
const app = setup()
@@ -140,7 +165,7 @@ describe("updater", () => {
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
@@ -220,7 +245,7 @@ describe("updater", () => {
await refresh
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
})
test("returns to ready after a failed installation and allows a retry", async () => {

Some files were not shown because too many files have changed in this diff Show More