|
|
@@ -3,6 +3,9 @@ import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "e
|
|
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
|
|
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
|
|
import * as Socket from "effect/unstable/socket/Socket"
|
|
|
+import path from "path"
|
|
|
+import { pathToFileURL } from "url"
|
|
|
+import { mkdir } from "fs/promises"
|
|
|
import { Location } from "@opencode-ai/core/location"
|
|
|
import { Pty } from "@opencode-ai/core/pty"
|
|
|
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
|
|
@@ -171,4 +174,78 @@ describe("v2 pty HttpApi", () => {
|
|
|
expect(removed.status).toBe(204)
|
|
|
}),
|
|
|
)
|
|
|
+
|
|
|
+ ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
|
|
+ "applies plugin shell environment before forced PTY values",
|
|
|
+ () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
|
|
+ const plugin = path.join(dir, "plugin.ts")
|
|
|
+ const cwd = path.join(dir, "child")
|
|
|
+ yield* Effect.promise(() => mkdir(cwd))
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ Bun.write(
|
|
|
+ plugin,
|
|
|
+ [
|
|
|
+ "export default async () => ({",
|
|
|
+ ' "shell.env": (input, output) => {',
|
|
|
+ ' output.env.SHARED = "plugin"',
|
|
|
+ ' output.env.PLUGIN = "plugin"',
|
|
|
+ ' output.env.TERM = "plugin"',
|
|
|
+ " output.env.HOOK_CWD = input.cwd",
|
|
|
+ " },",
|
|
|
+ "})",
|
|
|
+ "",
|
|
|
+ ].join("\n"),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ yield* Effect.promise(() =>
|
|
|
+ Bun.write(
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
+ JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ const created = yield* HttpClientRequest.post("/api/pty").pipe(
|
|
|
+ directoryHeader(dir),
|
|
|
+ HttpClientRequest.bodyJson({
|
|
|
+ command: "/bin/sh",
|
|
|
+ args: [
|
|
|
+ "-c",
|
|
|
+ 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5',
|
|
|
+ ],
|
|
|
+ cwd,
|
|
|
+ env: { CALLER: "caller", SHARED: "caller", TERM: "caller" },
|
|
|
+ }),
|
|
|
+ Effect.flatMap(HttpClient.execute),
|
|
|
+ )
|
|
|
+ expect(created.status).toBe(200)
|
|
|
+ const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data
|
|
|
+
|
|
|
+ const socket = yield* Socket.makeWebSocket(
|
|
|
+ `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`,
|
|
|
+ { closeCodeIsError: () => false },
|
|
|
+ )
|
|
|
+ const messages = yield* Queue.unbounded<string>()
|
|
|
+ yield* socket
|
|
|
+ .runRaw((message) =>
|
|
|
+ Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
|
|
+ )
|
|
|
+ .pipe(Effect.catch(() => Effect.void), Effect.forkScoped)
|
|
|
+ const write = yield* socket.writer
|
|
|
+
|
|
|
+ const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
|
|
+ if (next.includes(expected)) return next
|
|
|
+ return yield* takeUntil(expected, next)
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain(
|
|
|
+ `caller|plugin|plugin|xterm-256color|${cwd}`,
|
|
|
+ )
|
|
|
+ yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
|
|
+ yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|