1import {
2 ProviderSetupError,
3 type ProviderAuthMethod,
4 type ProviderInstanceId,
5} from "@t3tools/contracts";
6import type { AcpRegistrySettings } from "../settings.ts";
7import * as Crypto from "effect/Crypto";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Queue from "effect/Queue";
14import * as Scope from "effect/Scope";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16import type * as AcpSchema from "effect-acp/compat";
17
18import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
19import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow";
20import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./probe.ts";
21import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
22import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
23import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
24
25type Runtime = Pick<
26 AcpSessionRuntime.AcpSessionRuntime["Service"],
27 "initialize" | "authenticate" | "start" | "handleElicitation" | "logout"
28>;
29
30/** ACP credentials stay in the agent's own store, using the same launch environment as chat. */
31export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (options: {
32 readonly instanceId: ProviderInstanceId;
33 readonly settings: AcpRegistrySettings;
34 readonly cwd: string;
35 readonly environment: NodeJS.ProcessEnv;
36 readonly onChanged: (authenticated: boolean) => Effect.Effect<void>;
37 readonly makeRuntime?: (
38 spawn: AcpSessionRuntime.AcpSpawnInput,
39 ) => Effect.Effect<Runtime, ProviderSetupError, Scope.Scope>;
40}) {
41 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
42 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
43 const crypto = yield* Crypto.Crypto;
44 const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter);
45 const coordinator = yield* Effect.serviceOption(
46 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
47 );
48 const agentKey =
49 options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId;
50 const failure = (operation: string, detail: string, cause?: unknown) =>
51 new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause });
52 const resolve = catalog
53 .resolve(options.settings, options.cwd, options.environment)
54 .pipe(
55 Effect.mapError((cause) =>
56 failure("start", "Could not prepare the selected ACP agent.", cause),
57 ),
58 );
59 const makeRuntime =
60 options.makeRuntime ??
61 ((spawn) =>
62 Effect.gen(function* () {
63 const context = yield* Layer.build(
64 AcpSessionRuntime.layer({
65 spawn,
66 cwd: options.cwd,
67 authenticateOnAuthRequired: false,
68 clientCapabilities: {
69 auth: { terminal: Option.isSome(pty) },
70 elicitation: { url: {} },
71 fs: { readTextFile: false, writeTextFile: false },
72 terminal: false,
73 },
74 clientInfo: { name: "t3-code-provider-auth", version: "0.0.0" },
75 }).pipe(
76 Layer.provide(
77 Layer.mergeAll(
78 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
79 Layer.succeed(Crypto.Crypto, crypto),
80 ),
81 ),
82 ),
83 );
84 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
85 Effect.provide(context),
86 );
87 }).pipe(
88 Effect.mapError((cause) =>
89 failure("start", "Could not start the selected ACP agent.", cause),
90 ),
91 ));
92
93 let knownMethods:
94 | { readonly version: string | null; readonly methods: ReadonlyArray<ProviderAuthMethod> }
95 | undefined;
96 const discoverMethods = Effect.scoped(
97 Effect.gen(function* () {
98 const inspected = yield* catalog
99 .inspect(options.settings, options.environment)
100 .pipe(
101 Effect.mapError((cause) =>
102 failure("methods", "Could not inspect the selected ACP agent.", cause),
103 ),
104 );
105 if (inspected.status !== "ready")
106 return yield* failure("methods", "Prepare this ACP agent before signing in.");
107 // An unversioned command (a path override) can change in place, so only
108 // registry versions reuse earlier discovery.
109 if (inspected.version !== null && knownMethods?.version === inspected.version)
110 return knownMethods.methods;
111 const resolved = yield* resolve;
112 const runtime = yield* makeRuntime(resolved.spawn);
113 const initialized = yield* runtime
114 .initialize()
115 .pipe(
116 Effect.mapError((cause) =>
117 failure("methods", "Could not discover this agent's sign-in methods.", cause),
118 ),
119 );
120 const advertised = normalizeAcpRegistryAuthMethods(initialized.authMethods)
121 .filter(
122 (method) => method.id.length <= 128 && (method.type !== "terminal" || Option.isSome(pty)),
123 )
124 .slice(0, 32)
125 .map((method) => ({
126 id: method.id,
127 name: method.name,
128 description: method.description,
129 type: method.type === "env_var" ? ("credentials" as const) : method.type,
130 }));
131 knownMethods = { version: inspected.version, methods: advertised };
132 return advertised;
133 }),
134 ).pipe(
135 Effect.timeoutOrElse({
136 duration: "30 seconds",
137 orElse: () =>
138 Effect.fail(failure("methods", "The ACP agent did not advertise sign-in methods in time.")),
139 }),
140 );
141
142 const methods = Option.isSome(coordinator)
143 ? coordinator.value
144 .runBackgroundProbe(agentKey, discoverMethods)
145 .pipe(
146 Effect.flatMap((result) =>
147 Option.isSome(result)
148 ? Effect.succeed(result.value)
149 : Effect.fail(
150 failure(
151 "methods",
152 "Sign-in discovery was interrupted by an active provider session. Try again.",
153 ),
154 ),
155 ),
156 )
157 : discoverMethods;
158
159 const runTerminal = Effect.fnUntraced(function* (
160 resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent,
161 method: Extract<AcpSchema.AuthMethod, { readonly type: "terminal" }>,
162 context: ProviderAuthFlow.ProviderAuthFlowContext,
163 ) {
164 if (Option.isNone(pty))
165 return yield* failure(
166 "start",
167 "Interactive provider login is unavailable on this environment.",
168 );
169 const exited = yield* Deferred.make<number>();
170 const output = yield* Queue.sliding<string>(64);
171 const process = yield* pty.value
172 .spawn({
173 shell: resolved.spawn.command,
174 args: [...resolved.spawn.args, ...(method.args ?? [])],
175 cwd: options.cwd,
176 cols: 80,
177 rows: 24,
178 env: { ...options.environment, ...resolved.spawn.env, ...method.env },
179 })
180 .pipe(
181 Effect.mapError((cause) =>
182 failure("start", "Could not open the provider sign-in terminal.", cause),
183 ),
184 );
185 const detachData = process.onData((data) => {
186 Queue.offerUnsafe(output, data.slice(-16_384));
187 });
188 const detachExit = process.onExit(({ exitCode }) => {
189 Deferred.doneUnsafe(exited, Effect.succeed(exitCode));
190 });
191 yield* Effect.addFinalizer(() =>
192 Effect.sync(() => {
193 detachData();
194 detachExit();
195 try {
196 process.kill();
197 } catch {
198 /* The login process may already have exited. */
199 }
200 }),
201 );
202 let transcript = "";
203 let outputOffset = 0;
204 const update = () =>
205 context.setInteraction(
206 { type: "terminal", id: "terminal", output: transcript, outputOffset },
207 (response) =>
208 response.type === "terminal"
209 ? Effect.try({
210 try: () => {
211 if (response.size) process.resize(response.size.cols, response.size.rows);
212 if (response.data) process.write(response.data);
213 },
214 catch: () =>
215 failure("respond", "The provider sign-in terminal is no longer available."),
216 })
217 : Effect.void,
218 );
219 yield* update();
220 yield* Queue.take(output).pipe(
221 Effect.flatMap((data) => {
222 outputOffset += data.length;
223 transcript = (transcript + data).slice(-16_384);
224 return update();
225 }),
226 Effect.forever,
227 Effect.forkScoped,
228 );
229 if ((yield* Deferred.await(exited)) !== 0)
230 return yield* failure("start", "The provider login command did not finish successfully.");
231 });
232
233 const authenticate = (methodId: string, context: ProviderAuthFlow.ProviderAuthFlowContext) =>
234 Effect.gen(function* () {
235 if (!options.settings.enabled)
236 return yield* failure("start", "Enable this provider before signing in.");
237 const login = Effect.scoped(
238 Effect.gen(function* () {
239 const resolved = yield* resolve;
240 const runtimeScope = yield* Scope.make();
241 yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void));
242 const runtime = yield* makeRuntime(resolved.spawn).pipe(
243 Effect.provideService(Scope.Scope, runtimeScope),
244 );
245 yield* runtime.handleElicitation((request) =>
246 Effect.gen(function* () {
247 if (
248 request.mode !== "url" ||
249 !("url" in request) ||
250 !("elicitationId" in request) ||
251 typeof request.url !== "string" ||
252 typeof request.elicitationId !== "string"
253 )
254 return { action: "decline" } as const;
255 const url = normalizeAcpRegistryWebUrl(request.url);
256 const id = request.elicitationId.trim();
257 if (!url || !id || id.length > 128) return { action: "decline" } as const;
258 const consent = yield* Deferred.make<boolean>();
259 yield* context.setInteraction(
260 { type: "browser", id, url, requiresConsent: true },
261 (response) =>
262 response.type === "browser"
263 ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid)
264 : Effect.void,
265 );
266 return (yield* Deferred.await(consent))
267 ? ({ action: "accept" } as const)
268 : ({ action: "decline" } as const);
269 }),
270 );
271 const initialized = yield* runtime
272 .initialize()
273 .pipe(
274 Effect.mapError((cause) =>
275 failure("start", "Could not initialize the selected ACP agent.", cause),
276 ),
277 );
278 const method = initialized.authMethods?.find((method) => method.id === methodId);
279 if (!method)
280 return yield* failure("start", "The agent no longer advertises this sign-in method.");
281 if (method.type === "terminal") {
282 yield* Scope.close(runtimeScope, Exit.void);
283 yield* runTerminal(resolved, method, context).pipe(Effect.scoped);
284 // Terminal login is a separate invocation. Reconnect after it exits so
285 // the ACP process reads the credentials its login command persisted.
286 const verifiedRuntime = yield* makeRuntime(resolved.spawn);
287 yield* context.verifying;
288 yield* verifiedRuntime
289 .start()
290 .pipe(
291 Effect.mapError((cause) =>
292 failure(
293 "verify",
294 "The provider could not create a session after terminal sign-in.",
295 cause,
296 ),
297 ),
298 );
299 } else {
300 if (method.type === undefined || method.type === "agent") {
301 if (!runtime.authenticate)
302 return yield* failure(
303 "start",
304 "This runtime does not support explicit ACP sign-in.",
305 );
306 yield* runtime
307 .authenticate(methodId)
308 .pipe(
309 Effect.mapError((cause) =>
310 failure("start", "The ACP agent could not complete sign-in.", cause),
311 ),
312 );
313 }
314 // Legacy env_var methods use the secret-backed environment fields in
315 // provider settings. Never send their ID to authenticate.
316 yield* context.verifying;
317 yield* runtime
318 .start()
319 .pipe(
320 Effect.mapError((cause) =>
321 failure(
322 "verify",
323 "The provider could not create a session after sign-in.",
324 cause,
325 ),
326 ),
327 );
328 }
329 }),
330 );
331 yield* Option.isSome(coordinator)
332 ? coordinator.value.withForegroundStartup(agentKey, login)
333 : login;
334 yield* options.onChanged(true);
335 });
336 const logout = Effect.scoped(
337 Effect.gen(function* () {
338 const resolved = yield* resolve;
339 const runtime = yield* makeRuntime(resolved.spawn);
340 yield* runtime.logout.pipe(
341 Effect.mapError((cause) =>
342 failure(
343 "logout",
344 "This ACP agent could not sign out. It may not advertise logout support.",
345 cause,
346 ),
347 ),
348 );
349 }),
350 ).pipe(
351 Effect.timeoutOrElse({
352 duration: "60 seconds",
353 orElse: () =>
354 Effect.fail(failure("logout", "The ACP agent did not finish signing out in time.")),
355 }),
356 );
357 const signOut = (
358 Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout
359 ).pipe(Effect.andThen(options.onChanged(false)));
360 return yield* ProviderAuthFlow.make({
361 instanceId: options.instanceId,
362 // ACP doesn't advertise its credential scope. Conservatively treat all
363 // instances of the same agent on this environment as sharing credentials.
364 credentialBinding: { owner: "provider", key: `acp:${agentKey}` },
365 methods,
366 ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}),
367 authenticate,
368 logout: signOut,
369 });
370});