1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";
4import * as Crypto from "effect/Crypto";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as PlatformError from "effect/PlatformError";
10import * as Schema from "effect/Schema";
11
12import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
15
16import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
17import {
18 PUBLISH_AGENT_ACTIVITY_SECRET,
19 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
20 RELAY_URL_SECRET,
21} from "../cloud/config.ts";
22import * as ServerConfig from "../config.ts";
23import * as ServerEnvironment from "./ServerEnvironment.ts";
24
25const isServerEnvironmentIdPersistenceError = Schema.is(
26 ServerEnvironment.ServerEnvironmentIdPersistenceError,
27);
28
29const layerServerEnvironment = (baseDir: string) =>
30 ServerEnvironment.layer.pipe(
31 Layer.provide(ServerSecretStore.layer),
32 Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
33 );
34
35const layerEmptySecretStore = Layer.succeed(
36 ServerSecretStore.ServerSecretStore,
37 ServerSecretStore.ServerSecretStore.of({
38 get: () => Effect.succeedNone,
39 set: () => Effect.void,
40 create: () => Effect.void,
41 getOrCreateRandom: () => Effect.succeed(new Uint8Array()),
42 remove: () => Effect.void,
43 }),
44);
45
46const makeServerConfig = Effect.fn(function* (baseDir: string) {
47 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
48
49 return {
50 ...derivedPaths,
51 logLevel: "Error",
52 traceMinLevel: "Info",
53 traceTimingEnabled: true,
54 traceBatchWindowMs: 200,
55 traceMaxBytes: 10 * 1024 * 1024,
56 traceMaxFiles: 10,
57 otlpTracesUrl: undefined,
58 otlpMetricsUrl: undefined,
59 otlpLogsUrl: undefined,
60 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
61 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
62 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
63 otelEnvironment: OtelEnvironment.none,
64 cwd: process.cwd(),
65 baseDir,
66 mode: "web",
67 autoBootstrapProjectFromCwd: false,
68 logWebSocketEvents: false,
69 tailscaleServeEnabled: false,
70 tailscaleServePort: 443,
71 port: 0,
72 host: undefined,
73 desktopBootstrapToken: undefined,
74 staticDir: undefined,
75 devUrl: undefined,
76 devAllowedOrigins: [],
77 noBrowser: false,
78 startupPresentation: "browser",
79 } satisfies ServerConfig.ServerConfig["Service"];
80});
81
82it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
83 it.effect("publishes proven install ownership only for manually updated servers", () =>
84 Effect.gen(function* () {
85 const fs = yield* FileSystem.FileSystem;
86 const baseDir = yield* fs.makeTempDirectoryScoped();
87 const prefix = `${baseDir}/node`;
88 const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
89 yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true });
90 yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true });
91 yield* fs.writeFileString(entry, "");
92 yield* fs.writeFileString(
93 `${prefix}/lib/node_modules/t3/package.json`,
94 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
95 );
96 yield* fs.symlink(entry, `${prefix}/bin/t3`);
97 const config = yield* makeServerConfig(baseDir);
98 yield* fs.makeDirectory(config.stateDir, { recursive: true });
99 for (const mode of ["web", "desktop"] as const) {
100 const descriptor = yield* Effect.gen(function* () {
101 const environment = yield* ServerEnvironment.ServerEnvironment;
102 return yield* environment.getDescriptor;
103 }).pipe(
104 Effect.provide(
105 ServerEnvironment.layer.pipe(
106 Layer.provide(layerEmptySecretStore),
107 Layer.provide(ServerConfig.layer({ ...config, mode })),
108 ),
109 ),
110 Effect.provideService(HostProcess.Arguments, ["node", entry]),
111 Effect.provideService(HostProcess.IsExecutable, false),
112 Effect.provideService(HostProcess.Platform, "linux"),
113 Effect.provideService(HostProcess.Environment, {}),
114 );
115 expect(descriptor.capabilities.serverInstallation).toEqual(
116 mode === "web" ? { kind: "npm-global", prefix } : undefined,
117 );
118 expect(descriptor.capabilities.serverSelfUpdate).toBe(
119 mode === "web" ? undefined : "desktop-managed",
120 );
121 expect(descriptor.capabilities).toMatchObject({
122 settledWorktreeCleanup: true,
123 storageCleanupRun: true,
124 projectWorktreeCleanup: true,
125 worktreesDirectory: true,
126 threadAutoSettlementScope: true,
127 });
128 }
129 }),
130 );
131 it.effect.each([
132 { name: "missing", content: undefined },
133 { name: "empty", content: "" },
134 { name: "whitespace-only", content: " \t\n" },
135 ])("concurrent initializers recover a $name environment id file", ({ content }) =>
136 Effect.gen(function* () {
137 const fileSystem = yield* FileSystem.FileSystem;
138 const crypto = yield* Crypto.Crypto;
139 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
140 prefix: "t3-server-environment-concurrent-test-",
141 });
142 const serverConfig = yield* makeServerConfig(baseDir);
143 yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });
144 if (content !== undefined) {
145 yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content);
146 }
147 const bothGenerated = yield* Deferred.make<void>();
148 const bothReadEmpty = yield* Deferred.make<void>();
149 const firstInitialized = yield* Deferred.make<void>();
150 let remaining = 2;
151 let emptyReads = 0;
152 const readIdentity = Effect.gen(function* () {
153 const identity = yield* ServerEnvironment.ServerEnvironmentIdentity;
154 return yield* identity.getEnvironmentId;
155 }).pipe(
156 Effect.tap(() => Deferred.succeed(firstInitialized, undefined)),
157 Effect.provide(Layer.fresh(ServerEnvironment.layerIdentity)),
158 Effect.provideService(ServerConfig.ServerConfig, serverConfig),
159 Effect.provideService(FileSystem.FileSystem, {
160 ...fileSystem,
161 readFileString: (path) =>
162 fileSystem.readFileString(path).pipe(
163 Effect.tap(
164 Effect.fn(function* (value) {
165 if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) {
166 return;
167 }
168 // Both observe the empty file, but one repairs it after the other has finished.
169 if (++emptyReads === 2) {
170 yield* Deferred.succeed(bothReadEmpty, undefined);
171 yield* Deferred.await(firstInitialized);
172 } else {
173 yield* Deferred.await(bothReadEmpty);
174 }
175 }),
176 ),
177 ),
178 }),
179 Effect.provideService(Crypto.Crypto, {
180 ...crypto,
181 randomUUIDv4: (() =>
182 Effect.gen(function* () {
183 const id = yield* crypto.randomUUIDv4();
184 if (--remaining === 0) {
185 yield* Deferred.succeed(bothGenerated, undefined);
186 }
187 yield* Deferred.await(bothGenerated);
188 return id;
189 })) as Crypto.Crypto["randomUUIDv4"],
190 }),
191 );
192
193 const [first, second] = yield* Effect.all([readIdentity, readIdentity], {
194 concurrency: "unbounded",
195 });
196 const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath);
197
198 expect(first).toBe(second);
199 expect(persisted.trim()).toBe(first);
200 }),
201 );
202
203 it.effect("persists the environment id across service restarts", () =>
204 Effect.gen(function* () {
205 const fileSystem = yield* FileSystem.FileSystem;
206 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
207 prefix: "t3-server-environment-test-",
208 });
209
210 const first = yield* Effect.gen(function* () {
211 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
212 return yield* serverEnvironment.getDescriptor;
213 }).pipe(Effect.provide(layerServerEnvironment(baseDir)));
214 const second = yield* Effect.gen(function* () {
215 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
216 return yield* serverEnvironment.getDescriptor;
217 }).pipe(Effect.provide(layerServerEnvironment(baseDir)));
218
219 expect(first.environmentId).toBe(second.environmentId);
220 expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION);
221 expect(second.capabilities.repositoryIdentity).toBe(true);
222 expect(second.capabilities.connectionProbe).toBe(true);
223 expect(second.capabilities.attachmentUploads).toBe(true);
224 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
225 expect(second.capabilities.pullRequests).toBe(true);
226 expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
227 expect(second.capabilities.usagePriceOverrides).toBe(true);
228 expect(second.capabilities.threadActiveReorder).toBe(true);
229 expect(second.capabilities.threadTitleRegeneration).toBe(true);
230 expect(second.capabilities.threadPullRequests).toBe(true);
231 expect(second.capabilities.threadPullRequestLinking).toBe(true);
232 expect(second.capabilities.serverResolvedCommandContext).toBe(true);
233 expect(second.capabilities.projectProviderInstanceOverrides).toBe(true);
234 expect(second.capabilities.agentActivityPublishing).toBe(false);
235 }),
236 );
237
238 it.effect("reports agent activity publishing from the current secret state", () =>
239 Effect.gen(function* () {
240 const fileSystem = yield* FileSystem.FileSystem;
241 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
242 prefix: "t3-server-environment-publish-test-",
243 });
244 const layerTest = Layer.mergeAll(
245 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
246 ServerSecretStore.layer,
247 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
248
249 yield* Effect.gen(function* () {
250 const secrets = yield* ServerSecretStore.ServerSecretStore;
251 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
252 const encode = (value: string) => new TextEncoder().encode(value);
253
254 const unlinked = yield* serverEnvironment.getDescriptor;
255 expect(unlinked.capabilities.agentActivityPublishing).toBe(false);
256
257 // The opt-in alone is not enough: without relay link credentials no
258 // publish would leave this environment.
259 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true"));
260 const withoutLink = yield* serverEnvironment.getDescriptor;
261 expect(withoutLink.capabilities.agentActivityPublishing).toBe(false);
262
263 // Empty credentials are as unconfigured as missing ones: the
264 // publisher's truthiness gate skips them, so the capability must not
265 // advertise publishing.
266 yield* secrets.set(RELAY_URL_SECRET, encode(""));
267 yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential"));
268 const emptyUrl = yield* serverEnvironment.getDescriptor;
269 expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false);
270
271 yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example"));
272 const linked = yield* serverEnvironment.getDescriptor;
273 expect(linked.capabilities.agentActivityPublishing).toBe(true);
274
275 // The toggle changes at runtime, so the same service instance must
276 // reflect a flip without a restart.
277 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false"));
278 const disabled = yield* serverEnvironment.getDescriptor;
279 expect(disabled.capabilities.agentActivityPublishing).toBe(false);
280 }).pipe(Effect.provide(layerTest));
281 }),
282 );
283
284 it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () =>
285 Effect.gen(function* () {
286 const fileSystem = yield* FileSystem.FileSystem;
287 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
288 prefix: "t3-server-environment-desktop-update-test-",
289 });
290 const serverConfig = yield* makeServerConfig(baseDir);
291 yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });
292
293 const describeWith = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) =>
294 Effect.gen(function* () {
295 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
296 return yield* serverEnvironment.getDescriptor;
297 }).pipe(
298 Effect.provide(
299 ServerEnvironment.layer.pipe(
300 Layer.provide(ServerSecretStore.layer),
301 Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })),
302 ),
303 ),
304 );
305
306 const withFd = yield* describeWith({ mode: "desktop", desktopTelemetryControlFd: 5 });
307 expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
308 expect(withFd.capabilities.desktopAppUpdate).toBe(true);
309 expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true);
310 // v2 recovery terminalizes running runs on restart, so continuation
311 // stays unadvertised until the v2 runtime carries the markers.
312 expect(withFd.capabilities.serverUpdateThreadContinuation).toBeUndefined();
313
314 const withoutFd = yield* describeWith({ mode: "desktop" });
315 expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed");
316 expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined();
317 expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined();
318 expect(withoutFd.capabilities.serverUpdateThreadContinuation).toBeUndefined();
319
320 const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 });
321 expect(web.capabilities.desktopAppUpdate).toBeUndefined();
322 }),
323 );
324
325 it.effect("structures persisted environment id filesystem failures", () =>
326 Effect.gen(function* () {
327 const fileSystem = yield* FileSystem.FileSystem;
328 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
329 prefix: "t3-server-environment-error-test-",
330 });
331 const serverConfig = yield* makeServerConfig(baseDir);
332 const environmentIdPath = serverConfig.environmentIdPath;
333 const tempPath = `${environmentIdPath}.tmp`;
334 const methodByOperation = {
335 check: "exists",
336 read: "readFileString",
337 write: "writeFileString",
338 } as const;
339
340 for (const operation of ["check", "read", "write"] as const) {
341 const writeAttempts: string[] = [];
342 const cause = PlatformError.systemError({
343 _tag: "PermissionDenied",
344 module: "FileSystem",
345 method: methodByOperation[operation],
346 description: "permission denied",
347 pathOrDescriptor: environmentIdPath,
348 });
349 const layerFailingFileSystem = FileSystem.layerNoop({
350 exists: () =>
351 operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"),
352 readFileString: () => Effect.fail(cause),
353 makeTempFileScoped: () => Effect.succeed(tempPath),
354 writeFileString: (path) => {
355 writeAttempts.push(path);
356 return Effect.fail(cause);
357 },
358 });
359
360 const error = yield* Effect.gen(function* () {
361 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
362 return yield* serverEnvironment.getDescriptor;
363 }).pipe(
364 Effect.provide(
365 ServerEnvironment.layer.pipe(
366 Layer.provide(layerEmptySecretStore),
367 Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), layerFailingFileSystem)),
368 ),
369 ),
370 Effect.flip,
371 );
372
373 expect(isServerEnvironmentIdPersistenceError(error)).toBe(true);
374 if (!isServerEnvironmentIdPersistenceError(error)) {
375 throw error;
376 }
377 expect(error.operation).toBe(operation);
378 expect(error.environmentIdPath).toBe(environmentIdPath);
379 expect(error.cause).toBe(cause);
380 expect(error.message).toBe(
381 `Server environment ID ${operation} failed at '${environmentIdPath}'.`,
382 );
383 expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []);
384 }
385 }),
386 );
387});