diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 9d49009a5c51..25884ede2c86 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,7 +1,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyID } from "@opencode-ai/core/pty/schema" -import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket" +import { PTY_CONNECT_PATH, PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization, PtyConnectAuthorization } from "../middleware/authorization" @@ -34,7 +34,7 @@ export const PtyPaths = { update: `${root}/:ptyID`, remove: `${root}/:ptyID`, connectToken: `${root}/:ptyID/connect-token`, - connect: `${root}/:ptyID/connect`, + connect: PTY_CONNECT_PATH, } as const export const PtyApi = HttpApi.make("pty") diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 61ce39ad39e0..10f50a4bd29b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -2,7 +2,7 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" -import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { PTY_CONNECT_PATH, PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket" import { isPublicUIPath } from "@/server/shared/public-ui" export { Authorization as ServerAuthorization, @@ -139,8 +139,11 @@ export const ptyConnectAuthorizationLayer = Layer.effect( return PtyConnectAuthorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + const route = yield* HttpRouter.RouteContext + // Use the route selected by Effect so authorization and dispatch cannot disagree about the request path. const url = new URL(request.url, "http://localhost") - if (hasPtyConnectTicketURL(url)) return yield* effect + if (route.route.path === PTY_CONNECT_PATH && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)) + return yield* effect return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateCredential(effect, credential, config)), ) diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts index 0efd06e6a7ab..53008f24443d 100644 --- a/packages/opencode/src/server/shared/pty-ticket.ts +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -1,15 +1,4 @@ export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" - -const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ - -// Auth middleware skips Basic Auth when this matches; the PTY connect handler -// is then responsible for validating the ticket. -export function isPtyConnectPath(pathname: string) { - return PTY_CONNECT_PATH.test(pathname) -} - -export function hasPtyConnectTicketURL(url: URL) { - return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) -} +export const PTY_CONNECT_PATH = "/pty/:ptyID/connect" diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index a40b6c4b5332..501baaae4839 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -9,14 +9,7 @@ import { LocationQuery, locationQueryOpenApi } from "./location" export const PTY_CONNECT_TICKET_QUERY = "ticket" export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" - -const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ - -// Authorization middleware skips credential checks when this matches; the PTY connect handler -// is then responsible for consuming and validating the ticket. -export function hasPtyConnectTicketURL(url: URL) { - return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) -} +export const PTY_CONNECT_PATH = "/api/pty/:ptyID/connect" export const PtyGroup = HttpApiGroup.make("server.pty") .add( @@ -116,7 +109,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") .add( // Query fields are decoded in the raw handler after the existence check so a missing // session responds with an empty 404 before any upgrade work. - HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { + HttpApiEndpoint.get("pty.connect", PTY_CONNECT_PATH, { params: { ptyID: Pty.ID }, success: Schema.Boolean, error: [ForbiddenError, PtyNotFoundError], diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index cc785ee2472a..bfa4c0a6322e 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -2,9 +2,9 @@ import { ServerAuth } from "../auth" import { UnauthorizedError } from "@opencode-ai/protocol/errors" import { Authorization } from "@opencode-ai/protocol/middleware/authorization" export { Authorization } from "@opencode-ai/protocol/middleware/authorization" -import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" +import { PTY_CONNECT_PATH, PTY_CONNECT_TICKET_QUERY } from "@opencode-ai/protocol/groups/pty" import { Effect, Encoding, Layer, Redacted } from "effect" -import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" const AUTH_TOKEN_QUERY = "auth_token" const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' @@ -43,9 +43,13 @@ export const authorizationLayer = Layer.effect( return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + const route = yield* HttpRouter.RouteContext // Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips - // credential checks here; the connect handler consumes and validates the ticket. - if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect + // credential checks here; the connect handler consumes and validates the ticket. Use the + // route selected by Effect so authorization and dispatch cannot disagree about the request path. + const url = new URL(request.url, "http://localhost") + if (route.route.path === PTY_CONNECT_PATH && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)) + return yield* effect const credential = yield* credentialFromRequest(request) if (ServerAuth.authorized(credential, config)) return yield* effect yield* HttpEffect.appendPreResponseHandler((_request, response) =>