summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-05-03 17:13:42 -0400
committerGitHub <[email protected]>2026-05-03 21:13:42 +0000
commitca6150d6f092cc8761d6072b0b07b6a7de8748cf (patch)
tree2e62b173530b8bb8f1d0d9583764217d3b7a888b
parent825ab2e38d1f41074bb536b6ba5771f30594b197 (diff)
downloadopencode-ca6150d6f092cc8761d6072b0b07b6a7de8748cf.tar.gz
opencode-ca6150d6f092cc8761d6072b0b07b6a7de8748cf.zip
fix(app): preserve auth token credentials (#25636)
-rw-r--r--packages/app/src/components/terminal.tsx11
-rw-r--r--packages/app/src/context/server.test.ts53
-rw-r--r--packages/app/src/context/server.tsx51
-rw-r--r--packages/app/src/entry.tsx19
-rw-r--r--packages/app/src/utils/server.test.ts23
-rw-r--r--packages/app/src/utils/server.ts18
-rw-r--r--packages/app/src/utils/terminal-websocket-url.test.ts18
-rw-r--r--packages/app/src/utils/terminal-websocket-url.ts10
8 files changed, 176 insertions, 27 deletions
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 998936bc6..d4212e32e 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => {
drop?.()
const socket = new WebSocket(
- terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }),
+ terminalWebSocketURL({
+ url,
+ id,
+ directory,
+ cursor: seek,
+ sameOrigin,
+ username,
+ password,
+ authToken: server.current?.type === "http" ? server.current.authToken : false,
+ }),
)
socket.binaryType = "arraybuffer"
ws = socket
diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts
new file mode 100644
index 000000000..1fa35247c
--- /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 1204fba55..a981d99fa 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<ServerConnection.Any>
+ stored: StoredServer[]
+}): Array<ServerConnection.Any> {
+ const servers = [
+ ...input.stored.map((value) =>
+ typeof value === "string"
+ ? {
+ type: "http" as const,
+ http: { url: value },
+ }
+ : value,
+ ),
+ ...(input.props ?? []),
+ ]
+
+ const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
+ 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<ServerConnection.Any> => {
- 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/entry.tsx b/packages/app/src/entry.tsx
index ade572c2f..5115f0348 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(
() => (
<PlatformProvider value={platform}>
diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts
new file mode 100644
index 000000000..4666b7d6d
--- /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 ae849b71e..603784e4d 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
index c85863abd..5fa1506b1 100644
--- a/packages/app/src/utils/terminal-websocket-url.test.ts
+++ b/packages/app/src/utils/terminal-websocket-url.test.ts
@@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => {
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
- test("omits query auth for same-origin websocket URL", () => {
+ test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
@@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => {
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
index d364762d7..c1c7abad4 100644
--- a/packages/app/src/utils/terminal-websocket-url.ts
+++ b/packages/app/src/utils/terminal-websocket-url.ts
@@ -1,3 +1,5 @@
+import { authTokenFromCredentials } from "@/utils/server"
+
export function terminalWebSocketURL(input: {
url: string
id: string
@@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: {
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.sameOrigin && input.password)
- next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`))
+ if (input.password && (!input.sameOrigin || input.authToken))
+ next.searchParams.set(
+ "auth_token",
+ authTokenFromCredentials({ username: input.username, password: input.password }),
+ )
return next
}