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
25 changes: 13 additions & 12 deletions packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { terminalAttr, terminalProbe } from "@/testing/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
import { terminalWriter } from "@/utils/terminal-writer"

const TOGGLE_TERMINAL_ID = "terminal.toggle"
Expand Down Expand Up @@ -523,18 +524,18 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()

const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}

const socket = new WebSocket(next)
const socket = new WebSocket(
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
86 changes: 86 additions & 0 deletions packages/app/src/context/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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()
})

test("keeps persisted display name when startup auth_token credentials override a same-url server", () => {
const list = resolveServerList({
stored: [
{
type: "http",
displayName: "Team server",
http: { 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]?.displayName).toBe("Team server")
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)
})
})
57 changes: 35 additions & 22 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"

type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const HEALTH_POLL_INTERVAL_MS = 10_000

export function normalizeServerUrl(input: string) {
Expand Down Expand Up @@ -46,6 +45,7 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base

export type Sidecar = {
Expand Down Expand Up @@ -92,6 +92,37 @@ export namespace ServerConnection {
export const Key = { make: (v: string) => v as Key }
}

type StoredHttp = Omit<ServerConnection.Http, "authToken"> & { authToken?: never }
export type StoredServer = string | ServerConnection.HttpBase | StoredHttp

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
const existing = deduped.get(key)
deduped.set(key, existing ? { ...existing, ...conn, displayName: conn.displayName ?? existing.displayName } : conn)
}

return [...deduped.values()]
}

export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: {
Expand All @@ -113,26 +144,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 +186,8 @@ 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 { authToken: _authToken, ...persisted } = input
const conn: StoredHttp = { ...persisted, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
Expand Down
20 changes: 19 additions & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { handleNotificationClick } from "@/utils/notification-click"
import { showToast } from "@opencode-ai/ui/toast"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
import { authFromToken } from "@/utils/server"

const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl"

Expand Down Expand Up @@ -146,6 +147,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",
shell: { kind: "desktop", os: detectShellOs() },
Expand All @@ -163,7 +171,17 @@ const platform: Platform = {
}

if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
const authToken = new URLSearchParams(location.search).get("auth_token")
const auth = authFromToken(authToken)
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
53 changes: 53 additions & 0 deletions packages/app/src/utils/terminal-websocket-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"

describe("terminalWebSocketURL", () => {
test("uses query auth for non-same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://server.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: false,
username: "opencode",
password: "secret",
})

expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("10")
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"))
})
})
24 changes: 24 additions & 0 deletions packages/app/src/utils/terminal-websocket-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { authTokenFromCredentials } from "@/utils/server"

export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
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.password && (!input.sameOrigin || input.authToken)) {
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
}
return next
}
Loading
Loading