1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Redacted from "effect/Redacted";
10import * as TestClock from "effect/testing/TestClock";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientResponse from "effect/http/HttpClientResponse";
14import { describe } from "vite-plus/test";
15
16import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
17import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
18import * as OpenCode2Client from "./OpenCode2Client.ts";
19import * 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.
23const INFO_BODY =
24 '{"version":"2.0.18","pid":4242,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp/opencode"}}';
25const UNAUTHORIZED_BODY = '{"_tag":"UnauthorizedError","message":"Authentication required"}';
26const SPA_BODY = "<!doctype html><html><head><title>OpenCode</title></head></html>";
27
28const 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
48const 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
55describe("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
115describe("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
172describe("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).
196const FAKE_SERVER = `import { createServer } from "node:http";
197const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64");
198const 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});
207server.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
213describe("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});