Skip to content

Commit abc3974

Browse files
committed
feat(httpapi): bridge pty routes
1 parent 58244eb commit abc3974

5 files changed

Lines changed: 296 additions & 6 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,12 @@ This checklist tracks bridge parity only. Checked routes are available through t
320320

321321
### PTY Routes
322322

323-
- [ ] `GET /pty` - list PTY sessions.
324-
- [ ] `POST /pty` - create PTY session.
325-
- [ ] `GET /pty/:ptyID` - get PTY session.
326-
- [ ] `PUT /pty/:ptyID` - update PTY session.
327-
- [ ] `DELETE /pty/:ptyID` - remove PTY session.
328-
- [ ] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
323+
- [x] `GET /pty` - list PTY sessions.
324+
- [x] `POST /pty` - create PTY session.
325+
- [x] `GET /pty/:ptyID` - get PTY session.
326+
- [x] `PUT /pty/:ptyID` - update PTY session.
327+
- [x] `DELETE /pty/:ptyID` - remove PTY session.
328+
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
329329

330330
### TUI Routes
331331

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { EffectBridge } from "@/effect"
2+
import { Pty } from "@/pty"
3+
import { PtyID } from "@/pty/schema"
4+
import { Effect, Layer, Schema } from "effect"
5+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
6+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
7+
import * as Socket from "effect/unstable/socket/Socket"
8+
import { Authorization } from "./auth"
9+
10+
const root = "/pty"
11+
const Params = Schema.Struct({
12+
ptyID: PtyID,
13+
})
14+
const CursorQuery = Schema.Struct({
15+
cursor: Schema.optional(Schema.String),
16+
})
17+
18+
export const PtyPaths = {
19+
list: root,
20+
create: root,
21+
get: `${root}/:ptyID`,
22+
update: `${root}/:ptyID`,
23+
remove: `${root}/:ptyID`,
24+
connect: `${root}/:ptyID/connect`,
25+
} as const
26+
27+
export const PtyApi = HttpApi.make("pty")
28+
.add(
29+
HttpApiGroup.make("pty")
30+
.add(
31+
HttpApiEndpoint.get("list", PtyPaths.list, {
32+
success: Schema.Array(Pty.Info),
33+
}).annotateMerge(
34+
OpenApi.annotations({
35+
identifier: "pty.list",
36+
summary: "List PTY sessions",
37+
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
38+
}),
39+
),
40+
HttpApiEndpoint.post("create", PtyPaths.create, {
41+
payload: Pty.CreateInput,
42+
success: Pty.Info,
43+
}).annotateMerge(
44+
OpenApi.annotations({
45+
identifier: "pty.create",
46+
summary: "Create PTY session",
47+
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
48+
}),
49+
),
50+
HttpApiEndpoint.get("get", PtyPaths.get, {
51+
params: { ptyID: PtyID },
52+
success: Pty.Info,
53+
error: HttpApiError.NotFound,
54+
}).annotateMerge(
55+
OpenApi.annotations({
56+
identifier: "pty.get",
57+
summary: "Get PTY session",
58+
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
59+
}),
60+
),
61+
HttpApiEndpoint.put("update", PtyPaths.update, {
62+
params: { ptyID: PtyID },
63+
payload: Pty.UpdateInput,
64+
success: Pty.Info,
65+
error: HttpApiError.NotFound,
66+
}).annotateMerge(
67+
OpenApi.annotations({
68+
identifier: "pty.update",
69+
summary: "Update PTY session",
70+
description: "Update properties of an existing pseudo-terminal (PTY) session.",
71+
}),
72+
),
73+
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
74+
params: { ptyID: PtyID },
75+
success: Schema.Boolean,
76+
}).annotateMerge(
77+
OpenApi.annotations({
78+
identifier: "pty.remove",
79+
summary: "Remove PTY session",
80+
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
81+
}),
82+
),
83+
)
84+
.annotateMerge(
85+
OpenApi.annotations({
86+
title: "pty",
87+
description: "Experimental HttpApi PTY routes.",
88+
}),
89+
)
90+
.middleware(Authorization),
91+
)
92+
.annotateMerge(
93+
OpenApi.annotations({
94+
title: "opencode experimental HttpApi",
95+
version: "0.0.1",
96+
description: "Experimental HttpApi surface for selected instance routes.",
97+
}),
98+
)
99+
100+
export const ptyHandlers = Layer.unwrap(
101+
Effect.gen(function* () {
102+
const pty = yield* Pty.Service
103+
104+
const list = Effect.fn("PtyHttpApi.list")(function* () {
105+
return yield* pty.list()
106+
})
107+
108+
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
109+
const bridge = yield* EffectBridge.make()
110+
return yield* Effect.promise(() =>
111+
bridge.promise(
112+
pty.create({
113+
...ctx.payload,
114+
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
115+
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
116+
}),
117+
),
118+
)
119+
})
120+
121+
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
122+
const info = yield* pty.get(ctx.params.ptyID)
123+
if (!info) return yield* new HttpApiError.NotFound({})
124+
return info
125+
})
126+
127+
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
128+
params: { ptyID: PtyID }
129+
payload: typeof Pty.UpdateInput.Type
130+
}) {
131+
const info = yield* pty.update(ctx.params.ptyID, {
132+
...ctx.payload,
133+
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
134+
})
135+
if (!info) return yield* new HttpApiError.NotFound({})
136+
return info
137+
})
138+
139+
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
140+
yield* pty.remove(ctx.params.ptyID)
141+
return true
142+
})
143+
144+
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
145+
handlers
146+
.handle("list", list)
147+
.handle("create", create)
148+
.handle("get", get)
149+
.handle("update", update)
150+
.handle("remove", remove),
151+
)
152+
}),
153+
)
154+
155+
export const ptyConnectRoute = HttpRouter.add(
156+
"GET",
157+
PtyPaths.connect,
158+
Effect.gen(function* () {
159+
const pty = yield* Pty.Service
160+
const params = yield* HttpRouter.schemaPathParams(Params)
161+
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
162+
163+
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
164+
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
165+
const cursor =
166+
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
167+
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
168+
const write = yield* socket.writer
169+
let closed = false
170+
const adapter = {
171+
get readyState() {
172+
return closed ? 3 : 1
173+
},
174+
send: (data: string | Uint8Array | ArrayBuffer) => {
175+
if (closed) return
176+
Effect.runFork(
177+
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
178+
)
179+
},
180+
close: (code?: number, reason?: string) => {
181+
if (closed) return
182+
closed = true
183+
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
184+
},
185+
}
186+
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
187+
if (!handler) return HttpServerResponse.empty()
188+
189+
yield* socket
190+
.runRaw((message) => {
191+
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
192+
})
193+
.pipe(
194+
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
195+
Effect.ensuring(
196+
Effect.sync(() => {
197+
closed = true
198+
handler.onClose()
199+
}),
200+
),
201+
Effect.orDie,
202+
)
203+
return HttpServerResponse.empty()
204+
}).pipe(Effect.provide(Pty.defaultLayer)),
205+
)

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
66
import { Observability } from "@/effect"
77
import { InstanceBootstrap } from "@/project/bootstrap"
88
import { Instance } from "@/project/instance"
9+
import { Pty } from "@/pty"
910
import { lazy } from "@/util/lazy"
1011
import { Filesystem } from "@/util"
1112
import { authorizationLayer } from "./auth"
@@ -17,6 +18,7 @@ import { InstanceApi, instanceHandlers } from "./instance"
1718
import { McpApi, mcpHandlers } from "./mcp"
1819
import { PermissionApi, permissionHandlers } from "./permission"
1920
import { ProjectApi, projectHandlers } from "./project"
21+
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
2022
import { ProviderApi, providerHandlers } from "./provider"
2123
import { QuestionApi, questionHandlers } from "./question"
2224
import { SessionApi, sessionHandlers } from "./session"
@@ -68,12 +70,14 @@ const instance = HttpRouter.middleware()(
6870

6971
export const routes = Layer.mergeAll(
7072
eventRoute,
73+
ptyConnectRoute,
7174
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
7275
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
7376
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
7477
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
7578
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
7679
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
80+
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
7781
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
7882
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
7983
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question"
1616
import { PermissionRoutes } from "./permission"
1717
import { Flag } from "@opencode-ai/core/flag/flag"
1818
import { ExperimentalHttpApiServer } from "./httpapi/server"
19+
import { PtyPaths } from "./httpapi/pty"
1920
import { EventPaths } from "./httpapi/event"
2021
import { ExperimentalPaths } from "./httpapi/experimental"
2122
import { FilePaths } from "./httpapi/file"
@@ -96,6 +97,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
9697
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
9798
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
9899
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
100+
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
101+
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
102+
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
103+
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
104+
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
105+
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
99106
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
100107
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
101108
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import type { UpgradeWebSocket } from "hono/ws"
3+
import { Flag } from "@opencode-ai/core/flag/flag"
4+
import { PtyID } from "../../src/pty/schema"
5+
import { Instance } from "../../src/project/instance"
6+
import { InstanceRoutes } from "../../src/server/routes/instance"
7+
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
8+
import { Log } from "../../src/util"
9+
import { resetDatabase } from "../fixture/db"
10+
import { tmpdir } from "../fixture/fixture"
11+
12+
void Log.init({ print: false })
13+
14+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
15+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
16+
const testPty = process.platform === "win32" ? test.skip : test
17+
18+
function app() {
19+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
20+
return InstanceRoutes(websocket)
21+
}
22+
23+
afterEach(async () => {
24+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
25+
await Instance.disposeAll()
26+
await resetDatabase()
27+
})
28+
29+
describe("pty HttpApi bridge", () => {
30+
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
31+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
32+
const headers = { "x-opencode-directory": tmp.path }
33+
const list = await app().request(PtyPaths.list, { headers })
34+
expect(list.status).toBe(200)
35+
expect(await list.json()).toEqual([])
36+
37+
const created = await app().request(PtyPaths.create, {
38+
method: "POST",
39+
headers: { ...headers, "content-type": "application/json" },
40+
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
41+
})
42+
expect(created.status).toBe(200)
43+
const info = await created.json()
44+
45+
try {
46+
expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" })
47+
48+
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
49+
expect(found.status).toBe(200)
50+
expect(await found.json()).toMatchObject({ id: info.id, title: "demo" })
51+
52+
const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
53+
method: "PUT",
54+
headers: { ...headers, "content-type": "application/json" },
55+
body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }),
56+
})
57+
expect(updated.status).toBe(200)
58+
expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" })
59+
} finally {
60+
await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
61+
}
62+
63+
const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
64+
expect(missing.status).toBe(404)
65+
})
66+
67+
test("returns 404 for missing PTY websocket before upgrade", async () => {
68+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
69+
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
70+
headers: { "x-opencode-directory": tmp.path },
71+
})
72+
expect(response.status).toBe(404)
73+
})
74+
})

0 commit comments

Comments
 (0)