mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-24 09:37:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a9aa2556 |
@@ -451,6 +451,7 @@ export const dict = {
|
||||
"server.connect.scan": "Scan QR code",
|
||||
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
|
||||
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
|
||||
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
|
||||
"server.connect.camera": "Pairing camera",
|
||||
"server.connect.camera.starting": "Opening camera…",
|
||||
"server.connect.mixedContent":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingLink, pairingUrl } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
@@ -93,3 +93,20 @@ describe("pairing scan", () => {
|
||||
expect(decodePairingScan("not a code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pairing link", () => {
|
||||
test("reads the server address and code from opencode pair links", () => {
|
||||
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
|
||||
url: "http://192.168.1.2:49374",
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects other URLs", () => {
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
|
||||
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
|
||||
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
@@ -59,3 +60,22 @@ export function decodePairingUrl(value: string, origin?: string) {
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
|
||||
}
|
||||
|
||||
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
|
||||
export function pairingLink(value: string) {
|
||||
const url = URL.parse(value.trim())
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
||||
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
|
||||
const address = serverAddress(url.origin)
|
||||
if (!code || !address) return
|
||||
return { url: address, code }
|
||||
}
|
||||
|
||||
export function redeemPairingLink(link: { url: string; code: string }) {
|
||||
return OpenCode.make({ baseUrl: link.url })
|
||||
.server.connect({ code: link.code })
|
||||
.then(
|
||||
(session) => ({ urls: [link.url], password: session.token }),
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingScan } from "./pairing"
|
||||
import { decodePairingScan, pairingLink, redeemPairingLink } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
@@ -23,12 +23,21 @@ export function PairingScanner(props: {
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingScan(result.data)
|
||||
if (!pairing) {
|
||||
if (pairing) {
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
return
|
||||
}
|
||||
const link = pairingLink(result.data)
|
||||
if (!link) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
}
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
void redeemPairingLink(link).then((redeemed) => {
|
||||
if (redeemed) return props.onScan(redeemed)
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
})
|
||||
},
|
||||
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
@@ -39,6 +39,16 @@ export function ConnectServerScreen(
|
||||
)
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const link = pairingLink(state.url)
|
||||
if (link) {
|
||||
const redeemed = await redeemPairingLink(link)
|
||||
if (!redeemed) {
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
return
|
||||
}
|
||||
// Keep the token in the form so a failed connection check can retry without the spent code.
|
||||
setState({ url: link.url, password: redeemed.password })
|
||||
}
|
||||
const url = serverAddress(state.url)
|
||||
if (!url) {
|
||||
setState("error", language.t("server.connect.address.invalid"))
|
||||
|
||||
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
|
||||
skipWaiting: false,
|
||||
inlineWorkboxRuntime: true,
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Pairing links must reach the server so it can set the session cookie.
|
||||
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
|
||||
globPatterns: ["**/*"],
|
||||
globIgnores: ["**/*.map", "_headers", "_redirects"],
|
||||
|
||||
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
},
|
||||
}),
|
||||
Spec.make("pair", {
|
||||
description: "Show server pairing information",
|
||||
description: "Print one-time links to connect a browser or app",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
|
||||
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
const url = new URL(value)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -12,34 +11,25 @@ export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
? [input.url.value]
|
||||
: (yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
const link = info.urls[0]
|
||||
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
|
||||
: undefined
|
||||
: (yield* Effect.tryPromise(() => client.server.info())).urls
|
||||
const pairing = yield* Effect.tryPromise(() => client.server.pair())
|
||||
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` URLs ${info.urls[0] ?? "(none)"}`,
|
||||
...info.urls.slice(1).map((url) => ` ${url}`),
|
||||
` Username ${info.username}`,
|
||||
` Password ${info.password}`,
|
||||
...(link
|
||||
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
|
||||
"",
|
||||
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
|
||||
...(links[0]
|
||||
? [
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(link, { border: 2 })
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
` Link ${link}`,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
@@ -47,8 +37,17 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
if (Option.isSome(input.url)) return
|
||||
const hostname = new URL(endpoint.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
|
||||
const url = new URL(endpoint.url)
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
|
||||
process.stderr.write(
|
||||
[
|
||||
` Over SSH? Forward the port, then open the link on your machine:`,
|
||||
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
|
||||
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
|
||||
"",
|
||||
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,12 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
if (
|
||||
url.pathname === "/api" ||
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname.startsWith("/auth/") ||
|
||||
url.pathname === "/openapi.json"
|
||||
)
|
||||
return yield* api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
|
||||
@@ -68,6 +68,24 @@ describe("web UI", () => {
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
|
||||
|
||||
const pairing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/pair", origin), {
|
||||
method: "POST",
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}).then((response) => response.json() as Promise<{ code: string }>),
|
||||
)
|
||||
const redirect = yield* Effect.promise(() =>
|
||||
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
|
||||
redirect: "manual",
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
)
|
||||
expect(redirect.status).toBe(302)
|
||||
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
|
||||
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
|
||||
expect(authorized.status).toBe(200)
|
||||
yield* Effect.promise(() => authorized.arrayBuffer())
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,8 +47,17 @@ export type ServerInfoOutput = {
|
||||
}
|
||||
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
|
||||
|
||||
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
|
||||
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
|
||||
|
||||
export type ServerConnectInput = { readonly code: string }
|
||||
export type ServerConnectOutput = { readonly token: string }
|
||||
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
|
||||
|
||||
export interface ServerApi<E = never> {
|
||||
readonly info: ServerInfoOperation<E>
|
||||
readonly pair: ServerPairOperation<E>
|
||||
readonly connect: ServerConnectOperation<E>
|
||||
}
|
||||
|
||||
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
|
||||
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -281,7 +284,19 @@ const preserveStream =
|
||||
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
|
||||
const EndpointServerPair = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerPairOutput>()(raw["server.pair"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: ServerConnectInput) =>
|
||||
preserveEffect<ServerConnectOutput>()(
|
||||
raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({
|
||||
info: EndpointServerInfo(raw),
|
||||
pair: EndpointServerPair(raw),
|
||||
connect: EndpointServerConnect(raw),
|
||||
})
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -416,6 +419,22 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
pair: (requestOptions?: RequestOptions) =>
|
||||
request<ServerPairOutput>(
|
||||
{ method: "POST", path: `/api/pair`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
connect: (input: ServerConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerConnectOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth/connect/${encodeURIComponent(input.code)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2,6 +2,10 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
|
||||
|
||||
export type PairingCode = { code: string; expires_in: number }
|
||||
|
||||
export type PairingSession = { token: string }
|
||||
|
||||
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
|
||||
|
||||
export type LocationPublicRef = { directory: string }
|
||||
@@ -2696,6 +2700,12 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
export type ServerPairOutput = PairingCode
|
||||
|
||||
export type ServerConnectInput = { readonly code: { readonly code: string }["code"] }
|
||||
|
||||
export type ServerConnectOutput = PairingSession
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { UnauthorizedError } from "../errors.js"
|
||||
|
||||
export const ServerInfo = Schema.Struct({
|
||||
version: Schema.String,
|
||||
@@ -12,6 +13,24 @@ export const ServerInfo = Schema.Struct({
|
||||
}).annotate({ identifier: "ServerInfo" })
|
||||
export type ServerInfo = typeof ServerInfo.Type
|
||||
|
||||
export const PairingCode = Schema.Struct({
|
||||
code: Schema.String,
|
||||
expires_in: Schema.Int,
|
||||
}).annotate({ identifier: "PairingCode" })
|
||||
export type PairingCode = typeof PairingCode.Type
|
||||
|
||||
export const PairingSession = Schema.Struct({
|
||||
token: Schema.String,
|
||||
}).annotate({ identifier: "PairingSession" })
|
||||
export type PairingSession = typeof PairingSession.Type
|
||||
|
||||
const PAIRING_CONNECT_PATH = /^\/auth\/connect\/[^/]+$/
|
||||
|
||||
// Authorization middleware skips credential checks for pairing links; the connect handler consumes the code instead.
|
||||
export function isPairingConnectURL(url: URL) {
|
||||
return PAIRING_CONNECT_PATH.test(url.pathname)
|
||||
}
|
||||
|
||||
export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.info", "/api/info", {
|
||||
@@ -24,4 +43,29 @@ export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("server.pair", "/api/pair", {
|
||||
success: PairingCode,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "server.pair",
|
||||
summary: "Create pairing code",
|
||||
description: "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.connect", "/auth/connect/:code", {
|
||||
params: { code: Schema.String },
|
||||
success: PairingSession,
|
||||
error: UnauthorizedError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "server.connect",
|
||||
summary: "Redeem pairing code",
|
||||
description:
|
||||
"Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "server" }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ServerAuth from "./auth"
|
||||
|
||||
import { Context, Layer, Option, Redacted } from "effect"
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
|
||||
export type DecodedCredentials = {
|
||||
readonly username: string
|
||||
@@ -12,6 +13,8 @@ export type Info = {
|
||||
readonly username: string
|
||||
}
|
||||
|
||||
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
|
||||
static configLayer(input: Pick<Info, "password">) {
|
||||
return Layer.succeed(this, this.of({ ...input, username: "opencode" }))
|
||||
@@ -26,10 +29,38 @@ export function required(config: Info) {
|
||||
return Option.isSome(config.password) && config.password.value !== ""
|
||||
}
|
||||
|
||||
// Session tokens issued by pairing links are accepted anywhere the password is.
|
||||
export function authorized(credentials: DecodedCredentials, config: Info) {
|
||||
return (
|
||||
Option.isSome(config.password) &&
|
||||
credentials.username === config.username &&
|
||||
Redacted.value(credentials.password) === config.password.value
|
||||
)
|
||||
if (Option.isNone(config.password) || credentials.username !== config.username) return false
|
||||
const password = Redacted.value(credentials.password)
|
||||
return password === config.password.value || verifySession(password, config)
|
||||
}
|
||||
|
||||
// Sessions are signed with a key derived from the server password, so rotating the password revokes every session.
|
||||
export function issueSession(config: Info, now = Date.now()) {
|
||||
if (Option.isNone(config.password)) return
|
||||
const expires = String(Math.floor(now / 1000) + SESSION_TTL_SECONDS)
|
||||
return `${expires}.${sign(config.password.value, expires)}`
|
||||
}
|
||||
|
||||
export function verifySession(token: string, config: Info, now = Date.now()) {
|
||||
if (Option.isNone(config.password)) return false
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 2) return false
|
||||
const expires = Number(parts[0])
|
||||
if (!Number.isSafeInteger(expires) || expires * 1000 <= now) return false
|
||||
const expected = Buffer.from(sign(config.password.value, parts[0]))
|
||||
const actual = Buffer.from(parts[1])
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
// Browsers share cookies across ports on the same host, so the name carries the port to keep local servers apart.
|
||||
export function sessionCookieName(host: string | undefined) {
|
||||
const port = URL.parse(`http://${host ?? ""}`)?.port
|
||||
return port ? `opencode_session_${port}` : "opencode_session"
|
||||
}
|
||||
|
||||
function sign(password: string, payload: string) {
|
||||
const key = createHmac("sha256", password).update("opencode-session-v1").digest()
|
||||
return createHmac("sha256", key).update(payload).digest("base64url")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import { Effect } from "effect"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { UnauthorizedError } from "@opencode/protocol/errors"
|
||||
import { Api } from "../api"
|
||||
import { ServerAuth } from "../auth"
|
||||
import { ServerInfo } from "../server-info"
|
||||
import { ServerPairing } from "../pairing"
|
||||
|
||||
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
|
||||
handlers.handle("server.info", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid ?? 0,
|
||||
urls: info.urls(),
|
||||
paths: info.paths,
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const pairing = yield* ServerPairing.Service
|
||||
const auth = yield* ServerAuth.Config
|
||||
|
||||
return handlers
|
||||
.handle("server.info", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid ?? 0,
|
||||
urls: info.urls(),
|
||||
paths: info.paths,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle("server.pair", () => pairing.issue())
|
||||
.handle(
|
||||
"server.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
// Browser navigations ask for HTML; everything else is an API client that wants the token.
|
||||
const browser = request.headers.accept?.includes("text/html") === true
|
||||
const token = (yield* pairing.consume(ctx.params.code)) ? ServerAuth.issueSession(auth) : undefined
|
||||
if (token === undefined) {
|
||||
if (!browser) return yield* new UnauthorizedError({ message: "Pairing link expired or already used" })
|
||||
return HttpServerResponse.text(
|
||||
"This pairing link expired or was already used. Run `opencode pair` to get a new one.",
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
if (!browser) return { token }
|
||||
return HttpServerResponse.redirect("/").pipe(
|
||||
HttpServerResponse.setCookieUnsafe(ServerAuth.sessionCookieName(request.headers.host), token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: Duration.seconds(ServerAuth.SESSION_TTL_SECONDS),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Authorization } from "@opencode/protocol/middleware/authorization"
|
||||
export { Authorization } from "@opencode/protocol/middleware/authorization"
|
||||
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
|
||||
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -37,7 +38,18 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
}
|
||||
|
||||
export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
|
||||
return credentialFromRequest(request).pipe(Effect.map((credential) => ServerAuth.authorized(credential, config)))
|
||||
return credentialFromRequest(request).pipe(
|
||||
Effect.map((credential) => ServerAuth.authorized(credential, config) || authorizedSessionCookie(request, config)),
|
||||
)
|
||||
}
|
||||
|
||||
function authorizedSessionCookie(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
|
||||
const token = request.cookies[ServerAuth.sessionCookieName(request.headers.host)]
|
||||
if (!token) return false
|
||||
// Same-site pages on other ports still send this cookie, so only same-origin requests may use it.
|
||||
const origin = request.headers.origin
|
||||
if (origin !== undefined && URL.parse(origin)?.host !== request.headers.host) return false
|
||||
return ServerAuth.verifySession(token, config)
|
||||
}
|
||||
|
||||
export const authorizationLayer = Layer.effect(
|
||||
@@ -48,10 +60,11 @@ export const authorizationLayer = Layer.effect(
|
||||
return Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
|
||||
// credential checks here; the connect handler consumes and validates the ticket.
|
||||
// Ticketed PTY connects (browsers cannot set headers on WebSocket upgrades) and pairing links
|
||||
// skip credential checks here; their handlers consume and validate the ticket or code.
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
|
||||
return yield* effect
|
||||
if (yield* authorizedRequest(request, config)) return yield* effect
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export * as ServerPairing from "./pairing"
|
||||
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
const TTL = Duration.minutes(5)
|
||||
|
||||
export interface Interface {
|
||||
readonly issue: () => Effect.Effect<{ readonly code: string; readonly expires_in: number }>
|
||||
readonly consume: (code: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ServerPairing") {}
|
||||
|
||||
// Codes are inserted via Cache.set and removed via invalidateWhen, so the lookup never runs.
|
||||
const noLookup = () => Effect.die(new Error("ServerPairing cache must be used via set/invalidateWhen, never get"))
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* Cache.make<string, true>({ capacity: 1_000, lookup: noLookup, timeToLive: TTL })
|
||||
return Service.of({
|
||||
issue: Effect.fn("ServerPairing.issue")(function* () {
|
||||
const code = randomBytes(16).toString("base64url")
|
||||
yield* Cache.set(cache, code, true)
|
||||
return { code, expires_in: Duration.toSeconds(TTL) }
|
||||
}),
|
||||
consume: Effect.fn("ServerPairing.consume")(function* (code) {
|
||||
return yield* Cache.invalidateWhen(cache, code, () => true)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -6,6 +6,7 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
|
||||
import { InstallationEvent } from "@opencode/schema/installation-event"
|
||||
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
|
||||
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -186,6 +187,7 @@ function dispatch(
|
||||
return yield* infoResponse(status, version, urls, tmp)
|
||||
}
|
||||
if (
|
||||
!isPairingConnectURL(url) &&
|
||||
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
|
||||
!(yield* authorizedRequest(request, auth))
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ import { handlers } from "./handlers"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
import { PtyEnvironment } from "./pty-environment"
|
||||
import { ServerPairing } from "./pairing"
|
||||
import { layer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
@@ -68,6 +69,7 @@ const applicationServiceNodes = [
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
ServerPairing.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
|
||||
@@ -7,3 +7,18 @@ test("accepts only the fixed opencode username", () => {
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
||||
})
|
||||
|
||||
test("session tokens expire, resist tampering, and are revoked by changing the password", () => {
|
||||
const config = { password: Option.some("secret"), username: "opencode" }
|
||||
const now = Date.now()
|
||||
const token = ServerAuth.issueSession(config, now)
|
||||
if (!token) throw new Error("Expected a session token")
|
||||
expect(ServerAuth.verifySession(token, config, now)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make(token) }, config)).toBe(true)
|
||||
expect(ServerAuth.verifySession(token, config, now + ServerAuth.SESSION_TTL_SECONDS * 1000)).toBe(false)
|
||||
expect(ServerAuth.verifySession(`${token}x`, config, now)).toBe(false)
|
||||
const parts = token.split(".")
|
||||
expect(ServerAuth.verifySession(`${Number(parts[0]) + 1}.${parts[1]}`, config, now)).toBe(false)
|
||||
expect(ServerAuth.verifySession(token, { ...config, password: Option.some("rotated") }, now)).toBe(false)
|
||||
expect(ServerAuth.issueSession({ ...config, password: Option.none() })).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -164,6 +164,62 @@ it.live("authenticates API requests behind the frontend transform while allowing
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pairing links sign in browsers with a cookie and API clients with a token", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const request = (pathname: string, init?: RequestInit) =>
|
||||
Effect.promise(() => fetch(new URL(pathname, base), { redirect: "manual", ...init }))
|
||||
const pair = Effect.gen(function* () {
|
||||
const response = yield* request("/api/pair", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (yield* Effect.promise(() => response.json())) as { code: string; expires_in: number }
|
||||
})
|
||||
|
||||
expect((yield* request("/api/pair", { method: "POST" })).status).toBe(401)
|
||||
|
||||
const browser = yield* pair
|
||||
expect(browser.expires_in).toBe(300)
|
||||
const redirect = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
|
||||
expect(redirect.status).toBe(302)
|
||||
expect(redirect.headers.get("location")).toBe("/")
|
||||
const setCookie = redirect.headers.get("set-cookie") ?? ""
|
||||
expect(setCookie).toContain(`opencode_session_${new URL(base).port}=`)
|
||||
expect(setCookie).toContain("HttpOnly")
|
||||
expect(setCookie).toContain("SameSite=Lax")
|
||||
const cookie = setCookie.split(";")[0]
|
||||
|
||||
const reused = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
|
||||
expect(reused.status).toBe(401)
|
||||
expect(yield* Effect.promise(() => reused.text())).toContain("opencode pair")
|
||||
|
||||
expect((yield* request("/api/info", { headers: { cookie } })).status).toBe(200)
|
||||
expect((yield* request("/api/info", { headers: { cookie, origin: base } })).status).toBe(200)
|
||||
expect((yield* request("/api/info", { headers: { cookie, origin: "http://127.0.0.1:1" } })).status).toBe(401)
|
||||
expect((yield* request("/api/info", { headers: { cookie: `${cookie}x` } })).status).toBe(401)
|
||||
|
||||
const client = yield* pair
|
||||
const redeemed = yield* request(`/auth/connect/${client.code}`)
|
||||
expect(redeemed.status).toBe(200)
|
||||
const session = (yield* Effect.promise(() => redeemed.json())) as { token: string }
|
||||
expect(
|
||||
(yield* request("/api/info", { headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` } }))
|
||||
.status,
|
||||
).toBe(200)
|
||||
expect((yield* request(`/auth/connect/${client.code}`)).status).toBe(401)
|
||||
expect((yield* request("/auth/connect/unknown")).status).toBe(401)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
|
||||
@@ -362,15 +362,14 @@ $ opencode serve --help
|
||||
|
||||
## pair
|
||||
|
||||
Shows server pairing information, including URLs, credentials, and a QR code. The
|
||||
QR code contains a direct link to the advertised server, with credentials in the
|
||||
URL fragment.
|
||||
Prints one-time links, plus a QR code of the first one, that sign a browser or
|
||||
app in to the server. Links expire after 5 minutes and work once.
|
||||
|
||||
```bash
|
||||
$ opencode pair
|
||||
```
|
||||
|
||||
Advertise an external URL in the QR code and link.
|
||||
Use an external URL in the links.
|
||||
|
||||
```bash
|
||||
$ opencode pair --url https://dev.example.com
|
||||
|
||||
@@ -11,20 +11,26 @@ TUI. It's available by default and password protected.
|
||||
```bash
|
||||
$ opencode pair
|
||||
|
||||
URLs http://127.0.0.1:49374
|
||||
Username opencode
|
||||
Password ********
|
||||
Open a link to connect. Links work once and expire in 5 minutes.
|
||||
|
||||
Scan to pair
|
||||
http://127.0.0.1:49374/auth/connect/...
|
||||
|
||||
█▀▀▀▀▀█ ...
|
||||
|
||||
Link http://127.0.0.1:49374/connect#...
|
||||
```
|
||||
|
||||
The QR code and link open the advertised server directly. Connection credentials
|
||||
stay in the URL fragment and are saved by the `/connect` page before it starts
|
||||
authenticated requests.
|
||||
Opening a link in a browser signs it in with a session cookie and loads the web
|
||||
ui. Scanning the QR code from the OpenCode app, or pasting the link into its
|
||||
server address field, connects the app the same way. Sessions last 30 days;
|
||||
changing the server password signs every session out.
|
||||
|
||||
### Over SSH
|
||||
|
||||
When the server listens only on localhost, forward its port from your machine
|
||||
and open the printed link locally:
|
||||
|
||||
```bash
|
||||
$ ssh -L 49374:127.0.0.1:49374 my-server
|
||||
```
|
||||
|
||||
By default the server runs on port 49374 and listens only on localhost. You can
|
||||
change this config with the `opencode service` command.
|
||||
|
||||
Reference in New Issue
Block a user