| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 14 | import { describe } from "vite-plus/test"; |
| 15 | |
| 16 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; |
| 17 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; |
| 18 | import * as OpenCode2Client from "./OpenCode2Client.ts"; |
| 19 | import * as OpenCode2Server from "./OpenCode2Server.ts"; |
| 20 | |
| 21 | // Shapes and content types observed on `opencode serve` 2.0.18 (pid and port replaced). 1.x |
| 22 | // answers /api/info with its web UI: 200 text/html. |
| 23 | const INFO_BODY = |
| 24 | '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}'; |
| 25 | const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}'; |
| 26 | const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>"; |
| 27 | |
| 28 | const layerServerReplying = (reply: { |
| 29 | readonly status: number; |
| 30 | readonly contentType?: string; |
| 31 | readonly body: string; |
| 32 | }) => |
| 33 | Layer.succeed( |
| 34 | HttpClient.HttpClient, |
| 35 | HttpClient.make((request) => |
| 36 | Effect.succeed( |
| 37 | HttpClientResponse.fromWeb( |
| 38 | request, |
| 39 | new Response(reply.body, { |
| 40 | status: reply.status, |
| 41 | headers: reply.contentType === undefined ? {} : { "content-type": reply.contentType }, |
| 42 | }), |
| 43 | ), |
| 44 | ), |
| 45 | ), |
| 46 | ); |
| 47 | |
| 48 | const verify = (httpClient: Layer.Layer<HttpClient.HttpClient>, url = "http://127.0.0.1:4096") => |
| 49 | Effect.gen(function* () { |
| 50 | const opencode = yield* OpenCode2Client.OpenCode2Client; |
| 51 | const { client } = yield* opencode.connect({ baseUrl: url, password: "secret" }); |
| 52 | return yield* OpenCode2Server.verifyServer(client); |
| 53 | }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(httpClient)))); |
| 54 | |
| 55 | describe("OpenCode2Server.verifyServer", () => { |
| 56 | it.effect("returns the version of an authenticated OpenCode 2 server", () => |
| 57 | Effect.gen(function* () { |
| 58 | const version = yield* verify( |
| 59 | layerServerReplying({ status: 200, contentType: "application/json", body: INFO_BODY }), |
| 60 | ); |
| 61 | assert.strictEqual(version, "2.0.18"); |
| 62 | }), |
| 63 | ); |
| 64 | |
| 65 | it.effect("reports a rejected password, not a wrong server", () => |
| 66 | Effect.gen(function* () { |
| 67 | const error = yield* verify( |
| 68 | layerServerReplying({ |
| 69 | status: 401, |
| 70 | contentType: "application/json", |
| 71 | body: UNAUTHORIZED_BODY, |
| 72 | }), |
| 73 | ).pipe(Effect.flip); |
| 74 | assert.include(error.detail, "rejected the server password"); |
| 75 | }), |
| 76 | ); |
| 77 | |
| 78 | // 1.x rejects a wrong password with an empty 401, which the client cannot decode. |
| 79 | it.effect("reports an empty-body 401 as a rejected password", () => |
| 80 | Effect.gen(function* () { |
| 81 | const error = yield* verify(layerServerReplying({ status: 401, body: "" })).pipe(Effect.flip); |
| 82 | assert.include(error.detail, "rejected the server password"); |
| 83 | }), |
| 84 | ); |
| 85 | |
| 86 | it.effect("reports a server error as a server error, not as unreachable", () => |
| 87 | Effect.gen(function* () { |
| 88 | for (const status of [500, 502]) { |
| 89 | const error = yield* verify( |
| 90 | layerServerReplying({ status, contentType: "text/plain", body: "upstream failed" }), |
| 91 | ).pipe(Effect.flip); |
| 92 | assert.include(error.detail, `returned HTTP ${status}`); |
| 93 | } |
| 94 | }), |
| 95 | ); |
| 96 | |
| 97 | // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. |
| 98 | it.effect("rejects a server that answers with the web UI's HTML", () => |
| 99 | Effect.gen(function* () { |
| 100 | const error = yield* verify( |
| 101 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), |
| 102 | ).pipe(Effect.flip); |
| 103 | assert.include(error.detail, "is not an OpenCode 2 server"); |
| 104 | }), |
| 105 | ); |
| 106 | |
| 107 | it.effect("reports an unreachable server as unreachable", () => |
| 108 | Effect.gen(function* () { |
| 109 | const error = yield* verify(FetchHttpClient.layer, "http://127.0.0.1:1").pipe(Effect.flip); |
| 110 | assert.include(error.detail, "Could not reach the OpenCode server"); |
| 111 | }), |
| 112 | ); |
| 113 | }); |
| 114 | |
| 115 | describe("OpenCode2Server error details", () => { |
| 116 | // `detail` reaches clients through the provider status message, and a |
| 117 | // `serverUrl` can carry credentials in its userinfo or query. |
| 118 | const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; |
| 119 | const detailFor = (httpClient: Layer.Layer<HttpClient.HttpClient>) => |
| 120 | Effect.gen(function* () { |
| 121 | const server = yield* OpenCode2Server.make({ |
| 122 | binaryPath: "opencode", |
| 123 | serverUrl, |
| 124 | serverPassword: "secret", |
| 125 | directory: "/project", |
| 126 | environment: {}, |
| 127 | }); |
| 128 | const error = yield* server.withConnection(() => Effect.void).pipe(Effect.flip); |
| 129 | return error.detail; |
| 130 | }).pipe( |
| 131 | Effect.provide( |
| 132 | Layer.mergeAll( |
| 133 | OpenCode2Client.layer.pipe(Layer.provide(httpClient)), |
| 134 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), |
| 135 | ).pipe(Layer.provideMerge(NodeServices.layer)), |
| 136 | ), |
| 137 | ); |
| 138 | |
| 139 | it.effect("never include the server URL", () => |
| 140 | Effect.gen(function* () { |
| 141 | const layerHanging = Layer.succeed( |
| 142 | HttpClient.HttpClient, |
| 143 | HttpClient.make(() => Effect.never), |
| 144 | ); |
| 145 | const timedOut = yield* detailFor(layerHanging).pipe(Effect.forkChild); |
| 146 | yield* TestClock.adjust("10 seconds"); |
| 147 | const details = [ |
| 148 | yield* detailFor(FetchHttpClient.layer), |
| 149 | yield* detailFor(layerServerReplying({ status: 401, body: "" })), |
| 150 | yield* detailFor(layerServerReplying({ status: 502, contentType: "text/plain", body: "" })), |
| 151 | yield* detailFor( |
| 152 | layerServerReplying({ status: 200, contentType: "text/html", body: SPA_BODY }), |
| 153 | ), |
| 154 | yield* Fiber.join(timedOut), |
| 155 | ]; |
| 156 | assert.deepStrictEqual(details, [ |
| 157 | "Could not reach the OpenCode server.", |
| 158 | "The OpenCode server rejected the server password.", |
| 159 | "The OpenCode server returned HTTP 502.", |
| 160 | "The server is not an OpenCode 2 server.", |
| 161 | "Timed out waiting for the OpenCode server.", |
| 162 | ]); |
| 163 | for (const detail of details) { |
| 164 | assert.notInclude(detail, "url-secret"); |
| 165 | assert.notInclude(detail, "query-secret"); |
| 166 | assert.notInclude(detail, "127.0.0.1"); |
| 167 | } |
| 168 | }).pipe(Effect.provide(TestClock.layer())), |
| 169 | ); |
| 170 | }); |
| 171 | |
| 172 | describe("OpenCode2Server passwords", () => { |
| 173 | it.effect("generates a distinct 256-bit password per server", () => |
| 174 | Effect.gen(function* () { |
| 175 | const first = yield* OpenCode2Server.generatePassword; |
| 176 | const second = yield* OpenCode2Server.generatePassword; |
| 177 | assert.match(Redacted.value(first), /^[A-Za-z0-9_-]{43}$/); |
| 178 | assert.notStrictEqual(Redacted.value(first), Redacted.value(second)); |
| 179 | assert.notInclude(String(first), Redacted.value(first)); |
| 180 | }).pipe(Effect.provide(NodeServices.layer)), |
| 181 | ); |
| 182 | |
| 183 | it("hands the spawned server only the T3 password", () => { |
| 184 | const password = Redacted.make("t3-generated"); |
| 185 | const environment = OpenCode2Server.serverEnvironment( |
| 186 | { PATH: "/bin", OPENCODE_SERVER_PASSWORD: "ambient", OPENCODE_PASSWORD: "ambient" }, |
| 187 | password, |
| 188 | ); |
| 189 | assert.deepStrictEqual(environment, { PATH: "/bin", OPENCODE_PASSWORD: "t3-generated" }); |
| 190 | }); |
| 191 | }); |
| 192 | |
| 193 | // Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and |
| 194 | // prints the 2.x banner (plus the generated-password line 2.x prints when no |
| 195 | // password is set, which T3 must never need). |
| 196 | const FAKE_SERVER = `import { createServer } from "node:http"; |
| 197 | const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); |
| 198 | const server = createServer((request, response) => { |
| 199 | if (request.headers.authorization !== expected) { |
| 200 | response.writeHead(401, { "content-type": "application/json", "www-authenticate": "Basic" }); |
| 201 | response.end(${JSON.stringify(UNAUTHORIZED_BODY)}); |
| 202 | return; |
| 203 | } |
| 204 | response.writeHead(200, { "content-type": "application/json" }); |
| 205 | response.end(JSON.stringify({ version: "2.0.18", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); |
| 206 | }); |
| 207 | server.listen(0, "127.0.0.1", () => { |
| 208 | process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); |
| 209 | process.stdout.write("server password not-the-t3-password\\n"); |
| 210 | }); |
| 211 | `; |
| 212 | |
| 213 | describe("OpenCode2Server spawned server", () => { |
| 214 | it.live( |
| 215 | "is ready once /api/info accepts the generated password", |
| 216 | () => |
| 217 | Effect.gen(function* () { |
| 218 | const fs = yield* FileSystem.FileSystem; |
| 219 | const path = yield* Path.Path; |
| 220 | const platform = yield* HostProcess.Platform; |
| 221 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-fake-" }); |
| 222 | const isWindows = platform === "win32"; |
| 223 | const binaryPath = path.join(directory, isWindows ? "opencode.cmd" : "opencode"); |
| 224 | const scriptPath = path.join(directory, "opencode.mjs"); |
| 225 | yield* fs.writeFileString(scriptPath, FAKE_SERVER); |
| 226 | yield* fs.writeFileString( |
| 227 | binaryPath, |
| 228 | isWindows |
| 229 | ? `@echo off\r\n"${yield* HostProcess.ExecutablePath}" "${scriptPath}" %*\r\n` |
| 230 | : `#!/bin/sh\nexec "${yield* HostProcess.ExecutablePath}" "${scriptPath}" "$@"\n`, |
| 231 | ); |
| 232 | if (!isWindows) yield* fs.chmod(binaryPath, 0o755); |
| 233 | |
| 234 | const server = yield* OpenCode2Server.make({ |
| 235 | binaryPath, |
| 236 | serverUrl: "", |
| 237 | serverPassword: "", |
| 238 | directory, |
| 239 | environment: { ...(yield* HostProcess.Environment), OPENCODE_SERVER_PASSWORD: "ambient" }, |
| 240 | }); |
| 241 | const first = yield* server.withConnection((connection) => Effect.succeed(connection)); |
| 242 | const second = yield* server.withConnection((connection) => Effect.succeed(connection)); |
| 243 | assert.strictEqual(first.version, "2.0.18"); |
| 244 | assert.strictEqual(first.external, false); |
| 245 | assert.strictEqual(second.client, first.client); |
| 246 | }).pipe( |
| 247 | Effect.scoped, |
| 248 | Effect.provide( |
| 249 | Layer.mergeAll( |
| 250 | OpenCode2Client.layer, |
| 251 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), |
| 252 | ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), |
| 253 | ), |
| 254 | ), |
| 255 | 15_000, |
| 256 | ); |
| 257 | }); |