Skip to content
Source
Sign inSign up

OpenCode2Server.test.ts

274 lines10.7 KB

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