refactor(core): consolidate pty service (#30537)
This commit is contained in:
@@ -13,7 +13,6 @@
|
||||
"bench:test": "bun run script/bench-test-suite.ts",
|
||||
"profile:test": "bun run script/profile-test-files.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:temporary": "bun run --conditions=browser ./src/temporary.ts"
|
||||
},
|
||||
@@ -28,11 +27,6 @@
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.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": {
|
||||
@@ -86,7 +80,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -116,7 +109,6 @@
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
|
||||
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
|
||||
const result = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const stat = await fs.stat(file).catch(() => undefined)
|
||||
if (!stat) return
|
||||
if ((stat.mode & 0o111) === 0o111) return
|
||||
await fs.chmod(file, stat.mode | 0o755)
|
||||
return file
|
||||
}),
|
||||
)
|
||||
const fixed = result.filter(Boolean)
|
||||
if (fixed.length) {
|
||||
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,6 @@ import { Vcs } from "@/project/vcs"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
@@ -101,8 +99,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Reference.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
|
||||
30
packages/opencode/src/pty-preparation.ts
Normal file
30
packages/opencode/src/pty-preparation.ts
Normal 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,377 +0,0 @@
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { lazy } from "@opencode-ai/core/util/lazy"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import type { Proc } from "#pty"
|
||||
import * as Log from "@opencode-ai/core/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 BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
process: Proc
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<unknown, Socket>
|
||||
}
|
||||
|
||||
type State = {
|
||||
dir: string
|
||||
sessions: Map<PtyID, Active>
|
||||
}
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
command: Schema.String,
|
||||
args: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
// Windows ConPTY (@lydell/node-pty >= 1.2.0-beta.12) assigns the child pid
|
||||
// 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,
|
||||
}).annotate({ identifier: "Pty" })
|
||||
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
command: Schema.optional(Schema.String),
|
||||
args: Schema.optional(Schema.Array(Schema.String)),
|
||||
cwd: Schema.optional(Schema.String),
|
||||
title: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
|
||||
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
size: Schema.optional(
|
||||
Schema.Struct({
|
||||
rows: PositiveInt,
|
||||
cols: PositiveInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
|
||||
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly connect: (
|
||||
id: PtyID,
|
||||
ws: Socket,
|
||||
cursor?: number,
|
||||
) => Effect.Effect<
|
||||
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
|
||||
NotFoundError
|
||||
>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
function teardown(session: Active) {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (sock(ws) === sub) ws.close()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Pty.state")(function* (ctx) {
|
||||
const state = {
|
||||
dir: ctx.directory,
|
||||
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 session = (yield* InstanceState.get(state)).sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ ptyID: id })
|
||||
return session
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = yield* requireSession(id)
|
||||
s.sessions.delete(id)
|
||||
log.info("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Array.from(s.sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const cfg = yield* config.get()
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred(cfg.shell)
|
||||
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 proc = yield* Effect.sync(() =>
|
||||
spawn(command, args, {
|
||||
name: "xterm-256color",
|
||||
cwd,
|
||||
env,
|
||||
}),
|
||||
)
|
||||
|
||||
const info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
} as const
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
}
|
||||
s.sessions.set(id, session)
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
if (sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
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 })
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) {
|
||||
session.info.title = input.title
|
||||
}
|
||||
if (input.size) {
|
||||
session.process.resize(input.size.cols, input.size.rows)
|
||||
}
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") {
|
||||
session.process.resize(cols, rows)
|
||||
}
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") {
|
||||
session.process.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const session = yield* requireSession(id).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
ws.close()
|
||||
}),
|
||||
),
|
||||
)
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
|
||||
const data = (() => {
|
||||
if (!session.buffer) return ""
|
||||
if (from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
||||
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
}
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
},
|
||||
onClose: () => {
|
||||
log.info("client disconnected from session", { id })
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Pty from "."
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { spawn as create } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = create(file, args, opts)
|
||||
return {
|
||||
pid: pty.pid,
|
||||
onData(listener) {
|
||||
return pty.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return pty.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
pty.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
pty.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
pty.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const proc = pty.spawn(file, args, opts)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
onData(listener) {
|
||||
return proc.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return proc.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
proc.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
proc.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
proc.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export type Disp = {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export type Exit = {
|
||||
exitCode: number
|
||||
signal?: number | string
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
name: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Proc = {
|
||||
pid: number
|
||||
onData(listener: (data: string) => void): Disp
|
||||
onExit(listener: (event: Exit) => void): Disp
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
kill(signal?: string): void
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
|
||||
|
||||
export type PtyID = typeof ptyIdSchema.Type
|
||||
|
||||
export const PtyID = ptyIdSchema.pipe(
|
||||
withStatics((schema: typeof ptyIdSchema) => ({
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
|
||||
})),
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
export * as PtyTicket from "./ticket"
|
||||
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
const CAPACITY = 10_000
|
||||
|
||||
export const ConnectToken = Schema.Struct({
|
||||
ticket: Schema.String,
|
||||
expires_in: PositiveInt,
|
||||
})
|
||||
|
||||
export type Scope = {
|
||||
readonly ptyID: PtyID
|
||||
readonly directory?: string
|
||||
readonly workspaceID?: WorkspaceV2.ID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
|
||||
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
|
||||
|
||||
function matches(record: Scope, input: Scope) {
|
||||
return (
|
||||
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
|
||||
)
|
||||
}
|
||||
|
||||
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
|
||||
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
|
||||
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
|
||||
|
||||
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
|
||||
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
|
||||
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
|
||||
return Service.of({
|
||||
issue: Effect.fn("PtyTicket.issue")(function* (input) {
|
||||
const ticket = crypto.randomUUID()
|
||||
yield* Cache.set(cache, ticket, input)
|
||||
return { ticket, expires_in: expiresIn }
|
||||
}),
|
||||
consume: Effect.fn("PtyTicket.consume")(function* (input) {
|
||||
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const scope = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
workspaceID,
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
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 { EffectBridge } from "@/effect/bridge"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
@@ -10,7 +16,7 @@ import {
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} 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 { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
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)
|
||||
}
|
||||
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
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* () {
|
||||
return yield* Effect.promise(() => Shell.list())
|
||||
})
|
||||
|
||||
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 }) {
|
||||
return yield* pty.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
})
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
Effect.flatMap(
|
||||
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 } }) {
|
||||
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.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
@@ -62,25 +91,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
return yield* pty
|
||||
.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
service.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.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.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
@@ -97,7 +128,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
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" })
|
||||
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.fail(
|
||||
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
|
||||
@@ -119,13 +150,23 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
.handle("remove", remove)
|
||||
.handle("connectToken", connectToken)
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provide(LocationServiceMap.layer))
|
||||
|
||||
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
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(
|
||||
"connect",
|
||||
@@ -133,7 +174,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
|
||||
params: { ptyID: PtyID }
|
||||
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.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)
|
||||
if (ticket) {
|
||||
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
|
||||
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)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty
|
||||
.connect(ctx.params.ptyID, adapter, cursor)
|
||||
.pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
const handler = yield* pty(
|
||||
Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// 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 { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
@@ -212,7 +211,6 @@ export function createRoutes(
|
||||
ProjectCopy.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Pty } from "../../src/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) => ({
|
||||
id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K",
|
||||
title: "demo",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: "C:\\",
|
||||
status: "running",
|
||||
pid,
|
||||
})
|
||||
|
||||
describe("Pty.Info", () => {
|
||||
test("accepts pid 0 (Windows ConPTY assigns the pid asynchronously)", () => {
|
||||
expect(Schema.decodeUnknownSync(Pty.Info)(sample(0)).pid).toBe(0)
|
||||
})
|
||||
|
||||
test("accepts a positive pid", () => {
|
||||
expect(Schema.decodeUnknownSync(Pty.Info)(sample(48012)).pid).toBe(48012)
|
||||
})
|
||||
|
||||
test("rejects a negative pid", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -1,22 +1,34 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { Effect, Layer } from "effect"
|
||||
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 { testEffect } from "../lib/effect"
|
||||
|
||||
Shell.preferred.reset()
|
||||
|
||||
const it = testEffect(Pty.defaultLayer)
|
||||
|
||||
const createPty = (input: Pty.CreateInput) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* pty.create(input)
|
||||
return { pty, info }
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer))
|
||||
const preparationIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }),
|
||||
Layer.mock(Plugin.Service)({
|
||||
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) =>
|
||||
Effect.sync(() => {
|
||||
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", () => {
|
||||
if (process.platform !== "win32") return
|
||||
@@ -27,7 +39,7 @@ describe("pty shell args", () => {
|
||||
"does not add login args to pwsh",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* createPty({ command: ps, title: "pwsh" })
|
||||
const info = yield* preparePty({ command: ps, title: "pwsh" })
|
||||
expect(info.args).toEqual([])
|
||||
}),
|
||||
{ timeout: 30000 },
|
||||
@@ -44,7 +56,7 @@ describe("pty shell args", () => {
|
||||
"adds login args to bash",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* createPty({ command: bash, title: "bash" })
|
||||
const info = yield* preparePty({ command: bash, title: "bash" })
|
||||
expect(info.args).toEqual(["-l"])
|
||||
}),
|
||||
{ timeout: 30000 },
|
||||
@@ -61,7 +73,7 @@ describe("pty configured shell", () => {
|
||||
Effect.gen(function* () {
|
||||
if (!configured) return
|
||||
|
||||
const info = yield* createPty({ title: "configured" })
|
||||
const info = yield* preparePty({ title: "configured" })
|
||||
if (process.platform === "win32") {
|
||||
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
|
||||
} else {
|
||||
@@ -73,3 +85,18 @@ describe("pty configured shell", () => {
|
||||
{ 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { PtyTicket } from "../../src/pty/ticket"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PtyTicket.layer)
|
||||
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
|
||||
const issued = yield* tickets.issue(scope)
|
||||
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different request", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
itExpiring.live("rejects tickets after the TTL elapses", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID })
|
||||
|
||||
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const workspaceID = WorkspaceV2.ID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, workspaceID })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
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 { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { handlePtyInput } from "../../src/pty/input"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("pty HttpApi websocket input", () => {
|
||||
it.effect("does not forward invalid binary frames to the PTY handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages: Array<string | ArrayBuffer> = []
|
||||
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
|
||||
|
||||
yield* handlePtyInput(handler, "ready")
|
||||
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
|
||||
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
|
||||
|
||||
expect(messages).toEqual(["ready", "hello"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
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 { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
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 * as Socket from "effect/unstable/socket/Socket"
|
||||
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"
|
||||
|
||||
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 () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
|
||||
|
||||
Reference in New Issue
Block a user