Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/context/server.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
51 changes: 30 additions & 21 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -46,6 +73,7 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base

export type Sidecar = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}>
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/utils/server.test.ts
Original file line number Diff line number Diff line change
@@ -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"))
})
})
18 changes: 17 additions & 1 deletion packages/app/src/utils/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 })}`,
}
})()

Expand Down
18 changes: 17 additions & 1 deletion packages/app/src/utils/terminal-websocket-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"))
})
})
10 changes: 8 additions & 2 deletions packages/app/src/utils/terminal-websocket-url.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { authTokenFromCredentials } from "@/utils/server"

export function terminalWebSocketURL(input: {
url: string
id: string
Expand All @@ -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
}
Loading