1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts";
4import { AcpRegistrySettings } from "../settings.ts";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import type * as AcpSchema from "effect-acp/compat";
11import { AcpRequestError } from "effect-acp/errors";
12
13import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
14import { makeAcpRegistryAuth } from "./auth.ts";
15import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
16import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
17
18const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
19const instanceId = ProviderInstanceId.make("acp-auth-test");
20const browserMethod = { id: "browser", name: "Browser", type: "agent" as const };
21const terminalMethod = {
22 id: "terminal",
23 name: "Terminal",
24 type: "terminal" as const,
25 args: ["login"],
26 env: { LOGIN: "yes", OVERRIDE: "method" },
27};
28const resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent = {
29 agent: {
30 id: "test-agent",
31 name: "Test",
32 version: "1.0.0",
33 description: "Test",
34 distribution: {},
35 },
36 distribution: "binary",
37 spawn: {
38 command: "/managed/agent",
39 args: ["--acp"],
40 env: { OVERRIDE: "spawn", AGENT_HOME: "/custom/home" },
41 },
42};
43const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = {
44 inspect: () =>
45 Effect.succeed({
46 status: "ready",
47 agentId: "test-agent",
48 version: "1.0.0",
49 distribution: "binary",
50 }),
51 resolve: () => Effect.succeed(resolved),
52 search: () => Effect.die("unused"),
53 prepare: () => Effect.die("unused"),
54 uninstallManagedBinary: () => Effect.die("unused"),
55};
56
57const makeHarness = (
58 method: AcpSchema.AuthMethod,
59 failVerification = false,
60 settings = decodeSettings({ agentId: "test-agent" }),
61 providerInstanceId = instanceId,
62) =>
63 Effect.gen(function* () {
64 const verify = yield* Deferred.make<void>();
65 const changed: boolean[] = [];
66 const closed: number[] = [];
67 const authenticated: string[] = [];
68 const started: number[] = [];
69 let runtimes = 0;
70 let version = "1.0.0";
71 let terminalSpawn: PtyAdapter.PtySpawnInput | undefined;
72 let data: ((data: string) => void) | undefined;
73 let exit: ((event: PtyAdapter.PtyExitEvent) => void) | undefined;
74 let killed = false;
75 let closedBeforeTerminal = false;
76 const written: string[] = [];
77 const sizes: number[][] = [];
78 const controller = yield* makeAcpRegistryAuth({
79 instanceId: providerInstanceId,
80 settings,
81 cwd: "/workspace",
82 environment: { PATH: "/tools", OVERRIDE: "base" },
83 onChanged: (value) =>
84 Effect.sync(() => {
85 changed.push(value);
86 }),
87 makeRuntime: () =>
88 Effect.gen(function* () {
89 const id = ++runtimes;
90 yield* Effect.addFinalizer(() =>
91 Effect.sync(() => {
92 closed.push(id);
93 }),
94 );
95 const initialized: AcpSchema.InitializeResponse = {
96 protocolVersion: 2,
97 agentCapabilities: {},
98 authMethods: [{ ...method, name: `${method.name} ${version}` }],
99 };
100 let elicitation:
101 | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0]
102 | undefined;
103 return {
104 initialize: () => Effect.succeed(initialized),
105 handleElicitation: (handler) =>
106 Effect.sync(() => {
107 elicitation = handler;
108 }),
109 authenticate: (methodId) =>
110 Effect.gen(function* () {
111 authenticated.push(methodId);
112 if (elicitation)
113 yield* elicitation(
114 {
115 mode: "url",
116 url: "https://example.com/login",
117 elicitationId: "consent",
118 requestId: "login",
119 message: "Sign in",
120 },
121 { requestId: "login", method: "elicitation/create" },
122 );
123 }),
124 start: () =>
125 Effect.gen(function* () {
126 started.push(id);
127 yield* Deferred.await(verify);
128 if (failVerification)
129 return yield* new AcpRequestError({
130 method: "session/new",
131 code: -32000,
132 errorMessage: "Authentication required",
133 });
134 return {
135 sessionId: "verified",
136 initializeResult: initialized,
137 sessionSetupResult: { sessionId: "verified" },
138 modelConfigId: undefined,
139 };
140 }),
141 logout: Effect.succeed({}),
142 };
143 }),
144 }).pipe(
145 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, {
146 ...catalog,
147 inspect: () =>
148 Effect.sync(() => ({
149 status: "ready" as const,
150 agentId: "test-agent",
151 version,
152 distribution: "binary" as const,
153 })),
154 }),
155 Effect.provideService(PtyAdapter.PtyAdapter, {
156 spawn: (input) =>
157 Effect.sync(() => {
158 terminalSpawn = input;
159 closedBeforeTerminal = closed.includes(runtimes);
160 return {
161 pid: 123,
162 write: (input) => {
163 written.push(input);
164 },
165 resize: (cols, rows) => {
166 sizes.push([cols, rows]);
167 },
168 kill: () => {
169 killed = true;
170 },
171 onData: (callback) => {
172 data = callback;
173 return () => {
174 data = undefined;
175 };
176 },
177 onExit: (callback) => {
178 exit = callback;
179 return () => {
180 exit = undefined;
181 };
182 },
183 };
184 }),
185 }),
186 );
187 const state = (predicate: (state: ProviderAuthState) => boolean) =>
188 controller
189 .subscribe("owner")
190 .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow));
191 const phase = (phase: ProviderAuthState["phase"]) => state((value) => value.phase === phase);
192 yield* state((value) => (value.methods?.length ?? 0) > 0);
193 return {
194 controller,
195 verify,
196 changed,
197 closed,
198 authenticated,
199 started,
200 phase,
201 state,
202 terminalSpawn: () => terminalSpawn,
203 closedBeforeTerminal: () => closedBeforeTerminal,
204 data: (value: string) => data?.(value),
205 exit: (exitCode: number) => exit?.({ exitCode, signal: null }),
206 killed: () => killed,
207 written,
208 sizes,
209 runtimes: () => runtimes,
210 setVersion: (value: string) => {
211 version = value;
212 },
213 };
214 }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog));
215
216it.effect("keeps local credentials separate from a matching registry agent ID", () =>
217 Effect.gen(function* () {
218 const registry = yield* makeHarness(browserMethod);
219 const local = yield* makeHarness(
220 browserMethod,
221 false,
222 decodeSettings({ source: "local", commandPath: "/different/agent" }),
223 ProviderInstanceId.make("test-agent"),
224 );
225 assert.deepEqual(registry.controller.credentialBinding, {
226 owner: "provider",
227 key: "acp:test-agent",
228 });
229 assert.deepEqual(local.controller.credentialBinding, {
230 owner: "provider",
231 key: "acp:local:test-agent",
232 });
233 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
234);
235
236it.effect(
237 "discovers methods without signing in, then waits for browser consent and session verification",
238 () =>
239 Effect.gen(function* () {
240 const h = yield* makeHarness(browserMethod);
241 assert.deepEqual(h.authenticated, []);
242 assert.deepEqual(h.started, []);
243 assert.deepEqual(h.closed, [1]);
244 const flow = yield* h.controller.start("owner", Effect.void, "browser");
245 const waiting = yield* h.phase("waiting");
246 assert.deepEqual(waiting.interaction, {
247 type: "browser",
248 id: "consent",
249 url: "https://example.com/login",
250 requiresConsent: true,
251 });
252 yield* h.controller.respond!("owner", {
253 instanceId,
254 flowId: flow.flowId!,
255 interactionId: "consent",
256 response: { type: "browser", action: "accept" },
257 });
258 yield* h.phase("verifying");
259 assert.deepEqual(h.changed, []);
260 yield* Deferred.succeed(h.verify, undefined);
261 yield* h.phase("succeeded");
262 assert.deepEqual(h.authenticated, ["browser"]);
263 assert.deepEqual(h.changed, [true]);
264 assert.deepEqual(h.closed, [1, 2]);
265 yield* h.controller.logout(Effect.void);
266 assert.deepEqual(h.changed, [true, false]);
267 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
268);
269
270it.effect(
271 "runs terminal auth directly with the resolved environment and reconnects to verify",
272 () =>
273 Effect.gen(function* () {
274 const h = yield* makeHarness(terminalMethod);
275 const flow = yield* h.controller.start("owner", Effect.void, "terminal");
276 yield* h.phase("waiting");
277 assert.isTrue(h.closedBeforeTerminal());
278 assert.deepEqual(h.terminalSpawn(), {
279 shell: "/managed/agent",
280 args: ["--acp", "login"],
281 cwd: "/workspace",
282 cols: 80,
283 rows: 24,
284 env: { PATH: "/tools", OVERRIDE: "method", AGENT_HOME: "/custom/home", LOGIN: "yes" },
285 });
286 h.data("Enter code: ");
287 const output = yield* h.state(
288 (value) =>
289 value.interaction?.type === "terminal" && value.interaction.output === "Enter code: ",
290 );
291 assert.strictEqual(
292 output.interaction?.type === "terminal" && output.interaction.outputOffset,
293 12,
294 );
295 yield* h.controller.respond!("owner", {
296 instanceId,
297 flowId: flow.flowId!,
298 interactionId: "terminal",
299 response: { type: "terminal", data: "123\r", size: { cols: 100, rows: 30 } },
300 });
301 assert.deepEqual(h.written, ["123\r"]);
302 assert.deepEqual(h.sizes, [[100, 30]]);
303 h.exit(0);
304 yield* h.phase("verifying");
305 assert.isTrue(h.killed());
306 assert.deepEqual(h.authenticated, []);
307 yield* Deferred.succeed(h.verify, undefined);
308 yield* h.phase("succeeded");
309 assert.deepEqual(h.started, [3]);
310 assert.deepEqual(h.changed, [true]);
311 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
312);
313
314it.effect("cancels the login PTY and rejects further terminal input", () =>
315 Effect.gen(function* () {
316 const h = yield* makeHarness(terminalMethod);
317 const flow = yield* h.controller.start("owner");
318 yield* h.phase("waiting");
319 yield* h.controller.cancel("owner", flow.flowId!);
320 assert.isTrue(h.killed());
321 assert.deepEqual(h.started, []);
322 assert.deepEqual(h.changed, []);
323 assert.strictEqual(
324 (yield* h.controller.respond!("owner", {
325 instanceId,
326 flowId: flow.flowId!,
327 interactionId: "terminal",
328 response: { type: "terminal", data: "late\r" },
329 }).pipe(Effect.result))._tag,
330 "Failure",
331 );
332 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
333);
334
335it.effect("does not report authentication when post-login verification fails", () =>
336 Effect.gen(function* () {
337 const h = yield* makeHarness(terminalMethod, true);
338 yield* h.controller.start("owner");
339 yield* h.phase("waiting");
340 h.exit(0);
341 yield* h.phase("verifying");
342 yield* Deferred.succeed(h.verify, undefined);
343 yield* h.phase("failed");
344 assert.deepEqual(h.changed, []);
345 assert.isTrue(h.killed());
346 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
347);
348
349it.effect("refreshes advertised methods after a registry version change without logging in", () =>
350 Effect.gen(function* () {
351 const h = yield* makeHarness(browserMethod);
352 const count = h.runtimes();
353 yield* h.controller.refreshMethods!;
354 assert.strictEqual(h.runtimes(), count);
355 h.setVersion("2.0.0");
356 yield* h.controller.refreshMethods!;
357 const state = yield* h.state((value) => value.methods?.[0]?.name === "Browser 2.0.0");
358 assert.strictEqual(state.phase, "idle");
359 assert.strictEqual(h.runtimes(), count + 1);
360 assert.deepEqual(h.authenticated, []);
361 assert.deepEqual(h.started, []);
362 assert.deepEqual(h.closed, [1, 2]);
363 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
364);