1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/http";
6import * as NetAddress from "effect/net/NetAddress";
7
8import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
9import * as McpSessionRegistry from "./McpSessionRegistry.ts";
10
11const environmentId = EnvironmentId.make("environment-1");
12const makeFakeHttpServer = (hostname: string, port = 43123) =>
13 HttpServer.HttpServer.of({
14 address: NetAddress.inetAddressFromIpStringUnsafe(hostname, port),
15 serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
16 });
17const fakeHttpServer = makeFakeHttpServer("127.0.0.1");
18const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({
19 getEnvironmentId: Effect.succeed(environmentId),
20 getDescriptor: Effect.die("unused"),
21});
22
23const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
24 McpSessionRegistry.__testing
25 .make({
26 now,
27 livenessWindowMs: 100,
28 })
29 .pipe(
30 Effect.provideService(HttpServer.HttpServer, httpServer),
31 Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
32 Effect.provide(NodeServices.layer),
33 );
34
35it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
36 Effect.gen(function* () {
37 let timestamp = 1_000;
38 const registry = yield* makeRegistry(() => timestamp);
39 const threadId = ThreadId.make("thread-1");
40 const issued = yield* registry.issue({
41 threadId,
42 providerInstanceId: ProviderInstanceId.make("codex"),
43 capabilities: new Set(["preview"]),
44 });
45 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
46 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
47 expect(token.length).toBeGreaterThan(20);
48
49 const resolved = yield* registry.resolve(token);
50 expect(resolved?.thread.threadId).toBe(threadId);
51 expect(resolved?.capabilities).toEqual(
52 new Set(["preview", "orchestration", "worktree", "pull-requests"]),
53 );
54
55 yield* registry.revokeThread(threadId);
56 expect(yield* registry.resolve(token)).toBeUndefined();
57
58 timestamp += 2_000;
59 }),
60);
61
62it.effect("always grants pull-requests and gates browser and device access independently", () =>
63 Effect.gen(function* () {
64 const registry = yield* makeRegistry(() => 1_000);
65 const withPreview = yield* registry.issue({
66 threadId: ThreadId.make("thread-preview"),
67 providerInstanceId: ProviderInstanceId.make("codex"),
68 capabilities: new Set(["preview"]),
69 });
70 const withoutPreview = yield* registry.issue({
71 threadId: ThreadId.make("thread-no-preview"),
72 providerInstanceId: ProviderInstanceId.make("codex"),
73 capabilities: new Set(),
74 });
75 const withDevice = yield* registry.issue({
76 threadId: ThreadId.make("thread-device"),
77 providerInstanceId: ProviderInstanceId.make("codex"),
78 capabilities: new Set(["device"]),
79 });
80 const capabilitiesOf = (issued: typeof withPreview) =>
81 registry
82 .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, ""))
83 .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort()));
84
85 expect(yield* capabilitiesOf(withPreview)).toEqual([
86 "orchestration",
87 "preview",
88 "pull-requests",
89 "worktree",
90 ]);
91 expect(yield* capabilitiesOf(withoutPreview)).toEqual([
92 "orchestration",
93 "pull-requests",
94 "worktree",
95 ]);
96 expect(yield* capabilitiesOf(withDevice)).toEqual([
97 "device",
98 "orchestration",
99 "pull-requests",
100 "worktree",
101 ]);
102 }),
103);
104
105it.effect("builds MCP endpoints from the bound server host", () =>
106 Effect.gen(function* () {
107 const cases = [
108 ["100.64.0.40", "http://100.64.0.40:43123/mcp"],
109 ["0.0.0.0", "http://127.0.0.1:43123/mcp"],
110 ["::", "http://127.0.0.1:43123/mcp"],
111 ["::1", "http://[::1]:43123/mcp"],
112 ["127.0.0.1", "http://127.0.0.1:43123/mcp"],
113 ] as const;
114
115 for (const [hostname, expectedEndpoint] of cases) {
116 const registry = yield* makeRegistry(() => 1_000, makeFakeHttpServer(hostname));
117 const issued = yield* registry.issue({
118 threadId: ThreadId.make(`thread-${hostname}`),
119 providerInstanceId: ProviderInstanceId.make("codex"),
120 capabilities: new Set(["preview"]),
121 });
122 expect(issued.config.endpoint).toBe(expectedEndpoint);
123 }
124 }),
125);
126
127it.effect("expires credentials once their session stops showing signs of life", () =>
128 Effect.gen(function* () {
129 let timestamp = 1_000;
130 const registry = yield* makeRegistry(() => timestamp);
131 const issued = yield* registry.issue({
132 threadId: ThreadId.make("thread-2"),
133 providerInstanceId: ProviderInstanceId.make("claude"),
134 capabilities: new Set(["preview"]),
135 });
136 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
137 timestamp += 101;
138 expect(yield* registry.resolve(token)).toBeUndefined();
139 }),
140);
141
142it.effect("keeps a credential alive across turns that never touch an MCP tool", () =>
143 Effect.gen(function* () {
144 let timestamp = 1_000;
145 const registry = yield* makeRegistry(() => timestamp);
146 const threadId = ThreadId.make("thread-3");
147 const issued = yield* registry.issue({
148 threadId,
149 providerInstanceId: ProviderInstanceId.make("claude"),
150 capabilities: new Set(["preview"]),
151 });
152 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
153
154 // Well past the liveness window in total, but each turn reports in before
155 // it lapses — this is the long-session case that used to lose the toolkit.
156 for (let turn = 0; turn < 10; turn += 1) {
157 timestamp += 99;
158 yield* registry.touch(threadId);
159 }
160
161 expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId);
162 }),
163);
164
165it.effect("does not keep credentials of other threads alive", () =>
166 Effect.gen(function* () {
167 let timestamp = 1_000;
168 const registry = yield* makeRegistry(() => timestamp);
169 const issued = yield* registry.issue({
170 threadId: ThreadId.make("thread-4"),
171 providerInstanceId: ProviderInstanceId.make("codex"),
172 capabilities: new Set(["preview"]),
173 });
174 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
175
176 timestamp += 99;
177 yield* registry.touch(ThreadId.make("thread-unrelated"));
178 timestamp += 2;
179
180 expect(yield* registry.resolve(token)).toBeUndefined();
181 }),
182);