Skip to content

Commit 6916a66

Browse files
Merge branch 'dev' into fix-env-caching-12698
2 parents 9e11919 + c2b1974 commit 6916a66

43 files changed

Lines changed: 1557 additions & 939 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/src/components/terminal.tsx

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
1515
import type { LocalPTY } from "@/context/terminal"
1616
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
1717
import { terminalWriter } from "@/utils/terminal-writer"
18+
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
1819

1920
const TOGGLE_TERMINAL_ID = "terminal.toggle"
2021
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => {
6768
console.debug("[terminal]", ...values)
6869
}
6970

70-
const errorName = (err: unknown) => {
71-
if (!err || typeof err !== "object") return
72-
if (!("name" in err)) return
73-
const errorName = err.name
74-
return typeof errorName === "string" ? errorName : undefined
75-
}
76-
7771
const useTerminalUiBindings = (input: {
7872
container: HTMLDivElement
7973
term: Term
@@ -478,10 +472,9 @@ export const Terminal = (props: TerminalProps) => {
478472

479473
const gone = () =>
480474
client.pty
481-
.get({ ptyID: id })
482-
.then(() => false)
475+
.get({ ptyID: id }, { throwOnError: false })
476+
.then((result) => result.response.status === 404)
483477
.catch((err) => {
484-
if (errorName(err) === "NotFoundError") return true
485478
debugTerminal("failed to inspect terminal session", err)
486479
return false
487480
})
@@ -509,18 +502,18 @@ export const Terminal = (props: TerminalProps) => {
509502
if (disposed) return
510503
drop?.()
511504

512-
const next = new URL(url + `/pty/${id}/connect`)
513-
next.searchParams.set("directory", directory)
514-
next.searchParams.set("cursor", String(seek))
515-
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
516-
if (!sameOrigin && password) {
517-
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
518-
// For same-origin requests, let the browser reuse the page's existing auth.
519-
next.username = username
520-
next.password = password
521-
}
522-
523-
const socket = new WebSocket(next)
505+
const socket = new WebSocket(
506+
terminalWebSocketURL({
507+
url,
508+
id,
509+
directory,
510+
cursor: seek,
511+
sameOrigin,
512+
username,
513+
password,
514+
authToken: server.current?.type === "http" ? server.current.authToken : false,
515+
}),
516+
)
524517
socket.binaryType = "arraybuffer"
525518
ws = socket
526519

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { resolveServerList, ServerConnection } from "./server"
3+
4+
describe("resolveServerList", () => {
5+
test("lets startup auth_token credentials override a persisted same-url server", () => {
6+
const list = resolveServerList({
7+
stored: [{ url: "https://server.example.test" }],
8+
props: [
9+
{
10+
type: "http",
11+
authToken: true,
12+
http: {
13+
url: "https://server.example.test",
14+
username: "opencode",
15+
password: "secret",
16+
},
17+
},
18+
],
19+
})
20+
21+
expect(list).toHaveLength(1)
22+
expect(list[0]?.type).toBe("http")
23+
expect(list[0]?.http).toEqual({
24+
url: "https://server.example.test",
25+
username: "opencode",
26+
password: "secret",
27+
})
28+
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
29+
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
30+
})
31+
32+
test("keeps persisted credentials when startup has no auth_token", () => {
33+
const list = resolveServerList({
34+
stored: [
35+
{
36+
url: "https://server.example.test",
37+
username: "opencode",
38+
password: "saved",
39+
},
40+
],
41+
props: [{ type: "http", http: { url: "https://server.example.test" } }],
42+
})
43+
44+
expect(list).toHaveLength(1)
45+
expect(list[0]?.type).toBe("http")
46+
expect(list[0]?.http).toEqual({
47+
url: "https://server.example.test",
48+
username: "opencode",
49+
password: "saved",
50+
})
51+
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
52+
})
53+
})

packages/app/src/context/server.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
3333
if (host === "localhost" || host === "127.0.0.1") return "local"
3434
}
3535

36+
export function resolveServerList(input: {
37+
props?: Array<ServerConnection.Any>
38+
stored: StoredServer[]
39+
}): Array<ServerConnection.Any> {
40+
const servers = [
41+
...input.stored.map((value) =>
42+
typeof value === "string"
43+
? {
44+
type: "http" as const,
45+
http: { url: value },
46+
}
47+
: value,
48+
),
49+
...(input.props ?? []),
50+
]
51+
52+
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
53+
for (const value of servers) {
54+
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
55+
const key = ServerConnection.key(conn)
56+
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
57+
deduped.set(key, conn)
58+
}
59+
60+
return [...deduped.values()]
61+
}
62+
3663
export namespace ServerConnection {
3764
type Base = { displayName?: string }
3865

@@ -46,6 +73,7 @@ export namespace ServerConnection {
4673
export type Http = {
4774
type: "http"
4875
http: HttpBase
76+
authToken?: boolean
4977
} & Base
5078

5179
export type Sidecar = {
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
113141
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
114142

115143
const allServers = createMemo((): Array<ServerConnection.Any> => {
116-
const servers = [
117-
...(props.servers ?? []),
118-
...store.list.map((value) =>
119-
typeof value === "string"
120-
? {
121-
type: "http" as const,
122-
http: { url: value },
123-
}
124-
: value,
125-
),
126-
]
127-
128-
const deduped = new Map(
129-
servers.map((value) => {
130-
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
131-
return [ServerConnection.key(conn), conn]
132-
}),
133-
)
134-
135-
return [...deduped.values()]
144+
return resolveServerList({ stored: store.list, props: props.servers })
136145
})
137146

138147
const [state, setState] = createStore({
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
174183
function add(input: ServerConnection.Http) {
175184
const url_ = normalizeServerUrl(input.http.url)
176185
if (!url_) return
177-
const conn = { ...input, http: { ...input.http, url: url_ } }
186+
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
178187
return batch(() => {
179188
const existing = store.list.findIndex((x) => url(x) === url_)
180189
if (existing !== -1) {

packages/app/src/entry.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
77
import { dict as en } from "@/i18n/en"
88
import { dict as zh } from "@/i18n/zh"
99
import { handleNotificationClick } from "@/utils/notification-click"
10+
import { authFromToken } from "@/utils/server"
1011
import pkg from "../package.json"
1112
import { ServerConnection } from "./context/server"
1213

@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
111112
return getCurrentUrl()
112113
}
113114

115+
const clearAuthToken = () => {
116+
const params = new URLSearchParams(location.search)
117+
if (!params.has("auth_token")) return
118+
params.delete("auth_token")
119+
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
120+
}
121+
114122
const platform: Platform = {
115123
platform: "web",
116124
version: pkg.version,
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
146154
}
147155

148156
if (root instanceof HTMLElement) {
149-
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
157+
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
158+
clearAuthToken()
159+
const server: ServerConnection.Http = {
160+
type: "http",
161+
authToken: !!auth,
162+
http: {
163+
url: getCurrentUrl(),
164+
...auth,
165+
},
166+
}
150167
render(
151168
() => (
152169
<PlatformProvider value={platform}>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { authFromToken, authTokenFromCredentials } from "./server"
3+
4+
describe("authFromToken", () => {
5+
test("decodes basic auth credentials from auth_token", () => {
6+
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
7+
})
8+
9+
test("defaults blank username to opencode", () => {
10+
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
11+
})
12+
13+
test("ignores malformed tokens", () => {
14+
expect(authFromToken("not base64")).toBeUndefined()
15+
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
16+
})
17+
})
18+
19+
describe("authTokenFromCredentials", () => {
20+
test("encodes credentials with the default username", () => {
21+
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
22+
})
23+
})

packages/app/src/utils/server.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
22
import type { ServerConnection } from "@/context/server"
3+
import { decode64 } from "@/utils/base64"
4+
5+
export function authTokenFromCredentials(input: { username?: string; password: string }) {
6+
return btoa(`${input.username ?? "opencode"}:${input.password}`)
7+
}
8+
9+
export function authFromToken(token: string | null) {
10+
const decoded = decode64(token ?? undefined)
11+
if (!decoded) return
12+
const separator = decoded.indexOf(":")
13+
if (separator === -1) return
14+
return {
15+
username: decoded.slice(0, separator) || "opencode",
16+
password: decoded.slice(separator + 1),
17+
}
18+
}
319

420
export function createSdkForServer({
521
server,
@@ -10,7 +26,7 @@ export function createSdkForServer({
1026
const auth = (() => {
1127
if (!server.password) return
1228
return {
13-
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
29+
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
1430
}
1531
})()
1632

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { terminalWebSocketURL } from "./terminal-websocket-url"
3+
4+
describe("terminalWebSocketURL", () => {
5+
test("uses query auth without embedding credentials in websocket URL", () => {
6+
const url = terminalWebSocketURL({
7+
url: "http://127.0.0.1:49365",
8+
id: "pty_test",
9+
directory: "/tmp/project",
10+
cursor: 0,
11+
sameOrigin: false,
12+
username: "opencode",
13+
password: "secret",
14+
})
15+
16+
expect(url.protocol).toBe("ws:")
17+
expect(url.username).toBe("")
18+
expect(url.password).toBe("")
19+
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
20+
})
21+
22+
test("omits query auth for same-origin saved credentials", () => {
23+
const url = terminalWebSocketURL({
24+
url: "https://app.example.test",
25+
id: "pty_test",
26+
directory: "/tmp/project",
27+
cursor: 10,
28+
sameOrigin: true,
29+
username: "opencode",
30+
password: "secret",
31+
})
32+
33+
expect(url.protocol).toBe("wss:")
34+
expect(url.searchParams.has("auth_token")).toBe(false)
35+
})
36+
37+
test("uses query auth for same-origin credentials from auth_token", () => {
38+
const url = terminalWebSocketURL({
39+
url: "https://app.example.test",
40+
id: "pty_test",
41+
directory: "/tmp/project",
42+
cursor: 10,
43+
sameOrigin: true,
44+
username: "opencode",
45+
password: "secret",
46+
authToken: true,
47+
})
48+
49+
expect(url.protocol).toBe("wss:")
50+
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
51+
})
52+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { authTokenFromCredentials } from "@/utils/server"
2+
3+
export function terminalWebSocketURL(input: {
4+
url: string
5+
id: string
6+
directory: string
7+
cursor: number
8+
sameOrigin: boolean
9+
username: string
10+
password?: string
11+
authToken?: boolean
12+
}) {
13+
const next = new URL(`${input.url}/pty/${input.id}/connect`)
14+
next.searchParams.set("directory", input.directory)
15+
next.searchParams.set("cursor", String(input.cursor))
16+
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
17+
if (input.password && (!input.sameOrigin || input.authToken))
18+
next.searchParams.set(
19+
"auth_token",
20+
authTokenFromCredentials({ username: input.username, password: input.password }),
21+
)
22+
return next
23+
}

packages/core/src/filesystem.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export namespace AppFileSystem {
2424
readonly isDir: (path: string) => Effect.Effect<boolean>
2525
readonly isFile: (path: string) => Effect.Effect<boolean>
2626
readonly existsSafe: (path: string) => Effect.Effect<boolean>
27+
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
2728
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
2829
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
2930
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
@@ -47,6 +48,12 @@ export namespace AppFileSystem {
4748
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
4849
})
4950

51+
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
52+
return yield* fs
53+
.readFileString(path)
54+
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
55+
})
56+
5057
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
5158
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
5259
return info?.type === "Directory"
@@ -163,6 +170,7 @@ export namespace AppFileSystem {
163170
return Service.of({
164171
...fs,
165172
existsSafe,
173+
readFileStringSafe,
166174
isDir,
167175
isFile,
168176
readDirectoryEntries,

0 commit comments

Comments
 (0)