Skip to content
Source
Sign inSign up

McpSessionRegistry.test.ts

260 lines9.8 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Layer from "effect/Layer";
4import { expect, it } from "@effect/vitest";
5import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import { HttpServer } from "effect/http";
8import * as NetAddress from "effect/net/NetAddress";
9import * as TestClock from "effect/testing/TestClock";
10​
11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
12import * as McpSessionRegistry from "./McpSessionRegistry.ts";
13​
14const environmentId = EnvironmentId.make("environment-1");
15const makeFakeHttpServer = (hostname: string, port = 43123) =>
16 HttpServer.HttpServer.of({
17 address: NetAddress.inetAddressFromIpStringUnsafe(hostname, port),
18 serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
19 });
20const fakeHttpServer = makeFakeHttpServer("127.0.0.1");
21const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({
22 getEnvironmentId: Effect.succeed(environmentId),
23 getDescriptor: Effect.die("unused"),
24});
25​
26// Without `now`, the registry reads the Effect Clock (TestClock in these tests).
27const makeRegistry = (now: (() => number) | undefined, httpServer = fakeHttpServer) =>
28 McpSessionRegistry.__testing
29 .make({
30 ...(now ? { now } : {}),
31 livenessWindowMs: 100,
32 })
33 .pipe(
34 Effect.provideService(HttpServer.HttpServer, httpServer),
35 Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
36 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
37 );
38​
39it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
40 Effect.gen(function* () {
41 let timestamp = 1_000;
42 const registry = yield* makeRegistry(() => timestamp);
43 const threadId = ThreadId.make("thread-1");
44 const issued = yield* registry.issue({
45 threadId,
46 providerInstanceId: ProviderInstanceId.make("codex"),
47 capabilities: new Set(["preview"]),
48 });
49 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
50 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
51 expect(token.length).toBeGreaterThan(20);
52​
53 const resolved = yield* registry.resolve(token);
54 expect(resolved?.thread.threadId).toBe(threadId);
55 expect(resolved?.capabilities).toEqual(
56 new Set(["preview", "orchestration", "worktree", "pull-requests"]),
57 );
58​
59 yield* registry.revokeThread(threadId);
60 expect(yield* registry.resolve(token)).toBeUndefined();
61​
62 timestamp += 2_000;
63 }),
64);
65​
66it.effect("always grants pull-requests and gates browser and device access independently", () =>
67 Effect.gen(function* () {
68 const registry = yield* makeRegistry(() => 1_000);
69 const withPreview = yield* registry.issue({
70 threadId: ThreadId.make("thread-preview"),
71 providerInstanceId: ProviderInstanceId.make("codex"),
72 capabilities: new Set(["preview"]),
73 });
74 const withoutPreview = yield* registry.issue({
75 threadId: ThreadId.make("thread-no-preview"),
76 providerInstanceId: ProviderInstanceId.make("codex"),
77 capabilities: new Set(),
78 });
79 const withDevice = yield* registry.issue({
80 threadId: ThreadId.make("thread-device"),
81 providerInstanceId: ProviderInstanceId.make("codex"),
82 capabilities: new Set(["device"]),
83 });
84 const capabilitiesOf = (issued: typeof withPreview) =>
85 registry
86 .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, ""))
87 .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort()));
88​
89 expect(yield* capabilitiesOf(withPreview)).toEqual([
90 "orchestration",
91 "preview",
92 "pull-requests",
93 "worktree",
94 ]);
95 expect(yield* capabilitiesOf(withoutPreview)).toEqual([
96 "orchestration",
97 "pull-requests",
98 "worktree",
99 ]);
100 expect(yield* capabilitiesOf(withDevice)).toEqual([
101 "device",
102 "orchestration",
103 "pull-requests",
104 "worktree",
105 ]);
106 }),
107);
108​
109// regression: #3114 use bound host for MCP endpoint
110it.effect("builds MCP endpoints from the bound server host", () =>
111 Effect.gen(function* () {
112 const cases = [
113 ["100.64.0.40", "http://100.64.0.40:43123/mcp"],
114 ["0.0.0.0", "http://127.0.0.1:43123/mcp"],
115 ["::", "http://127.0.0.1:43123/mcp"],
116 ["::1", "http://[::1]:43123/mcp"],
117 ["127.0.0.1", "http://127.0.0.1:43123/mcp"],
118 ] as const;
119​
120 for (const [hostname, expectedEndpoint] of cases) {
121 const registry = yield* makeRegistry(() => 1_000, makeFakeHttpServer(hostname));
122 const issued = yield* registry.issue({
123 threadId: ThreadId.make(`thread-${hostname}`),
124 providerInstanceId: ProviderInstanceId.make("codex"),
125 capabilities: new Set(["preview"]),
126 });
127 expect(issued.config.endpoint).toBe(expectedEndpoint);
128 }
129 }),
130);
131​
132it.effect("expires credentials once their session stops showing signs of life", () =>
133 Effect.gen(function* () {
134 let timestamp = 1_000;
135 const registry = yield* makeRegistry(() => timestamp);
136 const issued = yield* registry.issue({
137 threadId: ThreadId.make("thread-2"),
138 providerInstanceId: ProviderInstanceId.make("claude"),
139 capabilities: new Set(["preview"]),
140 });
141 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
142 timestamp += 101;
143 expect(yield* registry.resolve(token)).toBeUndefined();
144 // Once a request found it expired, clocks moving back don't bring it back; a provider turn does.
145 timestamp = 1_000;
146 expect(yield* registry.resolve(token)).toBeUndefined();
147 yield* registry.touch(ThreadId.make("thread-2"));
148 expect((yield* registry.resolve(token))?.thread.threadId).toBe("thread-2");
149 }),
150);
151​
152it.effect("a backward wall clock step neither extends nor revives credentials", () =>
153 Effect.gen(function* () {
154 const registry = yield* makeRegistry(undefined);
155 const issueToken = (threadId: string) =>
156 registry
157 .issue({
158 threadId: ThreadId.make(threadId),
159 providerInstanceId: ProviderInstanceId.make("codex"),
160 capabilities: new Set(["preview"]),
161 })
162 .pipe(Effect.map((issued) => issued.config.authorizationHeader.replace(/^Bearer\s+/, "")));
163​
164 yield* TestClock.setTime(10_000);
165 const extended = yield* issueToken("thread-extended");
166 // setTime backwards moves wall time only; adjust moves both clocks.
167 yield* TestClock.setTime(1_000);
168 yield* TestClock.adjust(101);
169 expect(yield* registry.resolve(extended)).toBeUndefined();
170​
171 const revived = yield* issueToken("thread-revived");
172 yield* TestClock.adjust(101);
173 expect(yield* registry.resolve(revived)).toBeUndefined();
174 const active = yield* issueToken("thread-active");
175 yield* TestClock.setTime(0);
176 expect(yield* registry.resolve(revived)).toBeUndefined();
177 expect((yield* registry.resolve(active))?.thread.threadId).toBe("thread-active");
178 }),
179);
180​
181it.effect("does not keep credentials of other threads alive", () =>
182 Effect.gen(function* () {
183 let timestamp = 1_000;
184 const registry = yield* makeRegistry(() => timestamp);
185 const issued = yield* registry.issue({
186 threadId: ThreadId.make("thread-4"),
187 providerInstanceId: ProviderInstanceId.make("codex"),
188 capabilities: new Set(["preview"]),
189 });
190 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
191​
192 timestamp += 99;
193 yield* registry.touch(ThreadId.make("thread-unrelated"));
194 timestamp += 2;
195​
196 expect(yield* registry.resolve(token)).toBeUndefined();
197 }),
198);
199​
200it.effect("only a trusted touch restores an expired token with its original scope", () =>
201 Effect.gen(function* () {
202 let timestamp = 1_000;
203 const registry = yield* makeRegistry(() => timestamp);
204 const threadId = ThreadId.make("thread-idle");
205 const issued = yield* registry.issue({
206 threadId,
207 providerInstanceId: ProviderInstanceId.make("claude"),
208 capabilities: new Set(["preview"]),
209 });
210 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
211 const scope = yield* registry.resolve(token);
212 expect(scope).toBeDefined();
213​
214 timestamp += 101;
215 expect(yield* registry.resolve(token)).toBeUndefined();
216 const otherThreadId = ThreadId.make("thread-active");
217 const other = yield* registry.issue({
218 threadId: otherThreadId,
219 providerInstanceId: ProviderInstanceId.make("codex"),
220 capabilities: new Set(["device"]),
221 });
222 yield* registry.touch(otherThreadId);
223 expect(
224 (yield* registry.resolve(other.config.authorizationHeader.replace(/^Bearer\s+/, "")))?.thread
225 .threadId,
226 ).toBe(otherThreadId);
227 expect(yield* registry.resolve("unknown-token")).toBeUndefined();
228 expect(yield* registry.resolve(token)).toBeUndefined();
229​
230 yield* registry.touch(threadId);
231 expect(yield* registry.resolve(token)).toEqual(scope);
232 timestamp += 101;
233 expect(yield* registry.resolve(token)).toBeUndefined();
234 }),
235);
236​
237it.effect.each(["provider-session", "thread", "all"] as const)(
238 "never restores an expired token after %s revocation",
239 (revocation) =>
240 Effect.gen(function* () {
241 let timestamp = 1_000;
242 const registry = yield* makeRegistry(() => timestamp);
243 const threadId = ThreadId.make("thread-revoked");
244 const issued = yield* registry.issue({
245 threadId,
246 providerInstanceId: ProviderInstanceId.make("codex"),
247 capabilities: new Set(["preview"]),
248 });
249 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
250​
251 timestamp += 101;
252 yield* {
253 "provider-session": registry.revokeProviderSession(issued.config.providerSessionId),
254 thread: registry.revokeThread(threadId),
255 all: registry.revokeAll,
256 }[revocation];
257 yield* registry.touch(threadId);
258 expect(yield* registry.resolve(token)).toBeUndefined();
259 }),
260);