refactor(core): consolidate pty service (#30537)

This commit is contained in:
Shoubhit Dash
2026-06-03 15:17:46 +05:30
committed by GitHub
parent c6f684366a
commit 932fb6c9ec
28 changed files with 504 additions and 576 deletions
+2 -2
View File
@@ -260,6 +260,7 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1", "@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
@@ -271,6 +272,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -501,7 +503,6 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -531,7 +532,6 @@
"ai": "catalog:", "ai": "catalog:",
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
+1 -1
View File
@@ -15,7 +15,7 @@
"lint": "oxlint", "lint": "oxlint",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser", "sso": "aws sso login --sso-session=opencode --no-browser",
+8
View File
@@ -8,6 +8,7 @@
"scripts": { "scripts": {
"db": "bun drizzle-kit", "db": "bun drizzle-kit",
"migration": "bun run script/migration.ts", "migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test", "test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
@@ -23,6 +24,11 @@
"bun": "./src/database/sqlite.bun.ts", "bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts", "node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts" "default": "./src/database/sqlite.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -69,6 +75,7 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1", "@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
@@ -80,6 +87,7 @@
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1", "@openrouter/ai-sdk-provider": "2.8.1",
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+2
View File
@@ -21,6 +21,7 @@ import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher" import { Watcher } from "./filesystem/watcher"
import { ProjectReference } from "./project-reference" import { ProjectReference } from "./project-reference"
import { RepositoryCache } from "./repository-cache" import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) => {
@@ -37,6 +38,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
PermissionV2.locationLayer, PermissionV2.locationLayer,
FileSystem.locationLayer, FileSystem.locationLayer,
Watcher.locationLayer, Watcher.locationLayer,
Pty.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh) ).pipe(Layer.provideMerge(location), Layer.fresh)
}, },
idleTimeToLive: "60 minutes", idleTimeToLive: "60 minutes",
@@ -1,22 +1,19 @@
import { EventV2Bridge } from "@/event-v2-bridge" export * as Pty from "./pty"
import { EventV2 } from "@opencode-ai/core/event"
import { Config } from "@/config/config" import type { Disp, Proc } from "#pty"
import { InstanceState } from "@/effect/instance-state" import { Context, Effect, Layer, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "./event"
import { lazy } from "@opencode-ai/core/util/lazy" import { Location } from "./location"
import { Plugin } from "@/plugin" import { NonNegativeInt, PositiveInt } from "./schema"
import { Shell } from "@/shell/shell" import { PtyID } from "./pty/schema"
import type { Proc } from "#pty" import { lazy } from "./util/lazy"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "./util/log"
import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema"
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024 const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder() const encoder = new TextEncoder()
const pty = lazy(() => import("#pty"))
type Socket = { type Socket = {
readyState: number readyState: number
@@ -25,8 +22,6 @@ type Socket = {
close: (code?: number, reason?: string) => void close: (code?: number, reason?: string) => void
} }
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
type Active = { type Active = {
info: Info info: Info
process: Proc process: Proc
@@ -34,12 +29,10 @@ type Active = {
bufferCursor: number bufferCursor: number
cursor: number cursor: number
subscribers: Map<unknown, Socket> subscribers: Map<unknown, Socket>
listeners: Disp[]
} }
type State = { const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
dir: string
sessions: Map<PtyID, Active>
}
// WebSocket control frame: 0x00 + UTF-8 JSON. // WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => { const meta = (cursor: number) => {
@@ -51,8 +44,6 @@ const meta = (cursor: number) => {
return out return out
} }
const pty = lazy(() => import("#pty"))
export const Info = Schema.Struct({ export const Info = Schema.Struct({
id: PtyID, id: PtyID,
title: Schema.String, title: Schema.String,
@@ -60,14 +51,11 @@ export const Info = Schema.Struct({
args: Schema.Array(Schema.String), args: Schema.Array(Schema.String),
cwd: Schema.String, cwd: Schema.String,
status: Schema.Literals(["running", "exited"]), status: Schema.Literals(["running", "exited"]),
// Windows ConPTY (@lydell/node-pty >= 1.2.0-beta.12) assigns the child pid // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
// asynchronously, so `proc.pid` is 0 at the synchronous spawn point and only
// resolves a tick later. `create` snapshots it immediately, so 0 is a valid
// "pid not yet assigned" value here.
pid: NonNegativeInt, pid: NonNegativeInt,
}).annotate({ identifier: "Pty" }) }).annotate({ identifier: "Pty" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>> export type Info = Types.DeepMutable<typeof Info.Type>
export const CreateInput = Schema.Struct({ export const CreateInput = Schema.Struct({
command: Schema.optional(Schema.String), command: Schema.optional(Schema.String),
@@ -77,7 +65,15 @@ export const CreateInput = Schema.Struct({
env: Schema.optional(Schema.Record(Schema.String, Schema.String)), env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) })
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>> export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export type PreparedCreate = {
readonly command: string
readonly args: string[]
readonly cwd: string
readonly title?: string
readonly env: Record<string, string>
}
export const UpdateInput = Schema.Struct({ export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
@@ -89,7 +85,7 @@ export const UpdateInput = Schema.Struct({
), ),
}) })
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>> export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", { export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID, ptyID: PtyID,
@@ -105,7 +101,7 @@ export const Event = {
export interface Interface { export interface Interface {
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError> readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info> readonly create: (input: PreparedCreate) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError> readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError> readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError> readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
@@ -120,16 +116,20 @@ export interface Interface {
> >
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const events = yield* EventV2.Service
const events = yield* EventV2Bridge.Service const location = yield* Location.Service
const plugin = yield* Plugin.Service const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
function teardown(session: Active) { function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
@@ -141,93 +141,59 @@ export const layer = Layer.effect(
session.subscribers.clear() session.subscribers.clear()
} }
const state = yield* InstanceState.make<State>( yield* Effect.addFinalizer(() =>
Effect.fn("Pty.state")(function* (ctx) { Effect.sync(() => {
const state = { for (const session of sessions.values()) teardown(session)
dir: ctx.directory, sessions.clear()
sessions: new Map<PtyID, Active>(),
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of state.sessions.values()) {
teardown(session)
}
state.sessions.clear()
}),
)
return state
}), }),
) )
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = (yield* InstanceState.get(state)).sessions.get(id) const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id }) if (!session) return yield* new NotFoundError({ ptyID: id })
return session return session
}) })
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const s = yield* InstanceState.get(state) const session = sessions.get(id)
const session = yield* requireSession(id) if (!session) return false
s.sessions.delete(id) sessions.delete(id)
log.info("removing session", { id }) log.info("removing session", { id })
teardown(session) teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id }) yield* events.publish(Event.Deleted, { id: session.info.id })
return true
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
yield* requireSession(id)
yield* removeSession(id)
}) })
const list = Effect.fn("Pty.list")(function* () { const list = Effect.fn("Pty.list")(function* () {
const s = yield* InstanceState.get(state) return Array.from(sessions.values()).map((session) => session.info)
return Array.from(s.sessions.values()).map((session) => session.info)
}) })
const get = Effect.fn("Pty.get")(function* (id: PtyID) { const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info return (yield* requireSession(id)).info
}) })
const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const id = PtyID.ascending() const id = PtyID.ascending()
const command = input.command || Shell.preferred(cfg.shell) log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
}
const cwd = input.cwd || s.dir
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty()) const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() => const proc = yield* Effect.sync(() =>
spawn(command, args, { spawn(input.command, input.args, {
name: "xterm-256color", name: "xterm-256color",
cwd, cwd: input.cwd,
env, env: input.env,
}), }),
) )
const info = { const info = {
id, id,
title: input.title || `Terminal ${id.slice(-4)}`, title: input.title || `Terminal ${id.slice(-4)}`,
command, command: input.command,
args, args: input.args,
cwd, cwd: input.cwd,
status: "running", status: "running",
pid: proc.pid, pid: proc.pid,
} as const } as const
@@ -238,113 +204,89 @@ export const layer = Layer.effect(
bufferCursor: 0, bufferCursor: 0,
cursor: 0, cursor: 0,
subscribers: new Map(), subscribers: new Map(),
listeners: [],
} }
s.sessions.set(id, session) sessions.set(id, session)
proc.onData((chunk) => { session.listeners.push(
session.cursor += chunk.length proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) { for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) { if (ws.readyState !== 1 || sock(ws) !== key) {
session.subscribers.delete(key) session.subscribers.delete(key)
continue continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
} }
if (sock(ws) !== key) { session.buffer += chunk
session.subscribers.delete(key) if (session.buffer.length <= BUFFER_LIMIT) return
continue const excess = session.buffer.length - BUFFER_LIMIT
} session.buffer = session.buffer.slice(excess)
try { session.bufferCursor += excess
ws.send(chunk) }),
} catch { proc.onExit(({ exitCode }) => {
session.subscribers.delete(key) if (session.info.status === "exited") return
} runFork(
} Effect.gen(function* () {
log.info("session exited", { id, exitCode })
session.buffer += chunk session.info.status = "exited"
if (session.buffer.length <= BUFFER_LIMIT) return yield* events.publish(Event.Exited, { id, exitCode })
const excess = session.buffer.length - BUFFER_LIMIT yield* removeSession(id)
session.buffer = session.buffer.slice(excess) }),
session.bufferCursor += excess )
}) }),
proc.onExit(({ exitCode }) => { )
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(events.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
})
yield* events.publish(Event.Created, { info }) yield* events.publish(Event.Created, { info })
return info return info
}) })
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id) const session = yield* requireSession(id)
if (input.title) { if (input.title) session.info.title = input.title
session.info.title = input.title if (input.size) session.process.resize(input.size.cols, input.size.rows)
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
yield* events.publish(Event.Updated, { info: session.info }) yield* events.publish(Event.Updated, { info: session.info })
return session.info return session.info
}) })
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id) const session = yield* requireSession(id)
if (session.info.status === "running") { if (session.info.status === "running") session.process.resize(cols, rows)
session.process.resize(cols, rows)
}
}) })
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id) const session = yield* requireSession(id)
if (session.info.status === "running") { if (session.info.status === "running") session.process.write(data)
session.process.write(data)
}
}) })
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe( const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
Effect.tapError(() => log.info("client connected to session", { id, directory: location.directory })
Effect.sync(() => {
ws.close()
}),
),
)
log.info("client connected to session", { id })
const sub = sock(ws) const sub = sock(ws)
session.subscribers.delete(sub) session.subscribers.delete(sub)
session.subscribers.set(sub, ws) session.subscribers.set(sub, ws)
const cleanup = () => session.subscribers.delete(sub)
const cleanup = () => {
session.subscribers.delete(sub)
}
const start = session.bufferCursor const start = session.bufferCursor
const end = session.cursor const end = session.cursor
const from = const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => { const data = (() => {
if (!session.buffer) return "" if (!session.buffer || from >= end) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start) const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return "" if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset) return session.buffer.slice(offset)
})() })()
if (data) { if (data) {
try { try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) { for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch { } catch {
cleanup() cleanup()
ws.close() ws.close()
return return
} }
} }
try { try {
ws.send(meta(end)) ws.send(meta(end))
} catch { } catch {
@@ -352,7 +294,6 @@ export const layer = Layer.effect(
ws.close() ws.close()
return return
} }
return { return {
onMessage: (message: string | ArrayBuffer) => { onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
@@ -368,10 +309,4 @@ export const layer = Layer.effect(
}), }),
) )
export const defaultLayer = layer.pipe( export const locationLayer = layer
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Config.defaultLayer),
)
export * as Pty from "."
@@ -1,7 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Identifier } from "../id/id"
import { Identifier } from "@/id/id" import { withStatics } from "../schema"
import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
@@ -1,9 +1,8 @@
export * as PtyTicket from "./ticket" export * as PtyTicket from "./ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { WorkspaceV2 } from "../workspace"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PositiveInt } from "../schema"
import { PtyID } from "@/pty/schema" import { PtyID } from "./schema"
import { PositiveInt } from "@opencode-ai/core/schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
const DEFAULT_TTL = Duration.seconds(60) const DEFAULT_TTL = Duration.seconds(60)
@@ -57,12 +56,3 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
export const layer = Layer.effect(Service, make()) export const layer = Layer.effect(Service, make())
export const defaultLayer = layer export const defaultLayer = layer
export const scope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
workspaceID,
}
})
@@ -1,13 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Schema } from "effect" import { Schema } from "effect"
import { Pty } from "../../src/pty" import { Pty } from "@opencode-ai/core/pty"
// Windows ConPTY (via @lydell/node-pty >= 1.2.0-beta.12) assigns the child pid
// asynchronously: `proc.pid` reads back as 0 at the synchronous spawn point and
// only resolves to the real pid a tick later. `Pty.create` snapshots `proc.pid`
// while building `Info`, so `Info.pid` legitimately carries 0 right after spawn.
// `Pty.Info` must be able to represent that, otherwise every `pty.create` on
// Windows fails to encode/decode and the terminal feature is unusable.
const sample = (pid: number) => ({ const sample = (pid: number) => ({
id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K", id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K",
title: "demo", title: "demo",
@@ -1,9 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { handlePtyInput } from "../../src/pty/input" import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { it } from "../lib/effect" import { it } from "../lib/effect"
describe("pty HttpApi websocket input", () => { describe("pty websocket input", () => {
it.effect("does not forward invalid binary frames to the PTY handler", () => it.effect("does not forward invalid binary frames to the PTY handler", () =>
Effect.gen(function* () { Effect.gen(function* () {
const messages: Array<string | ArrayBuffer> = [] const messages: Array<string | ArrayBuffer> = []
@@ -0,0 +1,110 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Queue } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
type Socket = Parameters<Pty.Interface["connect"]>[1]
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
(info) => pty.remove(info.id).pipe(Effect.ignore),
)
})
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
typeof data === "string"
? data
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
const output = yield* Queue.unbounded<string>()
const socket: Socket = {
readyState: 1,
data,
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
close: () => {},
}
return { socket, output }
})
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
describe("pty output isolation", () => {
ptyTest("does not leak output when websocket objects are reused", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty("cat")
const b = yield* createPty("cat")
const shared = yield* makeSocket({ events: { connection: "a" } })
const outB = yield* Queue.unbounded<string>()
yield* pty.connect(a.id, shared.socket)
shared.socket.data = { events: { connection: "b" } }
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
yield* pty.connect(b.id, shared.socket)
yield* pty.write(a.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const first = yield* makeSocket({ events: { connection: "a" } })
const recycled = yield* Queue.unbounded<string>()
yield* pty.connect(info.id, first.socket)
first.socket.data = { events: { connection: "b" } }
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
yield* pty.write(info.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify" } })
yield* pty.connect(info.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("treats in-place socket data mutation as the same connection", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const data = { connId: 1 }
const socket = yield* makeSocket(data)
yield* pty.connect(info.id, socket.socket)
data.connId = 2
yield* pty.write(info.id, "AAA\n")
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
}),
)
})
@@ -0,0 +1,91 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
const source = yield* EventV2.Service
const events = yield* Queue.unbounded<PtyEvent>()
const unsubscribe = yield* source.listen((event) => {
if (event.type === Pty.Event.Created.type)
Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
if (event.type === Pty.Event.Exited.type)
Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
if (event.type === Pty.Event.Deleted.type)
Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
return Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
return events
})
const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: string, args: string[] = []) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(
pty.create({ command, args, cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
(info) => pty.remove(info.id).pipe(Effect.ignore),
)
})
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
Effect.gen(function* () {
const picked: Array<PtyEvent["type"]> = []
while (picked.length < count) {
const evt = yield* Queue.take(events)
if (evt.id === id) picked.push(evt.type)
}
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
let closed = false
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
yield* pty.remove(id).pipe(Effect.exit),
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
yield* pty.write(id, "input").pipe(Effect.exit),
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
]) {
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result))
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
}
expect(closed).toBe(true)
}),
)
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
Effect.gen(function* () {
const events = yield* subscribePtyEvents()
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
}),
)
})
@@ -1,8 +1,8 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { PtyID } from "../../src/pty/schema"
import { PtyTicket } from "../../src/pty/ticket"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer) const it = testEffect(PtyTicket.layer)
-8
View File
@@ -13,7 +13,6 @@
"bench:test": "bun run script/bench-test-suite.ts", "bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts", "profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts" "dev:temporary": "bun run --conditions=browser ./src/temporary.ts"
}, },
@@ -28,11 +27,6 @@
"bun": "./src/storage/db.bun.ts", "bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts", "node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts" "default": "./src/storage/db.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -86,7 +80,6 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -116,7 +109,6 @@
"ai": "catalog:", "ai": "catalog:",
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
@@ -44,8 +44,6 @@ import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference" import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
@@ -101,8 +99,6 @@ export const AppLayer = Layer.mergeAll(
Reference.defaultLayer, Reference.defaultLayer,
Workspace.defaultLayer, Workspace.defaultLayer,
Worktree.appLayer, Worktree.appLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Installation.defaultLayer, Installation.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
SessionShare.defaultLayer, SessionShare.defaultLayer,
+30
View File
@@ -0,0 +1,30 @@
export * as PtyPreparation from "./pty-preparation"
import { Config } from "@/config/config"
import * as InstanceState from "@/effect/instance-state"
import { Plugin } from "@/plugin"
import { Shell } from "@/shell/shell"
import { Pty } from "@opencode-ai/core/pty"
import { Effect } from "effect"
export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const command = input.command || Shell.preferred((yield* config.get()).shell)
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || (yield* InstanceState.context).directory
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
return { command, args, cwd, title: input.title, env }
})
@@ -1,6 +1,6 @@
import { Pty } from "@/pty" import { Pty } from "@opencode-ai/core/pty"
import { PtyTicket } from "@/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PtyID } from "@/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket" import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -1,7 +1,13 @@
import { Pty } from "@/pty" import * as InstanceState from "@/effect/instance-state"
import { PtyID } from "@/pty/schema" import { registerDisposer } from "@/effect/instance-registry"
import { PtyTicket } from "@/pty/ticket" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { handlePtyInput } from "@/pty/input" import { PtyPreparation } from "@/pty-preparation"
import { Pty } from "@opencode-ai/core/pty"
import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
@@ -10,7 +16,7 @@ import {
PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE, PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket" } from "@/server/shared/pty-ticket"
import { Effect, Option, Schema } from "effect" import { Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
@@ -23,30 +29,53 @@ function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOpt
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
} }
const ticketScope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return { directory: instance?.directory, workspaceID }
})
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
)
})
const shells = Effect.fn("PtyHttpApi.shells")(function* () { const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list()) return yield* Effect.promise(() => Shell.list())
}) })
const list = Effect.fn("PtyHttpApi.list")(function* () { const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list() return yield* pty(Pty.Service.use((service) => service.list()))
}) })
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
return yield* pty.create({ return yield* pty(
...ctx.payload, Pty.Service.use((service) =>
args: ctx.payload.args ? [...ctx.payload.args] : undefined, Effect.flatMap(
env: ctx.payload.env ? { ...ctx.payload.env } : undefined, PtyPreparation.prepareCreate({
}) ...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
service.create,
),
),
)
}) })
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
return yield* pty.get(ctx.params.ptyID).pipe( return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) => Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new ApiError.PtyNotFoundError({ new ApiError.PtyNotFoundError({
@@ -62,25 +91,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
params: { ptyID: PtyID } params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type payload: typeof Pty.UpdateInput.Type
}) { }) {
return yield* pty return yield* pty(
.update(ctx.params.ptyID, { Pty.Service.use((service) =>
...ctx.payload, service.update(ctx.params.ptyID, {
size: ctx.payload.size ? { ...ctx.payload.size } : undefined, ...ctx.payload,
}) size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
.pipe( }),
Effect.catchTag("Pty.NotFoundError", (error) => ),
Effect.fail( ).pipe(
new ApiError.PtyNotFoundError({ Effect.catchTag("Pty.NotFoundError", (error) =>
ptyID: error.ptyID, Effect.fail(
message: `PTY session not found: ${error.ptyID}`, new ApiError.PtyNotFoundError({
}), ptyID: error.ptyID,
), message: `PTY session not found: ${error.ptyID}`,
}),
), ),
) ),
)
}) })
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID).pipe( yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) => Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new ApiError.PtyNotFoundError({ new ApiError.PtyNotFoundError({
@@ -97,7 +128,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const request = yield* HttpServerRequest.HttpServerRequest const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" }) return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
yield* pty.get(ctx.params.ptyID).pipe( yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.catchTag("Pty.NotFoundError", (error) => Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new ApiError.PtyNotFoundError({ new ApiError.PtyNotFoundError({
@@ -107,7 +138,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
), ),
), ),
) )
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
}) })
return handlers return handlers
@@ -119,13 +150,23 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("remove", remove) .handle("remove", remove)
.handle("connectToken", connectToken) .handle("connectToken", connectToken)
}), }),
) ).pipe(Layer.provide(LocationServiceMap.layer))
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig const cors = yield* CorsConfig
const locations = yield* LocationServiceMap
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate({ directory: AbsolutePath.make(directory) })),
)
yield* Effect.addFinalizer(() => Effect.sync(unregister))
const pty = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
return yield* effect.pipe(
Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
)
})
return handlers.handleRaw( return handlers.handleRaw(
"connect", "connect",
@@ -133,7 +174,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
params: { ptyID: PtyID } params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest request: HttpServerRequest.HttpServerRequest
}) { }) {
const exists = yield* pty.get(ctx.params.ptyID).pipe( const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
Effect.as(true), Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
) )
@@ -144,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) { if (ticket) {
const valid = validOrigin(ctx.request, cors) const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) ? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
: false : false
if (!valid) return HttpServerResponse.empty({ status: 403 }) if (!valid) return HttpServerResponse.empty({ status: 403 })
} }
@@ -187,13 +228,13 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
writeScoped(write(new Socket.CloseEvent(code, reason))) writeScoped(write(new Socket.CloseEvent(code, reason)))
}, },
} }
const handler = yield* pty const handler = yield* pty(
.connect(ctx.params.ptyID, adapter, cursor) Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
.pipe( ).pipe(
Effect.catchTag("Pty.NotFoundError", () => Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
), ),
) )
if (!handler) return HttpServerResponse.empty() if (!handler) return HttpServerResponse.empty()
// The handshake runs inside `socket.runRaw`, after the input callback is // The handshake runs inside `socket.runRaw`, after the input callback is
@@ -214,4 +255,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
}), }),
) )
}), }),
) ).pipe(Layer.provide(LocationServiceMap.layer))
@@ -31,8 +31,7 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProviderAuth } from "@/provider/auth" import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Pty } from "@/pty" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PtyTicket } from "@/pty/ticket"
import { Question } from "@/question" import { Question } from "@/question"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
@@ -212,7 +211,6 @@ export function createRoutes(
ProjectCopy.defaultLayer, ProjectCopy.defaultLayer,
ProviderAuth.defaultLayer, ProviderAuth.defaultLayer,
Provider.defaultLayer, Provider.defaultLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer, PtyTicket.defaultLayer,
Question.defaultLayer, Question.defaultLayer,
Ripgrep.defaultLayer, Ripgrep.defaultLayer,
@@ -1,162 +0,0 @@
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { Pty } from "../../src/pty"
import { Duration, Effect, Layer, Queue } from "effect"
import { testEffect } from "../lib/effect"
type Socket = Parameters<Pty.Interface["connect"]>[1]
const it = testEffect(
Pty.layer.pipe(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provideMerge(Config.defaultLayer),
Layer.provideMerge(Plugin.defaultLayer),
),
)
const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (input: Pty.CreateInput) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore))
})
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
typeof data === "string"
? data
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
const output = yield* Queue.unbounded<string>()
const chunks: string[] = []
const socket: Socket = {
readyState: 1,
data,
send: (data) => {
const text = decodeOutput(data)
chunks.push(text)
Queue.offerUnsafe(output, text)
},
close: () => {
// no-op (simulate abrupt drop)
},
}
return { socket, output, chunks }
})
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) {
received += yield* Queue.take(output)
}
return received
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
const waitForLeakedOutput = (output: Queue.Queue<string>, text: string) =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) {
received += yield* Queue.take(output)
}
return received
}).pipe(
Effect.timeoutOrElse({
duration: "100 millis",
orElse: () => Effect.succeed(undefined),
}),
)
describe("pty", () => {
ptyTest(
"does not leak output when websocket objects are reused",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const b = yield* createPty({ command: "cat", title: "b" })
const connectionA = yield* makeSocket({ events: { connection: "a" } })
const connectionB = { events: { connection: "b" } }
yield* pty.connect(a.id, connectionA.socket)
const outBQueue = yield* Queue.unbounded<string>()
const outB: string[] = []
connectionA.socket.data = connectionB
connectionA.socket.send = (data) => {
const text = decodeOutput(data)
outB.push(text)
Queue.offerUnsafe(outBQueue, text)
}
yield* pty.connect(b.id, connectionA.socket)
connectionA.chunks.length = 0
outB.length = 0
yield* pty.write(a.id, "AAA\n")
const verifyA = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verifyA.socket)
yield* waitForOutput(verifyA.output, "AAA")
expect(outB.join("")).not.toContain("AAA")
expect(yield* waitForLeakedOutput(outBQueue, "AAA")).toBeUndefined()
}),
{ git: true },
)
ptyTest(
"does not leak output when Bun recycles websocket objects before re-connect",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const outA = yield* makeSocket({ events: { connection: "a" } })
const outB = yield* Queue.unbounded<string>()
yield* pty.connect(a.id, outA.socket)
outA.chunks.length = 0
const connectionB = { events: { connection: "b" } }
outA.socket.data = connectionB
outA.socket.send = (data) => {
Queue.offerUnsafe(outB, decodeOutput(data))
}
yield* pty.write(a.id, "AAA\n")
const verifyA = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verifyA.socket)
yield* waitForOutput(verifyA.output, "AAA")
expect(yield* waitForLeakedOutput(outB, "AAA")).toBeUndefined()
}),
{ git: true },
)
ptyTest(
"treats in-place socket data mutation as the same connection",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty({ command: "cat", title: "a" })
const ctx = { connId: 1 }
const out = yield* makeSocket(ctx)
yield* pty.connect(a.id, out.socket)
out.chunks.length = 0
ctx.connId = 2
yield* pty.write(a.id, "AAA\n")
expect(yield* waitForOutput(out.output, "AAA")).toContain("AAA")
}),
{ git: true },
)
})
@@ -1,140 +0,0 @@
import { describe, expect } from "bun:test"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { Pty } from "../../src/pty"
import type { PtyID } from "../../src/pty/schema"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const it = testEffect(
Pty.layer.pipe(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provideMerge(Config.defaultLayer),
Layer.provideMerge(Plugin.defaultLayer),
),
)
const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
const source = yield* EventV2Bridge.Service
const events = yield* Queue.unbounded<PtyEvent>()
const unsubscribe = yield* source.listen((event) => {
if (event.type === Pty.Event.Created.type)
Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
if (event.type === Pty.Event.Exited.type)
Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
if (event.type === Pty.Event.Deleted.type)
Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
return Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
return events
})
const createPty = Effect.fn("PtySessionTest.createPty")(function* (input: Pty.CreateInput) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(pty.create(input), (info) => pty.remove(info.id).pipe(Effect.ignore))
})
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) => {
return Effect.gen(function* () {
const picked: Array<PtyEvent["type"]> = []
while (picked.length < count) {
const evt = yield* Queue.take(events)
if (evt.id === id) picked.push(evt.type)
}
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
}
describe("pty", () => {
it.instance(
"returns typed not found errors for missing sessions",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
let closed = false
const socket = {
readyState: 1,
send: () => {},
close: () => {
closed = true
},
}
const get = yield* pty.get(id).pipe(Effect.exit)
expect(Exit.isFailure(get)).toBe(true)
if (Exit.isFailure(get)) expect(Cause.squash(get.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const update = yield* pty.update(id, { title: "missing" }).pipe(Effect.exit)
expect(Exit.isFailure(update)).toBe(true)
if (Exit.isFailure(update))
expect(Cause.squash(update.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const remove = yield* pty.remove(id).pipe(Effect.exit)
expect(Exit.isFailure(remove)).toBe(true)
if (Exit.isFailure(remove))
expect(Cause.squash(remove.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const resize = yield* pty.resize(id, 80, 24).pipe(Effect.exit)
expect(Exit.isFailure(resize)).toBe(true)
if (Exit.isFailure(resize))
expect(Cause.squash(resize.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const write = yield* pty.write(id, "input").pipe(Effect.exit)
expect(Exit.isFailure(write)).toBe(true)
if (Exit.isFailure(write))
expect(Cause.squash(write.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
const connect = yield* pty.connect(id, socket).pipe(Effect.exit)
expect(Exit.isFailure(connect)).toBe(true)
if (Exit.isFailure(connect))
expect(Cause.squash(connect.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
expect(closed).toBe(true)
}),
{ git: true },
)
ptyTest(
"publishes created, exited, deleted in order for a short-lived process",
() =>
Effect.gen(function* () {
const events = yield* subscribePtyEvents()
const info = yield* createPty({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
}),
{ git: true },
)
ptyTest(
"publishes created, exited, deleted in order for /bin/sh + remove",
() =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty({ command: "/bin/sh", title: "sh" })
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["created"])
yield* pty.write(info.id, "exit\n")
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["exited", "deleted"])
yield* pty.remove(info.id).pipe(Effect.ignore)
}),
{ git: true },
)
})
+42 -15
View File
@@ -1,22 +1,34 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { Pty } from "../../src/pty" import { Config } from "../../src/config/config"
import { Plugin } from "../../src/plugin"
import { PtyPreparation } from "../../src/pty-preparation"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
Shell.preferred.reset() Shell.preferred.reset()
const it = testEffect(Pty.defaultLayer) const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer))
const preparationIt = testEffect(
const createPty = (input: Pty.CreateInput) => Layer.mergeAll(
Effect.acquireRelease( Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }),
Effect.gen(function* () { Layer.mock(Plugin.Service)({
const pty = yield* Pty.Service trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) =>
const info = yield* pty.create(input) Effect.sync(() => {
return { pty, info } const result = output as { env: Record<string, string> }
result.env.INPUT = "plugin"
result.env.FROM_PLUGIN = "plugin"
result.env.TERM = "plugin"
return output
}),
list: () => Effect.succeed([]),
init: () => Effect.void,
}), }),
({ pty, info }) => pty.remove(info.id).pipe(Effect.ignore), ),
).pipe(Effect.map(({ info }) => info)) )
const preparePty = (input: Pty.CreateInput) => PtyPreparation.prepareCreate(input)
describe("pty shell args", () => { describe("pty shell args", () => {
if (process.platform !== "win32") return if (process.platform !== "win32") return
@@ -27,7 +39,7 @@ describe("pty shell args", () => {
"does not add login args to pwsh", "does not add login args to pwsh",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const info = yield* createPty({ command: ps, title: "pwsh" }) const info = yield* preparePty({ command: ps, title: "pwsh" })
expect(info.args).toEqual([]) expect(info.args).toEqual([])
}), }),
{ timeout: 30000 }, { timeout: 30000 },
@@ -44,7 +56,7 @@ describe("pty shell args", () => {
"adds login args to bash", "adds login args to bash",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const info = yield* createPty({ command: bash, title: "bash" }) const info = yield* preparePty({ command: bash, title: "bash" })
expect(info.args).toEqual(["-l"]) expect(info.args).toEqual(["-l"])
}), }),
{ timeout: 30000 }, { timeout: 30000 },
@@ -61,7 +73,7 @@ describe("pty configured shell", () => {
Effect.gen(function* () { Effect.gen(function* () {
if (!configured) return if (!configured) return
const info = yield* createPty({ title: "configured" }) const info = yield* preparePty({ title: "configured" })
if (process.platform === "win32") { if (process.platform === "win32") {
expect(info.command.toLowerCase()).toBe(configured.toLowerCase()) expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
} else { } else {
@@ -73,3 +85,18 @@ describe("pty configured shell", () => {
{ timeout: 30000 }, { timeout: 30000 },
) )
}) })
describe("pty environment preparation", () => {
preparationIt.instance("merges plugin environment before forced PTY values", () =>
Effect.gen(function* () {
const input = { command: "/bin/sh", args: [] as string[], env: { INPUT: "caller" } }
const prepared = yield* preparePty(input)
expect(input.args).toEqual([])
expect(prepared.env.INPUT).toBe("plugin")
expect(prepared.env.FROM_PLUGIN).toBe("plugin")
expect(prepared.env.TERM).toBe("xterm-256color")
expect(prepared.env.OPENCODE_TERMINAL).toBe("1")
}),
)
})
@@ -5,7 +5,7 @@ import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/even
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { ServerAuth } from "../../src/server/auth" import { ServerAuth } from "../../src/server/auth"
import { PtyID } from "../../src/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { PtyID } from "../../src/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -10,7 +10,7 @@ import { Config, Effect, Layer, Queue, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Pty } from "../../src/pty" import { Pty } from "@opencode-ai/core/pty"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -139,6 +139,23 @@ describe("pty HttpApi bridge", () => {
}) })
}) })
testPty("disposes PTY sessions with their legacy instance", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
})
expect(created.status).toBe(200)
await disposeAllInstances()
const list = await app().request(PtyPaths.list, { headers })
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
})
test("returns 404 for missing PTY websocket before upgrade", async () => { test("returns 404 for missing PTY websocket before upgrade", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {