|
|
@@ -31,8 +31,8 @@ afterEach(async () => {
|
|
|
await resetDatabase()
|
|
|
})
|
|
|
|
|
|
-async function startListener() {
|
|
|
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
|
|
+async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
|
|
|
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
|
|
|
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
|
|
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
|
|
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
|
|
@@ -40,19 +40,53 @@ async function startListener() {
|
|
|
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
|
|
}
|
|
|
|
|
|
+async function startNoAuthListener() {
|
|
|
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
|
|
+ Flag.OPENCODE_SERVER_PASSWORD = undefined
|
|
|
+ Flag.OPENCODE_SERVER_USERNAME = auth.username
|
|
|
+ delete process.env.OPENCODE_SERVER_PASSWORD
|
|
|
+ process.env.OPENCODE_SERVER_USERNAME = auth.username
|
|
|
+ return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
|
|
+}
|
|
|
+
|
|
|
function authorization() {
|
|
|
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
|
|
}
|
|
|
|
|
|
-function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
|
|
+function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
|
|
|
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
|
|
url.protocol = "ws:"
|
|
|
url.searchParams.set("directory", dir)
|
|
|
url.searchParams.set("cursor", "-1")
|
|
|
- url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`))
|
|
|
+ if (ticket) url.searchParams.set("ticket", ticket)
|
|
|
return url
|
|
|
}
|
|
|
|
|
|
+async function requestTicket(
|
|
|
+ listener: Awaited<ReturnType<typeof startListener>>,
|
|
|
+ id: string,
|
|
|
+ dir: string,
|
|
|
+ options?: { ticketHeader?: boolean; origin?: string },
|
|
|
+) {
|
|
|
+ const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
|
|
|
+ method: "POST",
|
|
|
+ headers: {
|
|
|
+ authorization: authorization(),
|
|
|
+ "x-opencode-directory": dir,
|
|
|
+ ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
|
|
|
+ ...(options?.origin ? { origin: options.origin } : {}),
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ return response
|
|
|
+}
|
|
|
+
|
|
|
+async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
|
|
+ const response = await requestTicket(listener, id, dir)
|
|
|
+ expect(response.status).toBe(200)
|
|
|
+ return (await response.json()) as { ticket: string; expires_in: number }
|
|
|
+}
|
|
|
+
|
|
|
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
|
|
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
|
|
method: "POST",
|
|
|
@@ -81,6 +115,28 @@ async function openSocket(url: URL) {
|
|
|
return ws
|
|
|
}
|
|
|
|
|
|
+async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
|
|
|
+ // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
|
|
|
+ const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
|
|
|
+ const ws = new Ctor(url, init)
|
|
|
+ await withTimeout(
|
|
|
+ new Promise<void>((resolve, reject) => {
|
|
|
+ ws.addEventListener(
|
|
|
+ "open",
|
|
|
+ () => {
|
|
|
+ ws.close(1000)
|
|
|
+ reject(new Error("websocket opened"))
|
|
|
+ },
|
|
|
+ { once: true },
|
|
|
+ )
|
|
|
+ ws.addEventListener("error", () => resolve(), { once: true })
|
|
|
+ ws.addEventListener("close", () => resolve(), { once: true })
|
|
|
+ }),
|
|
|
+ 5_000,
|
|
|
+ "timed out waiting for websocket rejection",
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
|
|
return withTimeout(listener.stop(true), 10_000, label)
|
|
|
}
|
|
|
@@ -125,7 +181,9 @@ describe("HttpApi Server.listen", () => {
|
|
|
)
|
|
|
|
|
|
const info = await createCat(listener, tmp.path)
|
|
|
- const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
|
|
+ const ticket = await connectTicket(listener, info.id, tmp.path)
|
|
|
+ expect(ticket.expires_in).toBeGreaterThan(0)
|
|
|
+ const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
|
|
|
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
|
|
|
|
|
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
|
|
@@ -140,7 +198,8 @@ describe("HttpApi Server.listen", () => {
|
|
|
const restarted = await startListener()
|
|
|
try {
|
|
|
const nextInfo = await createCat(restarted, tmp.path)
|
|
|
- const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path))
|
|
|
+ const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
|
|
|
+ const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
|
|
|
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
|
|
nextWs.send("ping-restarted\n")
|
|
|
expect(await nextMessage).toContain("ping-restarted")
|
|
|
@@ -152,4 +211,64 @@ describe("HttpApi Server.listen", () => {
|
|
|
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
|
|
}
|
|
|
})
|
|
|
+
|
|
|
+ testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
|
|
|
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
|
+ const listener = await startListener("hono")
|
|
|
+ try {
|
|
|
+ const info = await createCat(listener, tmp.path)
|
|
|
+ const ticket = await connectTicket(listener, info.id, tmp.path)
|
|
|
+ const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
|
|
|
+ const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
|
|
|
+ ws.send("ping-hono-ticket\n")
|
|
|
+ expect(await message).toContain("ping-hono-ticket")
|
|
|
+ ws.close(1000)
|
|
|
+ } finally {
|
|
|
+ await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
|
|
|
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
|
+ const listener = await startListener()
|
|
|
+ try {
|
|
|
+ const info = await createCat(listener, tmp.path)
|
|
|
+
|
|
|
+ expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
|
|
|
+ expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
|
|
|
+
|
|
|
+ await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
|
|
|
+
|
|
|
+ const reusable = await connectTicket(listener, info.id, tmp.path)
|
|
|
+ const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
|
|
+ await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
|
|
+ ws.close(1000)
|
|
|
+
|
|
|
+ const other = await createCat(listener, tmp.path)
|
|
|
+ const scoped = await connectTicket(listener, info.id, tmp.path)
|
|
|
+ await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
|
|
|
+
|
|
|
+ const crossOrigin = await connectTicket(listener, info.id, tmp.path)
|
|
|
+ await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
|
|
|
+ headers: { origin: "https://evil.example" },
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
|
|
|
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
|
+ const listener = await startNoAuthListener()
|
|
|
+ try {
|
|
|
+ const info = await createCat(listener, tmp.path)
|
|
|
+ const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
|
|
+ const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
|
|
|
+ ws.send("ping-no-auth\n")
|
|
|
+ expect(await message).toContain("ping-no-auth")
|
|
|
+ ws.close(1000)
|
|
|
+ } finally {
|
|
|
+ await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
|
|
|
+ }
|
|
|
+ })
|
|
|
})
|