mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
import { existsSync } from "node:fs"
|
|
import fs from "node:fs/promises"
|
|
import { createHash } from "node:crypto"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { expect } from "bun:test"
|
|
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
|
import { Session } from "@opencode-ai/schema/session"
|
|
import { Effect, Schema } from "effect"
|
|
import { HttpServer } from "effect/unstable/http"
|
|
import { it } from "../../core/test/lib/effect"
|
|
import { ServerProcess } from "../src/process"
|
|
|
|
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
|
|
const smoke = existsSync(binary) ? it.live : it.live.skip
|
|
|
|
smoke(
|
|
"creates two persistent terminals for one session through the client API",
|
|
() =>
|
|
Effect.acquireUseRelease(
|
|
Effect.promise(async () => {
|
|
const environment = {
|
|
binary: process.env.OPENCODE_PTY_BIN,
|
|
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
|
|
xdg: process.env.XDG_RUNTIME_DIR,
|
|
}
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
|
|
const database = path.join(root, "opencode.db")
|
|
const runtime = path.join(root, "runtime")
|
|
process.env.OPENCODE_PTY_BIN = binary
|
|
delete process.env.OPENCODE_PTY_RUNTIME_DIR
|
|
process.env.XDG_RUNTIME_DIR = runtime
|
|
return {
|
|
database,
|
|
directory: path.join(
|
|
runtime,
|
|
"opencode-pty",
|
|
createHash("sha256").update(database).digest("hex").slice(0, 16),
|
|
),
|
|
environment,
|
|
root,
|
|
}
|
|
}),
|
|
(fixture) =>
|
|
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: fixture.database },
|
|
fs: { filewatcher: false },
|
|
})
|
|
const base = HttpServer.formatAddress(server.address)
|
|
const sessionID = Session.ID.make("ses_persistent_pty_test")
|
|
const events = yield* Effect.promise(() => openEventStream(base))
|
|
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
|
|
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
|
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
|
|
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
|
|
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
|
command: "/bin/sh",
|
|
args: ["-c", "stty -echo; printf terminal-one; cat"],
|
|
cwd: process.cwd(),
|
|
title: "first",
|
|
env: {},
|
|
})).data,
|
|
)
|
|
expect(yield* Effect.promise(() => events.next("persistent-pty.added"))).toMatchObject({
|
|
data: { sessionID, terminal: { id: first.id } },
|
|
})
|
|
expect(first.size).toEqual({ cols: 80, rows: 24 })
|
|
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
|
|
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
|
|
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
|
command: "/bin/sh",
|
|
args: ["-c", "printf terminal-two; sleep 30"],
|
|
cwd: process.cwd(),
|
|
title: "second",
|
|
env: {},
|
|
})).data,
|
|
)
|
|
|
|
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
|
|
(yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
|
)
|
|
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
|
|
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
|
|
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
|
|
yield* Effect.promise(() => verifySharedControl(base, first.id))
|
|
const snapshot = yield* request(base, "GET", `/api/experimental/persistent-pty/${first.id}/snapshot`)
|
|
if (
|
|
!isRecord(snapshot.data) ||
|
|
typeof snapshot.data.checkpoint !== "string" ||
|
|
!isRecord(snapshot.data.info) ||
|
|
!isRecord(snapshot.data.info.output) ||
|
|
typeof snapshot.data.info.output.tail !== "number"
|
|
)
|
|
throw new Error("Persistent PTY snapshot response was invalid")
|
|
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
|
|
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
|
|
|
|
const ticket = yield* request(
|
|
base,
|
|
"POST",
|
|
`/api/experimental/persistent-pty/${first.id}/connect-token`,
|
|
undefined,
|
|
{
|
|
"x-opencode-ticket": "1",
|
|
},
|
|
)
|
|
if (!isRecord(ticket.data) || typeof ticket.data.ticket !== "string")
|
|
throw new Error("Invalid connect ticket")
|
|
const connectTicket = ticket.data.ticket
|
|
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${first.id}`)
|
|
yield* Effect.promise(async () => {
|
|
const url = new URL(`/api/experimental/persistent-pty/${first.id}/connect`, base)
|
|
url.searchParams.set("ticket", "invalid")
|
|
expect((await fetch(url)).status).toBe(403)
|
|
url.protocol = "ws:"
|
|
url.searchParams.set("ticket", connectTicket)
|
|
const socket = new WebSocket(url)
|
|
try {
|
|
const closed = await new Promise<CloseEvent>((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error("Removed terminal socket did not close")), 5_000)
|
|
socket.addEventListener("close", (event) => {
|
|
clearTimeout(timeout)
|
|
resolve(event)
|
|
})
|
|
socket.addEventListener("error", () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error("Valid ticket should upgrade before the missing terminal is reported"))
|
|
})
|
|
})
|
|
expect(closed.code).toBe(4404)
|
|
expect(closed.reason).toBe("terminal unavailable")
|
|
} finally {
|
|
socket.close()
|
|
}
|
|
})
|
|
expect(yield* Effect.promise(() => events.next("persistent-pty.removed"))).toMatchObject({
|
|
data: { sessionID, ptyID: first.id },
|
|
})
|
|
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${second.id}`)
|
|
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
|
|
|
yield* request(base, "POST", "/api/experimental/persistent-pty/shutdown")
|
|
|
|
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
|
|
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
|
command: "/bin/sh",
|
|
args: ["-c", "exit 7"],
|
|
cwd: process.cwd(),
|
|
title: "unattended",
|
|
env: {},
|
|
})).data,
|
|
)
|
|
yield* waitForStatus(base, unattended.id, "exited")
|
|
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toMatchObject([
|
|
{ id: unattended.id, status: "exited" },
|
|
])
|
|
yield* request(base, "DELETE", `/api/experimental/persistent-pty/${unattended.id}`)
|
|
|
|
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
|
|
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
|
|
command: "/bin/sh",
|
|
args: ["-c", "read value"],
|
|
cwd: process.cwd(),
|
|
title: "visible",
|
|
env: {},
|
|
})).data,
|
|
)
|
|
yield* attachAndExit(base, visible.id)
|
|
yield* waitForTerminals(base, sessionID, [])
|
|
yield* Effect.promise(() => events.close())
|
|
}),
|
|
(fixture) =>
|
|
Effect.promise(async () => {
|
|
await Bun.spawn([binary, "stop"], {
|
|
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}).exited
|
|
await fs.rm(fixture.root, { recursive: true, force: true })
|
|
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
|
|
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
|
|
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
|
|
}),
|
|
),
|
|
20_000,
|
|
)
|
|
|
|
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
|
|
return Effect.tryPromise({
|
|
try: async () => {
|
|
const response = await fetch(new URL(pathname, base), {
|
|
method,
|
|
headers: {
|
|
authorization: `Basic ${btoa("opencode:secret")}`,
|
|
...headers,
|
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
})
|
|
if (!response.ok) throw new Error(`${method} ${pathname} failed (${response.status}): ${await response.text()}`)
|
|
if (response.status === 204) return {}
|
|
const value: unknown = await response.json()
|
|
if (!isRecord(value)) throw new Error(`${method} ${pathname} returned a non-object response`)
|
|
return value
|
|
},
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
})
|
|
}
|
|
|
|
async function openEventStream(base: string) {
|
|
const response = await fetch(new URL("/api/event", base), {
|
|
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
|
})
|
|
if (!response.ok || !response.body) throw new Error(`Persistent PTY event stream failed (${response.status})`)
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let pending = ""
|
|
return {
|
|
async next(type: string) {
|
|
while (true) {
|
|
const boundary = pending.indexOf("\n\n")
|
|
if (boundary !== -1) {
|
|
const frame = pending.slice(0, boundary)
|
|
pending = pending.slice(boundary + 2)
|
|
const data = frame
|
|
.split("\n")
|
|
.find((line) => line.startsWith("data: "))
|
|
?.slice(6)
|
|
if (!data) continue
|
|
const event: unknown = JSON.parse(data)
|
|
if (isRecord(event) && event.type === type) return event
|
|
continue
|
|
}
|
|
const chunk = await reader.read()
|
|
if (chunk.done) throw new Error(`Persistent PTY event stream closed before ${type}`)
|
|
pending += decoder.decode(chunk.value, { stream: true })
|
|
}
|
|
},
|
|
close: () => reader.cancel(),
|
|
}
|
|
}
|
|
|
|
function waitForText(base: string, ptyID: string, expected: string) {
|
|
return Effect.tryPromise({
|
|
try: async () => {
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
const response = await Effect.runPromise(
|
|
request(base, "GET", `/api/experimental/persistent-pty/${ptyID}/snapshot`),
|
|
)
|
|
if (isRecord(response.data) && typeof response.data.text === "string" && response.data.text.includes(expected))
|
|
return response.data.text
|
|
await Bun.sleep(50)
|
|
}
|
|
throw new Error(`Persistent PTY snapshot did not contain ${expected}`)
|
|
},
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
})
|
|
}
|
|
|
|
function waitForStatus(base: string, ptyID: string, status: string) {
|
|
return Effect.tryPromise({
|
|
try: async () => {
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
const response = await Effect.runPromise(request(base, "GET", `/api/experimental/persistent-pty/${ptyID}`))
|
|
if (isRecord(response.data) && response.data.status === status) return
|
|
await Bun.sleep(50)
|
|
}
|
|
throw new Error(`Persistent PTY ${ptyID} did not reach status ${status}`)
|
|
},
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
})
|
|
}
|
|
|
|
function attachAndExit(base: string, ptyID: string) {
|
|
return Effect.tryPromise({
|
|
try: async () => {
|
|
const response = await Effect.runPromise(
|
|
request(base, "POST", `/api/experimental/persistent-pty/${ptyID}/connect-token`, undefined, {
|
|
"x-opencode-ticket": "1",
|
|
}),
|
|
)
|
|
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
|
|
throw new Error("Persistent PTY connect token response was invalid")
|
|
const url = new URL(`/api/experimental/persistent-pty/${ptyID}/connect`, base)
|
|
url.protocol = "ws:"
|
|
url.searchParams.set("ticket", response.data.ticket)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = new WebSocket(url)
|
|
const timeout = setTimeout(() => {
|
|
socket.close()
|
|
reject(new Error("Persistent PTY did not exit while attached"))
|
|
}, 5_000)
|
|
socket.addEventListener("message", (event) => {
|
|
if (typeof event.data !== "string") return
|
|
const message: unknown = JSON.parse(event.data)
|
|
if (!isRecord(message)) return
|
|
if (message.type === "attached") socket.send(new Uint8Array([4]))
|
|
if (message.type !== "exited") return
|
|
clearTimeout(timeout)
|
|
socket.close()
|
|
resolve()
|
|
})
|
|
socket.addEventListener("error", () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error("Persistent PTY WebSocket failed"))
|
|
})
|
|
})
|
|
},
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
})
|
|
}
|
|
|
|
async function verifySharedControl(base: string, ptyID: string) {
|
|
const first = await openTerminalSocket(base, ptyID, "first")
|
|
const second = await openTerminalSocket(base, ptyID, "second", "observer")
|
|
try {
|
|
first.socket.send(controlFrame(90, 25))
|
|
first.socket.send(inputFrame(90, 25, "from-first\n"))
|
|
await waitForSocketOutput([first, second], "from-first")
|
|
|
|
second.socket.send(inputFrame(70, 20, "from-second\n"))
|
|
await waitForSocketOutput([first, second], "from-second")
|
|
|
|
await waitForForegroundProcess([first, second], "cat")
|
|
|
|
for (const character of "printf abc | rev\n") second.socket.send(inputFrame(70, 20, character))
|
|
await waitForSocketOutput([first, second], "printf abc | rev")
|
|
|
|
second.socket.send(inputFrame(70, 20, "x".repeat(1024)))
|
|
second.socket.send(inputFrame(70, 20, "after-burst\n"))
|
|
await waitForSocketOutput([first, second], "after-burst")
|
|
expect(first.closed).toBeFalse()
|
|
expect(second.closed).toBeFalse()
|
|
expect(first.resizes).toBeGreaterThan(0)
|
|
expect(second.resizes).toBeGreaterThan(0)
|
|
expect(first.output).not.toContain("\0")
|
|
expect(second.output).not.toContain("\0")
|
|
} finally {
|
|
first.socket.close()
|
|
second.socket.close()
|
|
}
|
|
}
|
|
|
|
async function openTerminalSocket(
|
|
base: string,
|
|
ptyID: string,
|
|
attachmentID: string,
|
|
role: "controller" | "observer" = "controller",
|
|
) {
|
|
const response = await Effect.runPromise(
|
|
request(base, "POST", `/api/experimental/persistent-pty/${ptyID}/connect-token`, undefined, {
|
|
"x-opencode-ticket": "1",
|
|
}),
|
|
)
|
|
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
|
|
throw new Error("Persistent PTY connect token response was invalid")
|
|
const url = new URL(`/api/experimental/persistent-pty/${ptyID}/connect`, base)
|
|
url.protocol = "ws:"
|
|
url.searchParams.set("ticket", response.data.ticket)
|
|
url.searchParams.set("attachment_id", attachmentID)
|
|
url.searchParams.set("role", role)
|
|
url.searchParams.set("takeover", "true")
|
|
url.searchParams.set("input_protocol", "1")
|
|
const state = {
|
|
socket: new WebSocket(url),
|
|
output: "",
|
|
closed: false,
|
|
resizes: 0,
|
|
foregroundProcess: null as string | null,
|
|
}
|
|
state.socket.binaryType = "arraybuffer"
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error("Persistent PTY WebSocket did not attach")), 5_000)
|
|
let attached = false
|
|
state.socket.addEventListener("message", (event) => {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
state.output += new TextDecoder().decode(event.data)
|
|
return
|
|
}
|
|
if (typeof event.data !== "string") return
|
|
const message: unknown = JSON.parse(event.data)
|
|
if (!isRecord(message)) return
|
|
if (message.type === "resized") {
|
|
if (typeof message.checkpoint !== "string") {
|
|
clearTimeout(timeout)
|
|
reject(new Error("Persistent PTY resize omitted its checkpoint"))
|
|
return
|
|
}
|
|
state.resizes++
|
|
return
|
|
}
|
|
if (message.type === "foreground_process_changed") {
|
|
state.foregroundProcess = typeof message.process === "string" ? message.process : null
|
|
return
|
|
}
|
|
if (message.type === "attached") {
|
|
if (message.inputProtocol === 1) {
|
|
if (isRecord(message.info) && typeof message.info.foregroundProcess === "string")
|
|
state.foregroundProcess = message.info.foregroundProcess
|
|
attached = true
|
|
return
|
|
}
|
|
clearTimeout(timeout)
|
|
reject(new Error("Persistent PTY WebSocket did not negotiate framed input"))
|
|
return
|
|
}
|
|
if (message.type !== "replay_complete" || !attached) return
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
})
|
|
state.socket.addEventListener("close", () => {
|
|
state.closed = true
|
|
})
|
|
state.socket.addEventListener("error", () => {
|
|
clearTimeout(timeout)
|
|
reject(new Error("Persistent PTY WebSocket failed"))
|
|
})
|
|
})
|
|
return state
|
|
}
|
|
|
|
async function waitForForegroundProcess(
|
|
sockets: Array<{ foregroundProcess: string | null; closed: boolean }>,
|
|
expected: string,
|
|
) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (sockets.every((socket) => socket.foregroundProcess === expected)) return
|
|
if (sockets.some((socket) => socket.closed)) throw new Error("Persistent PTY observer disconnected")
|
|
await Bun.sleep(20)
|
|
}
|
|
throw new Error(
|
|
`Persistent PTY sockets did not both report ${expected}: ${JSON.stringify(sockets.map((socket) => socket.foregroundProcess))}`,
|
|
)
|
|
}
|
|
|
|
function inputFrame(cols: number, rows: number, input: string) {
|
|
const data = new TextEncoder().encode(input)
|
|
const frame = new Uint8Array(5 + data.byteLength)
|
|
const view = new DataView(frame.buffer)
|
|
frame[0] = 1
|
|
view.setUint16(1, cols)
|
|
view.setUint16(3, rows)
|
|
frame.set(data, 5)
|
|
return frame
|
|
}
|
|
|
|
function controlFrame(cols: number, rows: number) {
|
|
const frame = new Uint8Array(5)
|
|
const view = new DataView(frame.buffer)
|
|
view.setUint16(1, cols)
|
|
view.setUint16(3, rows)
|
|
return frame
|
|
}
|
|
|
|
async function waitForSocketOutput(sockets: Array<{ output: string; closed: boolean }>, expected: string) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (sockets.every((socket) => socket.output.includes(expected))) return
|
|
if (sockets.some((socket) => socket.closed)) throw new Error("Persistent PTY observer disconnected")
|
|
await Bun.sleep(20)
|
|
}
|
|
throw new Error(
|
|
`Persistent PTY sockets did not both receive ${expected}: ${JSON.stringify(sockets.map((socket) => socket.output))}`,
|
|
)
|
|
}
|
|
|
|
function waitForTerminals(base: string, sessionID: string, expected: unknown[]) {
|
|
return Effect.tryPromise({
|
|
try: async () => {
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
const response = await Effect.runPromise(
|
|
request(base, "GET", `/api/experimental/session/${sessionID}/terminal`),
|
|
)
|
|
if (JSON.stringify(response.data) === JSON.stringify(expected)) return
|
|
await Bun.sleep(50)
|
|
}
|
|
throw new Error(`Persistent PTYs for ${sessionID} did not reconcile`)
|
|
},
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
})
|
|
}
|
|
|
|
function restore(key: string, value: string | undefined) {
|
|
if (value === undefined) delete process.env[key]
|
|
if (value !== undefined) process.env[key] = value
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
}
|