Skip to content

Commit ecf064a

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

21 files changed

Lines changed: 659 additions & 31 deletions

File tree

packages/app/src/components/terminal.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,21 @@ export const Terminal = (props: TerminalProps) => {
479479
return false
480480
})
481481

482+
const connectToken = async () => {
483+
const result = await client.pty.connectToken(
484+
{ ptyID: id },
485+
{
486+
throwOnError: false,
487+
headers: { "x-opencode-ticket": "1" },
488+
},
489+
)
490+
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
491+
if ((result.response.status === 404 || result.response.status === 405) && password) return
492+
if (result.response.status === 403)
493+
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
494+
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
495+
}
496+
482497
const retry = (err: unknown) => {
483498
if (disposed) return
484499
if (reconn !== undefined) return
@@ -498,16 +513,24 @@ export const Terminal = (props: TerminalProps) => {
498513
}, ms)
499514
}
500515

501-
const open = () => {
516+
const open = async () => {
502517
if (disposed) return
503518
drop?.()
504519

520+
const ticket = await connectToken().catch((err) => {
521+
fail(err)
522+
return undefined
523+
})
524+
if (once.value) return
525+
if (disposed) return
526+
505527
const socket = new WebSocket(
506528
terminalWebSocketURL({
507529
url,
508530
id,
509531
directory,
510532
cursor: seek,
533+
ticket,
511534
sameOrigin,
512535
username,
513536
password,

packages/app/src/utils/terminal-websocket-url.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@ export function terminalWebSocketURL(input: {
55
id: string
66
directory: string
77
cursor: number
8-
sameOrigin: boolean
9-
username: string
8+
ticket?: string
9+
sameOrigin?: boolean
10+
username?: string
1011
password?: string
1112
authToken?: boolean
1213
}) {
1314
const next = new URL(`${input.url}/pty/${input.id}/connect`)
1415
next.searchParams.set("directory", input.directory)
1516
next.searchParams.set("cursor", String(input.cursor))
1617
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
18+
if (input.ticket) {
19+
next.searchParams.set("ticket", input.ticket)
20+
return next
21+
}
1722
if (input.password && (!input.sameOrigin || input.authToken))
1823
next.searchParams.set(
1924
"auth_token",

packages/opencode/src/effect/app-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { Vcs } from "@/project/vcs"
4646
import { Workspace } from "@/control-plane/workspace"
4747
import { Worktree } from "@/worktree"
4848
import { Pty } from "@/pty"
49+
import { PtyTicket } from "@/pty/ticket"
4950
import { Installation } from "@/installation"
5051
import { ShareNext } from "@/share/share-next"
5152
import { SessionShare } from "@/share/session"
@@ -98,6 +99,7 @@ export const AppLayer = Layer.mergeAll(
9899
Workspace.defaultLayer,
99100
Worktree.appLayer,
100101
Pty.defaultLayer,
102+
PtyTicket.defaultLayer,
101103
Installation.defaultLayer,
102104
ShareNext.defaultLayer,
103105
SessionShare.defaultLayer,

packages/opencode/src/plugin/codex.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com"
1414
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
1515
const OAUTH_PORT = 1455
1616
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
17-
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"])
17+
const ALLOWED_MODELS = new Set([
18+
"gpt-5.5",
19+
"gpt-5.2",
20+
"gpt-5.3-codex",
21+
"gpt-5.3-codex-spark",
22+
"gpt-5.4",
23+
"gpt-5.4-mini",
24+
])
1825

1926
interface PkceCodes {
2027
verifier: string
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
export * as PtyTicket from "./ticket"
2+
3+
import { WorkspaceID } from "@/control-plane/schema"
4+
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
5+
import { PtyID } from "@/pty/schema"
6+
import { PositiveInt } from "@/util/schema"
7+
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
8+
9+
const DEFAULT_TTL = Duration.seconds(60)
10+
const CAPACITY = 10_000
11+
12+
export const ConnectToken = Schema.Struct({
13+
ticket: Schema.String,
14+
expires_in: PositiveInt,
15+
})
16+
17+
export type Scope = {
18+
readonly ptyID: PtyID
19+
readonly directory?: string
20+
readonly workspaceID?: WorkspaceID
21+
}
22+
23+
export interface Interface {
24+
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
25+
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
26+
}
27+
28+
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
29+
30+
function matches(record: Scope, input: Scope) {
31+
return (
32+
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
33+
)
34+
}
35+
36+
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
37+
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
38+
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
39+
40+
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
41+
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
42+
Effect.gen(function* () {
43+
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
44+
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
45+
return Service.of({
46+
issue: Effect.fn("PtyTicket.issue")(function* (input) {
47+
const ticket = crypto.randomUUID()
48+
yield* Cache.set(cache, ticket, input)
49+
return { ticket, expires_in: expiresIn }
50+
}),
51+
consume: Effect.fn("PtyTicket.consume")(function* (input) {
52+
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
53+
}),
54+
})
55+
})
56+
57+
export const layer = Layer.effect(Service, make())
58+
59+
export const defaultLayer = layer
60+
61+
export const scope = Effect.gen(function* () {
62+
const instance = yield* InstanceRef
63+
const workspaceID = yield* WorkspaceRef
64+
return {
65+
directory: instance?.directory,
66+
workspaceID,
67+
}
68+
})

packages/opencode/src/server/cors.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { Context } from "effect"
2+
13
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
24

35
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
46

7+
export const CorsConfig = Context.Reference<CorsOptions | undefined>("@opencode/ServerCorsConfig", {
8+
defaultValue: () => undefined,
9+
})
10+
511
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
612
if (!input) return true
713
if (input.startsWith("http://localhost:")) return true
@@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption
1218
if (opencodeOrigin.test(input)) return true
1319
return opts?.cors?.includes(input) ?? false
1420
}
21+
22+
export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) {
23+
if (!input) return true
24+
if (host && sameHost(input, host)) return true
25+
return isAllowedCorsOrigin(input, opts)
26+
}
27+
28+
function sameHost(origin: string, host: string) {
29+
try {
30+
return new URL(origin).host === host
31+
} catch {
32+
return false
33+
}
34+
}

packages/opencode/src/server/error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const ERRORS = {
2121
},
2222
},
2323
},
24+
403: {
25+
description: "Forbidden",
26+
},
2427
404: {
2528
description: "Not found",
2629
content: {

packages/opencode/src/server/middleware.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { cors } from "hono/cors"
1212
import { compress } from "hono/compress"
1313
import * as ServerBackend from "./backend"
1414
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
15+
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
1516

1617
const log = Log.create({ service: "server" })
1718

@@ -44,6 +45,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
4445
if (c.req.method === "OPTIONS") return next()
4546
const password = Flag.OPENCODE_SERVER_PASSWORD
4647
if (!password) return next()
48+
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
4749
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
4850

4951
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
@@ -58,6 +60,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
5860
const attributes = {
5961
method: c.req.method,
6062
path: c.req.path,
63+
// If this logger grows full-URL fields, redact auth_token and ticket query params.
6164
...backendAttributes,
6265
}
6366
log.info("request", attributes)

packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Pty } from "@/pty"
2+
import { PtyTicket } from "@/pty/ticket"
23
import { PtyID } from "@/pty/schema"
34
import { Schema } from "effect"
45
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -23,6 +24,7 @@ export const PtyPaths = {
2324
get: `${root}/:ptyID`,
2425
update: `${root}/:ptyID`,
2526
remove: `${root}/:ptyID`,
27+
connectToken: `${root}/:ptyID/connect-token`,
2628
connect: `${root}/:ptyID/connect`,
2729
} as const
2830

@@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty")
9395
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
9496
}),
9597
),
98+
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
99+
params: { ptyID: PtyID },
100+
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
101+
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
102+
}).annotateMerge(
103+
OpenApi.annotations({
104+
identifier: "pty.connectToken",
105+
summary: "Create PTY WebSocket token",
106+
description: "Create a short-lived ticket for opening a PTY WebSocket connection.",
107+
}),
108+
),
96109
)
97110
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
98111
.middleware(InstanceContextMiddleware)
@@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
113126
HttpApiEndpoint.get("connect", PtyPaths.connect, {
114127
params: Params,
115128
success: described(Schema.Boolean, "Connected session"),
116-
error: HttpApiError.NotFound,
129+
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
117130
}).annotateMerge(
118131
OpenApi.annotations({
119132
identifier: "pty.connect",

packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { Pty } from "@/pty"
22
import { PtyID } from "@/pty/schema"
3+
import { PtyTicket } from "@/pty/ticket"
34
import { handlePtyInput } from "@/pty/input"
45
import { Shell } from "@/shell/shell"
56
import { EffectBridge } from "@/effect/bridge"
7+
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
8+
import {
9+
PTY_CONNECT_TICKET_QUERY,
10+
PTY_CONNECT_TOKEN_HEADER,
11+
PTY_CONNECT_TOKEN_HEADER_VALUE,
12+
} from "@/server/shared/pty-ticket"
613
import { Effect } from "effect"
714
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
815
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
@@ -11,9 +18,15 @@ import { InstanceHttpApi } from "../api"
1118
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
1219
import { WebSocketTracker } from "../websocket-tracker"
1320

21+
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
22+
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
23+
}
24+
1425
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
1526
Effect.gen(function* () {
1627
const pty = yield* Pty.Service
28+
const tickets = yield* PtyTicket.Service
29+
const cors = yield* CorsConfig
1730

1831
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
1932
return yield* Effect.promise(() => Shell.list())
@@ -54,19 +67,30 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
5467
return true
5568
})
5669

70+
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
71+
const request = yield* HttpServerRequest.HttpServerRequest
72+
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
73+
return yield* new HttpApiError.Forbidden({})
74+
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
75+
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
76+
})
77+
5778
return handlers
5879
.handle("shells", shells)
5980
.handle("list", list)
6081
.handle("create", create)
6182
.handle("get", get)
6283
.handle("update", update)
6384
.handle("remove", remove)
85+
.handle("connectToken", connectToken)
6486
}),
6587
)
6688

6789
export const ptyConnectRoute = HttpRouter.use((router) =>
6890
Effect.gen(function* () {
6991
const pty = yield* Pty.Service
92+
const tickets = yield* PtyTicket.Service
93+
const cors = yield* CorsConfig
7094
yield* router.add(
7195
"GET",
7296
PtyPaths.connect,
@@ -75,12 +99,20 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
7599
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
76100

77101
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
102+
const request = yield* HttpServerRequest.HttpServerRequest
103+
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
104+
if (ticket) {
105+
const valid = validOrigin(request, cors)
106+
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
107+
: false
108+
if (!valid) return HttpServerResponse.empty({ status: 403 })
109+
}
78110
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
79111
const cursor =
80112
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
81113
? parsedCursor
82114
: undefined
83-
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
115+
const socket = yield* Effect.orDie(request.upgrade)
84116
const write = yield* socket.writer
85117
const closeAccepted = (event: Socket.CloseEvent) =>
86118
socket

0 commit comments

Comments
 (0)