-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
- {!tauriApi() && }
-
-
+
+
+
+ {!tauriApi() && }
+
+
+
)
diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts
new file mode 100644
index 0000000000..1fa35247c8
--- /dev/null
+++ b/packages/app/src/context/server.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test"
+import { resolveServerList, ServerConnection } from "./server"
+
+describe("resolveServerList", () => {
+ test("lets startup auth_token credentials override a persisted same-url server", () => {
+ const list = resolveServerList({
+ stored: [{ url: "https://server.example.test" }],
+ props: [
+ {
+ type: "http",
+ authToken: true,
+ http: {
+ url: "https://server.example.test",
+ username: "opencode",
+ password: "secret",
+ },
+ },
+ ],
+ })
+
+ expect(list).toHaveLength(1)
+ expect(list[0]?.type).toBe("http")
+ expect(list[0]?.http).toEqual({
+ url: "https://server.example.test",
+ username: "opencode",
+ password: "secret",
+ })
+ expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
+ expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
+ })
+
+ test("keeps persisted credentials when startup has no auth_token", () => {
+ const list = resolveServerList({
+ stored: [
+ {
+ url: "https://server.example.test",
+ username: "opencode",
+ password: "saved",
+ },
+ ],
+ props: [{ type: "http", http: { url: "https://server.example.test" } }],
+ })
+
+ expect(list).toHaveLength(1)
+ expect(list[0]?.type).toBe("http")
+ expect(list[0]?.http).toEqual({
+ url: "https://server.example.test",
+ username: "opencode",
+ password: "saved",
+ })
+ expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 1204fba557..a981d99fa1 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
+export function resolveServerList(input: {
+ props?: Array
+ stored: StoredServer[]
+}): Array {
+ const servers = [
+ ...input.stored.map((value) =>
+ typeof value === "string"
+ ? {
+ type: "http" as const,
+ http: { url: value },
+ }
+ : value,
+ ),
+ ...(input.props ?? []),
+ ]
+
+ const deduped = new Map()
+ for (const value of servers) {
+ const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
+ const key = ServerConnection.key(conn)
+ if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
+ deduped.set(key, conn)
+ }
+
+ return [...deduped.values()]
+}
+
export namespace ServerConnection {
type Base = { displayName?: string }
@@ -46,6 +73,7 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
+ authToken?: boolean
} & Base
export type Sidecar = {
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array => {
- const servers = [
- ...(props.servers ?? []),
- ...store.list.map((value) =>
- typeof value === "string"
- ? {
- type: "http" as const,
- http: { url: value },
- }
- : value,
- ),
- ]
-
- const deduped = new Map(
- servers.map((value) => {
- const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
- return [ServerConnection.key(conn), conn]
- }),
- )
-
- return [...deduped.values()]
+ return resolveServerList({ stored: store.list, props: props.servers })
})
const [state, setState] = createStore({
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
- const conn = { ...input, http: { ...input.http, url: url_ } }
+ const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts
index 6e07e03124..5bca1b4b7e 100644
--- a/packages/app/src/context/terminal.test.ts
+++ b/packages/app/src/context/terminal.test.ts
@@ -1,6 +1,9 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
-let getWorkspaceTerminalCacheKey: (dir: string) => string
+type ServerKey = Parameters[1]
+
+let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
+let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -17,6 +20,7 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
+ getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
@@ -25,6 +29,45 @@ describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
+
+ test("can include a server scope", () => {
+ expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
+ })
+})
+
+describe("getTerminalServerScope", () => {
+ test("preserves local server keys", () => {
+ expect(
+ getTerminalServerScope(
+ { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
+ "sidecar" as ServerKey,
+ ),
+ ).toBeUndefined()
+ expect(
+ getTerminalServerScope(
+ { type: "http", http: { url: "http://localhost:4096" } },
+ "http://localhost:4096" as ServerKey,
+ ),
+ ).toBeUndefined()
+ expect(
+ getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
+ ).toBeUndefined()
+ })
+
+ test("scopes non-local server keys", () => {
+ expect(
+ getTerminalServerScope(
+ { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
+ "wsl:Debian" as ServerKey,
+ ),
+ ).toBe("wsl:Debian" as ServerKey)
+ expect(
+ getTerminalServerScope(
+ { type: "http", http: { url: "https://example.com" } },
+ "https://example.com" as ServerKey,
+ ),
+ ).toBe("https://example.com" as ServerKey)
+ })
})
describe("getLegacyTerminalStorageKeys", () => {
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 31d2d6e04c..f6751c3f0e 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
+import { ServerConnection, useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -82,10 +83,31 @@ export function migrateTerminalState(value: unknown) {
}
}
-export function getWorkspaceTerminalCacheKey(dir: string) {
+export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
+ if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
return `${dir}:${WORKSPACE_KEY}`
}
+export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
+ if (!conn) return
+ if (conn.type === "sidecar" && conn.variant === "base") return
+ if (conn.type === "http") {
+ try {
+ const url = new URL(conn.http.url)
+ if (
+ url.hostname === "localhost" ||
+ url.hostname === "127.0.0.1" ||
+ url.hostname === "::1" ||
+ url.hostname === "[::1]"
+ )
+ return
+ } catch {
+ return key
+ }
+ }
+ return key
+}
+
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
@@ -110,15 +132,16 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
-export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
- const key = getWorkspaceTerminalCacheKey(dir)
+export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
+ const key = getWorkspaceTerminalCacheKey(dir, scope)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
- void removePersisted(Persist.workspace(dir, "terminal"), platform)
+ void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
+ if (scope) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
-function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) {
- const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
+function createWorkspaceTerminalSession(
+ sdk: ReturnType,
+ dir: string,
+ legacySessionID?: string,
+ scope?: string,
+) {
+ const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
const [store, setStore, _, ready] = persisted(
{
- ...Persist.workspace(dir, "terminal", legacy),
+ ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
@@ -357,8 +385,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
+ const server = useServer()
const params = useParams()
const cache = new Map()
+ const scope = createMemo(() => {
+ return getTerminalServerScope(server.current, server.key)
+ })
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
- const loadWorkspace = (dir: string, legacySessionID?: string) => {
+ const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
- const key = getWorkspaceTerminalCacheKey(dir)
+ const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
- value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
+ value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
dispose,
}))
@@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
- const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
+ const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
createEffect(
on(
- () => ({ dir: params.dir, id: params.id }),
+ () => ({ dir: params.dir, id: params.id, scope: scope() }),
(next, prev) => {
if (!prev?.dir) return
- if (next.dir === prev.dir && next.id === prev.id) return
- if (next.dir === prev.dir && next.id) return
- loadWorkspace(prev.dir, prev.id).trimAll()
+ if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
+ if (next.dir === prev.dir && next.id && next.scope === prev.scope) return
+ loadWorkspace(prev.dir, prev.id, prev.scope).trimAll()
},
{ defer: true },
),
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index ade572c2fd..5115f0348a 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
+import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
return getCurrentUrl()
}
+const clearAuthToken = () => {
+ const params = new URLSearchParams(location.search)
+ if (!params.has("auth_token")) return
+ params.delete("auth_token")
+ history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
+}
+
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
if (root instanceof HTMLElement) {
- const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
+ const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
+ clearAuthToken()
+ const server: ServerConnection.Http = {
+ type: "http",
+ authToken: !!auth,
+ http: {
+ url: getCurrentUrl(),
+ ...auth,
+ },
+ }
render(
() => (
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 7e9e2d32aa..a08372649f 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
-import { clearWorkspaceTerminals } from "@/context/terminal"
+import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -1557,6 +1557,7 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
+ getTerminalServerScope(server.current, server.key),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 2c2d9817f0..d7868d9170 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -37,6 +37,7 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
+ recovered: {} as Record,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
@@ -145,6 +146,21 @@ export function TerminalPanel() {
const all = terminal.all
const ids = createMemo(() => all().map((pty) => pty.id))
+ const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => {
+ if (store.recovered[key]) return
+ setStore("recovered", key, true)
+ void clone(id)
+ }
+
+ const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
+ return String(pty.titleNumber || pty.title || pty.id)
+ }
+
+ const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
+ setStore("recovered", key, false)
+ trim(id)
+ }
+
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -280,9 +296,9 @@ export function TerminalPanel() {
ops.trim(id)}
+ onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
- onConnectError={() => ops.clone(id)}
+ onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
/>
)}
diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts
new file mode 100644
index 0000000000..4666b7d6d0
--- /dev/null
+++ b/packages/app/src/utils/server.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from "bun:test"
+import { authFromToken, authTokenFromCredentials } from "./server"
+
+describe("authFromToken", () => {
+ test("decodes basic auth credentials from auth_token", () => {
+ expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
+ })
+
+ test("defaults blank username to opencode", () => {
+ expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
+ })
+
+ test("ignores malformed tokens", () => {
+ expect(authFromToken("not base64")).toBeUndefined()
+ expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
+ })
+})
+
+describe("authTokenFromCredentials", () => {
+ test("encodes credentials with the default username", () => {
+ expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
+ })
+})
diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts
index ae849b71ee..603784e4d4 100644
--- a/packages/app/src/utils/server.ts
+++ b/packages/app/src/utils/server.ts
@@ -1,5 +1,21 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
+import { decode64 } from "@/utils/base64"
+
+export function authTokenFromCredentials(input: { username?: string; password: string }) {
+ return btoa(`${input.username ?? "opencode"}:${input.password}`)
+}
+
+export function authFromToken(token: string | null) {
+ const decoded = decode64(token ?? undefined)
+ if (!decoded) return
+ const separator = decoded.indexOf(":")
+ if (separator === -1) return
+ return {
+ username: decoded.slice(0, separator) || "opencode",
+ password: decoded.slice(separator + 1),
+ }
+}
export function createSdkForServer({
server,
@@ -10,7 +26,7 @@ export function createSdkForServer({
const auth = (() => {
if (!server.password) return
return {
- Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
+ Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
})()
diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts
new file mode 100644
index 0000000000..5fa1506b1e
--- /dev/null
+++ b/packages/app/src/utils/terminal-websocket-url.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "bun:test"
+import { terminalWebSocketURL } from "./terminal-websocket-url"
+
+describe("terminalWebSocketURL", () => {
+ test("uses query auth without embedding credentials in websocket URL", () => {
+ const url = terminalWebSocketURL({
+ url: "http://127.0.0.1:49365",
+ id: "pty_test",
+ directory: "/tmp/project",
+ cursor: 0,
+ sameOrigin: false,
+ username: "opencode",
+ password: "secret",
+ })
+
+ expect(url.protocol).toBe("ws:")
+ expect(url.username).toBe("")
+ expect(url.password).toBe("")
+ expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
+ })
+
+ test("omits query auth for same-origin saved credentials", () => {
+ const url = terminalWebSocketURL({
+ url: "https://app.example.test",
+ id: "pty_test",
+ directory: "/tmp/project",
+ cursor: 10,
+ sameOrigin: true,
+ username: "opencode",
+ password: "secret",
+ })
+
+ expect(url.protocol).toBe("wss:")
+ expect(url.searchParams.has("auth_token")).toBe(false)
+ })
+
+ test("uses query auth for same-origin credentials from auth_token", () => {
+ const url = terminalWebSocketURL({
+ url: "https://app.example.test",
+ id: "pty_test",
+ directory: "/tmp/project",
+ cursor: 10,
+ sameOrigin: true,
+ username: "opencode",
+ password: "secret",
+ authToken: true,
+ })
+
+ expect(url.protocol).toBe("wss:")
+ expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
+ })
+})
diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts
new file mode 100644
index 0000000000..06facdc7d2
--- /dev/null
+++ b/packages/app/src/utils/terminal-websocket-url.ts
@@ -0,0 +1,28 @@
+import { authTokenFromCredentials } from "@/utils/server"
+
+export function terminalWebSocketURL(input: {
+ url: string
+ id: string
+ directory: string
+ cursor: number
+ ticket?: string
+ sameOrigin?: boolean
+ username?: string
+ password?: string
+ authToken?: boolean
+}) {
+ const next = new URL(`${input.url}/pty/${input.id}/connect`)
+ next.searchParams.set("directory", input.directory)
+ next.searchParams.set("cursor", String(input.cursor))
+ next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
+ if (input.ticket) {
+ next.searchParams.set("ticket", input.ticket)
+ return next
+ }
+ if (input.password && (!input.sameOrigin || input.authToken))
+ next.searchParams.set(
+ "auth_token",
+ authTokenFromCredentials({ username: input.username, password: input.password }),
+ )
+ return next
+}
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index afb9033779..3d07a87cfd 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.14.31",
+ "version": "1.14.39",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts
index b486acb99d..7a4b5ef65e 100644
--- a/packages/console/app/src/routes/download/[channel]/[platform].ts
+++ b/packages/console/app/src/routes/download/[channel]/[platform].ts
@@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types"
const prodAssetNames: Record