authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 23:08:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 23:08:35-07:00
log43f8a8de17a7ac1baa7a3cf36d681856de2d8add
tree066b5c970c708992025a52b80146cd2e50021e7c
parentcb46c62504aca99295a4a8879644625a20685b7f
signature Signed by PGP key B5690EEEBB952194

refactor(provider-acp-registry): move the ACP Registry into its own package (#17405)


77 files changed, 9969 insertions(+), 9798 deletions(-)

apps/mobile/package.json+1
......@@ -69,6 +69,7 @@
6969 "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text",
7070 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
7171 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
72 "@t3tools/provider-acp-registry": "workspace:*",
7273 "@t3tools/provider-core": "workspace:*",
7374 "@t3tools/provider-cursor": "workspace:*",
7475 "@t3tools/provider-grok": "workspace:*",
apps/mobile/src/lib/providerClients.ts+2
......@@ -1,4 +1,5 @@
11import { isProviderDriverKind } from "@t3tools/contracts";
2import { acpRegistryClient } from "@t3tools/provider-acp-registry/client";
23import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
34import { cursorClient } from "@t3tools/provider-cursor/client";
45import { grokClient } from "@t3tools/provider-grok/client";
......@@ -13,6 +14,7 @@ const providerClients = makeProviderClientRegistry([
1314 museClient,
1415 openCodeClient,
1516 piClient,
17 acpRegistryClient,
1618]);
1719
1820/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */
apps/server/package.json+1-1
......@@ -36,9 +36,9 @@
3636 "@ff-labs/fff-node": "0.9.4",
3737 "@muse-code/sdk": "1.4.2",
3838 "@napi-rs/keyring": "^1.3.0",
39 "@noble/hashes": "catalog:",
4039 "@opencode-ai/sdk": "^1.3.15",
4140 "@t3tools/provider-acp": "workspace:*",
41 "@t3tools/provider-acp-registry": "workspace:*",
4242 "@t3tools/provider-core": "workspace:*",
4343 "@t3tools/provider-cursor": "workspace:*",
4444 "@t3tools/provider-grok": "workspace:*",
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+2-2
......@@ -2,7 +2,7 @@ import {
22 normalizeDevinSessionUpdate,
33 normalizeDevinToolCall,
44 extractDevinSubagentUpdate,
5} from "./DevinAcp.ts";
5} from "@t3tools/provider-acp-registry/testing";
66// @effect-diagnostics nodeBuiltinImport:off
77import * as NodeChildProcess from "node:child_process";
88import * as NodeFS from "node:fs";
......@@ -95,7 +95,7 @@ import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing";
9595import {
9696 acpRegistryPromptFailure,
9797 registerMistralVibeAcpExtensions,
98} from "./AcpRegistryAdapterV2.ts";
98} from "@t3tools/provider-acp-registry/testing";
9999
100100const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
101101
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+4-4
......@@ -19,8 +19,8 @@ import * as ServerSettings from "../../serverSettings.ts";
1919import type {
2020 AcpRegistryAvailableCommands,
2121 AcpRegistryLiveConfiguration,
22} from "../../provider/acp/AcpRegistryProbe.ts";
23import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
22} from "@t3tools/provider-acp-registry/testing";
23import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
2424import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
2525import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
2626import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
......@@ -31,12 +31,12 @@ import {
3131} from "./AcpAdapterV2.testkit.ts";
3232import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
3333import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
34import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server";
3435import {
3536 ACP_REGISTRY_PROVIDER,
36 AcpRegistryAdapterV2Driver,
3737 makeAcpRegistryAdapterV2,
3838 acpRegistryPromptFailure,
39} from "./AcpRegistryAdapterV2.ts";
39} from "@t3tools/provider-acp-registry/testing";
4040
4141const registryUrl = "https://registry.test/registry.json";
4242const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect(
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts+2-2
......@@ -1,5 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AcpRegistrySettings } from "@t3tools/contracts";
2import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings";
33import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
44import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
......@@ -24,7 +24,7 @@ import {
2424 ACP_REGISTRY_DEFAULT_INSTANCE_ID,
2525 ACP_REGISTRY_PROVIDER,
2626 makeAcpRegistryAdapterV2,
27} from "./AcpRegistryAdapterV2.ts";
27} from "@t3tools/provider-acp-registry/testing";
2828
2929const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({
3030 agentId: "replay-agent",
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts deleted-317
......@@ -1,317 +0,0 @@
1import {
2 normalizeDevinSessionUpdate,
3 normalizeDevinToolCall,
4 extractDevinSubagentUpdate,
5} from "./DevinAcp.ts";
6import {
7 AcpRegistrySettings,
8 defaultInstanceIdForDriver,
9 ProviderDriverKind,
10} from "@t3tools/contracts";
11import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
12import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import type * as Scope from "effect/Scope";
21import { ChildProcessSpawner } from "effect/process";
22import * as EffectAcpErrors from "effect-acp/errors";
23
24import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
25import {
26 normalizeAcpRegistryCommands,
27 normalizeAcpRegistryLiveConfiguration,
28 normalizeAcpRegistryWebUrl,
29} from "../../provider/acp/AcpRegistryProbe.ts";
30import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts";
31import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts";
32import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
33import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
34import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
35import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
36import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
37import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
38import {
39 ProviderAdapterDriverCreateError,
40 type ProviderAdapterDriver,
41 type ProviderAdapterDriverCreateInput,
42} from "@t3tools/provider-core/server/adapterDriver";
43import {
44 AcpProviderCapabilitiesV2,
45 makeAcpAdapterV2,
46 type AcpAdapterV2ExtensionContext,
47 type AcpAdapterV2Flavor,
48 type AcpAdapterV2RuntimeInput,
49} from "@t3tools/provider-acp/server/adapter";
50
51export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry");
52export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER);
53
54const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({});
55const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError);
56
57export interface AcpRegistryAdapterV2Options {
58 readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
59 readonly settings: AcpRegistrySettings;
60 readonly environment: NodeJS.ProcessEnv;
61 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
62 readonly selfInvocation: SelfInvocation;
63 readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">;
64 readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"];
65 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
66 readonly makeRuntime?: (
67 input: AcpAdapterV2RuntimeInput,
68 ) => Effect.Effect<
69 AcpSessionRuntime.AcpSessionRuntime["Service"],
70 EffectAcpErrors.AcpError,
71 Crypto.Crypto | Scope.Scope
72 >;
73 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
74}
75
76// ─── Per-agent exceptions ────────────────────────────────────────────────────
77// This adapter serves every ACP registry agent through the plain ACP spec.
78// Agent-specific behavior does not belong here: an agent that needs it gets a
79// dedicated driver (as Grok and Antigravity have). The few exceptions below
80// predate that rule and are small presentation hooks, not permission or tool
81// behavior.
82//
83// Agents changing this file: do NOT add another `agentId === "..."` branch,
84// agent table, or agent-specific hook without explicit approval from the
85// maintainer in the conversation. Propose a dedicated driver instead.
86
87// Mistral Vibe: its application error code for rate limits (not an ACP code).
88const MISTRAL_VIBE_RATE_LIMITED = -31001;
89
90const MistralVibeSessionRetrying = Schema.Struct({
91 sessionId: Schema.String,
92 category: Schema.Literals(["rate_limited", "server_error", "timed_out", "connection", "unknown"]),
93 detail: Schema.String,
94});
95
96/** Mistral Vibe (v2.25.5) reports SDK backoff through this ACP extension. */
97export function registerMistralVibeAcpExtensions(context: AcpAdapterV2ExtensionContext) {
98 return context.runtime.handleExtNotification(
99 "_session/retrying",
100 MistralVibeSessionRetrying,
101 (notice) =>
102 context.reportProviderRetry({
103 sessionId: notice.sessionId,
104 failure: makeProviderFailure({
105 message: notice.detail,
106 class:
107 notice.category === "rate_limited"
108 ? "usage_limit"
109 : notice.category === "unknown"
110 ? "provider_error"
111 : "transport_error",
112 retryable: true,
113 }),
114 }),
115 );
116}
117// ─── End per-agent exceptions (Devin's gates are marked in makeAcpRegistryAdapterV2) ───
118
119export function acpRegistryPromptFailure(agentId: string, cause: unknown) {
120 return makeProviderFailure({
121 cause,
122 ...(isAcpRequestError(cause)
123 ? {
124 message: cause.errorMessage,
125 code: String(cause.code),
126 class:
127 // Per-agent exception: see the note above registerMistralVibeAcpExtensions.
128 agentId === "mistral-vibe" && cause.code === MISTRAL_VIBE_RATE_LIMITED
129 ? ("usage_limit" as const)
130 : ("provider_error" as const),
131 }
132 : { class: "provider_error" as const }),
133 });
134}
135
136function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
137 return (
138 input: AcpAdapterV2RuntimeInput,
139 ): Effect.Effect<
140 AcpSessionRuntime.AcpSessionRuntime["Service"],
141 EffectAcpErrors.AcpError,
142 Crypto.Crypto | Scope.Scope
143 > =>
144 Effect.gen(function* () {
145 const { processEnvironment, ...runtimeInput } = input;
146 const resolved = yield* options.resolver
147 .resolve(options.settings, input.cwd, options.environment)
148 .pipe(
149 Effect.mapError(
150 (cause) =>
151 new EffectAcpErrors.AcpSpawnError({
152 command: options.settings.agentId || ACP_REGISTRY_PROVIDER,
153 cause,
154 }),
155 ),
156 );
157 const context = yield* Layer.build(
158 AcpSessionRuntime.layer({
159 ...runtimeInput,
160 spawn:
161 processEnvironment === undefined
162 ? resolved.spawn
163 : {
164 ...resolved.spawn,
165 env: { ...resolved.spawn.env, ...processEnvironment },
166 },
167 ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),
168 }).pipe(
169 Layer.provide(
170 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner),
171 ),
172 ),
173 );
174 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
175 Effect.provide(context),
176 );
177 });
178}
179
180export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(function* (
181 options: AcpRegistryAdapterV2Options,
182) {
183 const runtimeCoordinator = options.runtimeCoordinator;
184 const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId;
185 const startupKey =
186 options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId;
187 const isDevin = registryAgentId === "devin";
188 const flavor: AcpAdapterV2Flavor = {
189 driver: ACP_REGISTRY_PROVIDER,
190 capabilities: AcpProviderCapabilitiesV2,
191 promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause),
192 // Per-agent exceptions (Mistral Vibe, Devin): see the note above
193 // registerMistralVibeAcpExtensions before adding any more.
194 ...(registryAgentId === "mistral-vibe"
195 ? { registerExtensions: registerMistralVibeAcpExtensions }
196 : {}),
197 ...(isDevin
198 ? {
199 clientCapabilitiesMeta: {
200 "cognition.ai/subagentSupport": true,
201 "cognition.ai/messageGrouping": true,
202 },
203 normalizeSessionUpdate: normalizeDevinSessionUpdate,
204 normalizeToolCall: normalizeDevinToolCall,
205 extractSubagentUpdate: extractDevinSubagentUpdate,
206 }
207 : {}),
208 makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options),
209 ...(runtimeCoordinator === undefined
210 ? {}
211 : {
212 onAvailableCommandsUpdate: (commands) =>
213 runtimeCoordinator.publishAvailableCommands(
214 options.instanceId,
215 normalizeAcpRegistryCommands(commands),
216 ),
217 onSessionConfigurationUpdate: (configOptions, modeState) =>
218 runtimeCoordinator.publishLiveConfiguration(
219 options.instanceId,
220 normalizeAcpRegistryLiveConfiguration(configOptions, modeState),
221 ),
222 onUrlElicitation: ({ elicitationId, url, message }) => {
223 const normalizedUrl = normalizeAcpRegistryWebUrl(url);
224 if (normalizedUrl === undefined || elicitationId.trim().length === 0) {
225 return Effect.succeed(false);
226 }
227 return runtimeCoordinator.requestUrlAuthentication(options.instanceId, {
228 elicitationId: elicitationId.trim().slice(0, 256),
229 url: normalizedUrl,
230 message: message.trim().slice(0, 1_024),
231 });
232 },
233 withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
234 runtimeCoordinator.withForegroundStartup(startupKey, effect),
235 }),
236 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
237 };
238 return yield* makeAcpAdapterV2({
239 instanceId: options.instanceId,
240 flavor,
241 selfInvocation: options.selfInvocation,
242 // Per-agent exception (see the note above registerMistralVibeAcpExtensions):
243 // Devin runs commands through client terminals and has no ask mode over
244 // ACP to fall back on. Every other registry agent runs its own.
245 ...(isDevin
246 ? {
247 clientTerminals: {
248 childProcessSpawner: options.childProcessSpawner,
249 environment: options.environment,
250 shellCommands: true,
251 },
252 }
253 : {}),
254 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
255 });
256});
257
258export type AcpRegistryAdapterV2DriverEnv =
259 | ChildProcessSpawner.ChildProcessSpawner
260 | Crypto.Crypto
261 | FileSystem.FileSystem
262 | AcpRegistrySupport.AcpRegistryCatalog
263 | IdAllocator.IdAllocatorV2
264 | Path.Path
265 | ProviderEventLoggers.ProviderEventLoggers
266 | ProviderHost.ProviderHost;
267
268export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
269 AcpRegistrySettings,
270 AcpRegistryAdapterV2DriverEnv
271> = {
272 driverKind: ACP_REGISTRY_PROVIDER,
273 configSchema: AcpRegistrySettings,
274 defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS,
275 create: Effect.fn("AcpRegistryAdapterV2Driver.create")(
276 function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) {
277 const hostEnvironment = yield* HostProcessEnvironment;
278 const selfInvocation = yield* resolveSelfInvocation();
279 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
280 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
281 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
282 const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog;
283 const runtimeCoordinator = yield* Effect.serviceOption(
284 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
285 );
286 return yield* makeAcpRegistryAdapterV2({
287 instanceId: input.instanceId,
288 settings: { ...input.config, enabled: input.enabled },
289 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
290 childProcessSpawner,
291 resolver,
292 ...(Option.isSome(runtimeCoordinator)
293 ? { runtimeCoordinator: runtimeCoordinator.value }
294 : {}),
295 selfInvocation,
296 nativeLogging: (threadId) =>
297 makeNativeLogger({
298 nativeEventLogger: providerEventLoggers.native,
299 provider: ACP_REGISTRY_PROVIDER,
300 threadId,
301 }),
302 });
303 },
304 (effect, input) =>
305 effect.pipe(
306 Effect.mapError(
307 (cause) =>
308 new ProviderAdapterDriverCreateError({
309 driver: ACP_REGISTRY_PROVIDER,
310 instanceId: input.instanceId,
311 detail: "Failed to create ACP Registry adapter.",
312 cause,
313 }),
314 ),
315 ),
316 ),
317};
apps/server/src/orchestration-v2/Adapters/DevinAcp.ts deleted-57
......@@ -1,57 +0,0 @@
1import type * as AcpSchema from "effect-acp/compat";
2import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
3import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter";
4
5function record(value: unknown): Record<string, unknown> | undefined {
6 return typeof value === "object" && value !== null && !Array.isArray(value)
7 ? (value as Record<string, unknown>)
8 : undefined;
9}
10
11/** Devin's typed extension metadata, captured from its native ACP stream. */
12export function normalizeDevinSessionUpdate(
13 notification: AcpSchema.SessionNotification,
14): AcpSchema.SessionNotification {
15 const update = notification.update;
16 const meta = record("_meta" in update ? update._meta : undefined);
17 const parentAgentId = record(meta?.["cognition.ai/subagent_context"])?.parentAgentId;
18 const routed =
19 typeof parentAgentId === "string" && parentAgentId !== "root"
20 ? { ...notification, sessionId: parentAgentId }
21 : notification;
22 const messageId = meta?.["cognition.ai/streamingMessageId"];
23 if (
24 (update.sessionUpdate === "agent_message_chunk" ||
25 update.sessionUpdate === "agent_thought_chunk") &&
26 typeof messageId === "string"
27 ) {
28 return { ...routed, update: { ...update, messageId } };
29 }
30 return routed;
31}
32
33export function normalizeDevinToolCall(tool: AcpToolCallState): AcpToolCallState {
34 const name = record(tool.data.meta)?.["cognition.ai/inferenceToolName"];
35 return typeof name === "string" && (!tool.title || tool.title === "Tool")
36 ? { ...tool, title: name }
37 : tool;
38}
39
40export function extractDevinSubagentUpdate(
41 tool: AcpToolCallState,
42): AcpAdapterV2SubagentUpdate | undefined {
43 const meta = record(tool.data.meta);
44 const completed = record(meta?.["cognition.ai/subagent_completed"]);
45 const started = record(meta?.["cognition.ai/subagent_started"]);
46 const data = completed ?? started;
47 if (typeof data?.agentId !== "string") return undefined;
48 return {
49 nativeTaskId: data.agentId,
50 childSessionId: data.agentId,
51 prompt: typeof started?.task === "string" ? started.task : "",
52 title: typeof started?.title === "string" ? started.title : null,
53 model: typeof started?.model === "string" ? started.model : null,
54 status: completed ? (completed.success === false ? "failed" : "completed") : "running",
55 result: typeof completed?.summary === "string" ? completed.summary : null,
56 };
57}
apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts+1-1
......@@ -18,7 +18,7 @@ import * as Schema from "effect/Schema";
1818import type * as Scope from "effect/Scope";
1919import * as Stream from "effect/Stream";
2020
21import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts";
21import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow";
2222import type { ProviderAuthController } from "../provider/ProviderAuthService.ts";
2323import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
2424import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1
......@@ -3,7 +3,7 @@ import type { ProviderDriverKind } from "@t3tools/contracts";
33import {
44 AcpRegistryAdapterV2Driver,
55 type AcpRegistryAdapterV2DriverEnv,
6} from "./Adapters/AcpRegistryAdapterV2.ts";
6} from "@t3tools/provider-acp-registry/server";
77import {
88 ClaudeAdapterV2Driver,
99 type ClaudeAdapterV2DriverEnv,
apps/server/src/provider/AcpRegistryCatalog.ts deleted-22
......@@ -1,22 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Path from "effect/Path";
4
5import * as ServerConfig from "../config.ts";
6import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts";
7import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts";
8
9/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */
10export const layer = Layer.merge(
11 Layer.unwrap(
12 Effect.gen(function* () {
13 const config = yield* ServerConfig.ServerConfig;
14 const path = yield* Path.Path;
15 return AcpRegistrySupport.AcpRegistryCatalog.layer({
16 cacheDir: config.providerStatusCacheDir,
17 toolsDir: path.join(config.baseDir, "tools"),
18 });
19 }),
20 ),
21 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer,
22);
apps/server/src/provider/AcpRegistryProbe.test.ts created+606
......@@ -0,0 +1,606 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ProviderInstanceId } from "@t3tools/contracts";
7import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings";
8import { describe, expect, it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14import * as EffectAcpErrors from "effect-acp/errors";
15
16import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
17import {
18 acpRegistryProbeFailure,
19 acpRegistryProbeResult,
20 listAcpRegistrySessions,
21 logoutAcpRegistry,
22 normalizeAcpRegistryAuthMethods,
23 normalizeAcpRegistryCommands,
24 probeAcpRegistryConfiguration,
25} from "@t3tools/provider-acp-registry/testing";
26import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
27
28const instanceId = ProviderInstanceId.make("acpRegistry_codex");
29const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
30const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
31const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
32
33describe("ACP Registry probe", () => {
34 it("returns advertised auth methods and de-duplicated models", () => {
35 const result = acpRegistryProbeResult(instanceId, {
36 sessionId: "probe-session",
37 initializeResult: {
38 protocolVersion: 1,
39 agentCapabilities: {
40 loadSession: true,
41 auth: { logout: {} },
42 sessionCapabilities: { list: {}, resume: {} },
43 },
44 authMethods: [
45 { id: "chatgpt", name: "ChatGPT" },
46 {
47 id: "api-key",
48 name: "API key",
49 type: "env_var",
50 vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }],
51 },
52 { id: "login", name: "Terminal login", type: "terminal" },
53 ],
54 },
55 sessionSetupResult: {
56 sessionId: "probe-session",
57 configOptions: [
58 {
59 id: "model",
60 name: "Model",
61 category: "model",
62 type: "select",
63 currentValue: "gpt-5.4",
64 options: [
65 { value: "gpt-5.4", name: "GPT-5.4" },
66 { value: "gpt-5.4", name: "Duplicate" },
67 ],
68 },
69 ],
70 },
71 modelConfigId: "model",
72 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
73
74 expect(result).toEqual({
75 instanceId,
76 ready: true,
77 icon: null,
78 authMethods: [
79 { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" },
80 {
81 id: "api-key",
82 name: "API key",
83 description: null,
84 type: "env_var",
85 envVarNames: ["OPENAI_API_KEY"],
86 },
87 { id: "login", name: "Terminal login", description: null, type: "terminal" },
88 ],
89 models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }],
90 currentModelId: "gpt-5.4",
91 configOptions: [],
92 sessionManagement: {
93 canList: true,
94 canLoad: true,
95 canResume: true,
96 canLogout: true,
97 canDelete: false,
98 canConfigureProviders: false,
99 },
100 });
101 });
102
103 it("composes runnable terminal auth commands from the resolved spawn recipe", () => {
104 const methods = normalizeAcpRegistryAuthMethods(
105 [
106 {
107 id: "login",
108 name: "Terminal login",
109 type: "terminal",
110 args: ["auth", "log in"],
111 env: { FORCE_TTY: "1" },
112 },
113 ],
114 { command: "/opt/agents/devin", args: ["--acp"] },
115 );
116
117 expect(methods).toEqual([
118 {
119 id: "login",
120 name: "Terminal login",
121 description: null,
122 type: "terminal",
123 command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'",
124 },
125 ]);
126 });
127
128 it("omits unsafe truncated auth commands instead of publishing broken shell", () => {
129 const methods = normalizeAcpRegistryAuthMethods(
130 [
131 {
132 id: "login",
133 name: "Terminal login",
134 type: "terminal",
135 args: ["x".repeat(2_048)],
136 },
137 ],
138 { command: "/opt/agents/devin", args: ["--acp"] },
139 );
140
141 expect(methods[0]).not.toHaveProperty("command");
142 });
143
144 it("omits overlong opaque auth method and environment variable ids", () => {
145 const methods = normalizeAcpRegistryAuthMethods([
146 { id: "x".repeat(129), name: "Invalid" },
147 {
148 id: "api-key",
149 name: "API key",
150 type: "env_var",
151 vars: [
152 { name: "y".repeat(129), label: "Invalid" },
153 { name: "OPENAI_API_KEY", label: "OpenAI API key" },
154 ],
155 },
156 ]);
157
158 expect(methods).toEqual([
159 {
160 id: "api-key",
161 name: "API key",
162 description: null,
163 type: "env_var",
164 envVarNames: ["OPENAI_API_KEY"],
165 },
166 ]);
167 });
168
169 it("falls back to model-category configuration options", () => {
170 const result = acpRegistryProbeResult(instanceId, {
171 sessionId: "probe-session",
172 initializeResult: { protocolVersion: 1 },
173 sessionSetupResult: {
174 sessionId: "probe-session",
175 configOptions: [
176 {
177 id: "model",
178 name: "Model",
179 category: "model",
180 type: "select",
181 currentValue: "sonnet",
182 options: [
183 { value: "sonnet", name: "Sonnet", description: "Balanced" },
184 { value: "haiku", name: "Haiku" },
185 ],
186 },
187 ],
188 },
189 modelConfigId: "model",
190 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
191
192 expect(result.models).toEqual([
193 { id: "sonnet", name: "Sonnet", description: "Balanced" },
194 { id: "haiku", name: "Haiku", description: null },
195 ]);
196 expect(result.currentModelId).toBe("sonnet");
197 });
198
199 it("omits overlong opaque model ids instead of publishing mutated ids", () => {
200 const result = acpRegistryProbeResult(instanceId, {
201 sessionId: "probe-session",
202 initializeResult: { protocolVersion: 1 },
203 sessionSetupResult: {
204 sessionId: "probe-session",
205 configOptions: [
206 {
207 id: "model",
208 name: "Model",
209 category: "model",
210 type: "select",
211 currentValue: "x".repeat(129),
212 options: [
213 { value: "x".repeat(129), name: "Too long" },
214 { value: "valid", name: "Valid" },
215 ],
216 },
217 ],
218 },
219 modelConfigId: "model",
220 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
221
222 expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]);
223 expect(result.currentModelId).toBeNull();
224 });
225
226 it("preserves grouped model values and advertises reasoning choices", () => {
227 const codexModel = '["codex","gpt-6.1-sol"]';
228 const goModel = '["opencode-go","deepseek-v4.1-flash"]';
229 const result = acpRegistryProbeResult(instanceId, {
230 sessionId: "probe-session",
231 initializeResult: { protocolVersion: 1 },
232 sessionSetupResult: {
233 sessionId: "probe-session",
234 configOptions: [
235 {
236 id: "model",
237 name: "Model",
238 category: "model",
239 type: "select",
240 currentValue: goModel,
241 options: [
242 {
243 groupId: "codex",
244 name: "Codex",
245 options: [{ value: codexModel, name: "GPT-6.1 Sol" }],
246 },
247 {
248 groupId: "opencode-go",
249 name: "OpenCode Go",
250 options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }],
251 },
252 ],
253 },
254 {
255 id: "reasoning",
256 name: "Reasoning",
257 category: "thought_level",
258 type: "select",
259 currentValue: "high",
260 options: [
261 {
262 groupId: "effort",
263 name: "Effort",
264 options: [
265 { value: "medium", name: "Medium" },
266 { value: "high", name: "High" },
267 ],
268 },
269 ],
270 },
271 ],
272 },
273 modelConfigId: "model",
274 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
275
276 expect(result.models).toEqual([
277 { id: codexModel, name: "GPT-6.1 Sol", description: null },
278 { id: goModel, name: "DeepSeek V4.1 Flash", description: null },
279 ]);
280 expect(result.currentModelId).toBe(goModel);
281 expect(result.configOptions).toEqual([
282 {
283 id: "reasoning",
284 label: "Reasoning",
285 type: "select",
286 currentValue: "high",
287 options: [
288 { id: "medium", label: "Medium" },
289 { id: "high", label: "High" },
290 ],
291 },
292 ]);
293 });
294
295 it("omits a current model that falls outside the bounded model catalog", () => {
296 const result = acpRegistryProbeResult(instanceId, {
297 sessionId: "probe-session",
298 initializeResult: { protocolVersion: 1 },
299 sessionSetupResult: {
300 sessionId: "probe-session",
301 configOptions: [
302 {
303 id: "model",
304 name: "Model",
305 category: "model",
306 type: "select",
307 currentValue: "model-256",
308 options: Array.from({ length: 257 }, (_, index) => ({
309 value: `model-${index}`,
310 name: `Model ${index}`,
311 })),
312 },
313 ],
314 },
315 modelConfigId: "model",
316 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
317
318 expect(result.models).toHaveLength(256);
319 expect(result.currentModelId).toBeNull();
320 });
321
322 it("bounds and de-duplicates advertised commands", () => {
323 const commands = normalizeAcpRegistryCommands([
324 {
325 name: "create_plan",
326 description: " Create a plan ",
327 input: { type: "text", hint: " topic " },
328 },
329 { name: "CREATE_PLAN", description: "duplicate" },
330 ...Array.from({ length: 140 }, (_, index) => ({
331 name: `command_${index}`,
332 description: "",
333 })),
334 ]);
335
336 expect(commands.slashCommands).toHaveLength(128);
337 expect(commands.slashCommands[0]).toEqual({
338 name: "create_plan",
339 description: "Create a plan",
340 input: { hint: "topic" },
341 });
342 expect(
343 commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"),
344 ).toHaveLength(1);
345 expect(commands.skills).toEqual([]);
346 });
347
348 it("routes dollar-prefixed ACP commands to the provider skill menu", () => {
349 expect(
350 normalizeAcpRegistryCommands([
351 { name: "$workspace-skill", description: "Run the workspace skill" },
352 { name: "$WORKSPACE-SKILL", description: "duplicate" },
353 { name: "$", description: "empty" },
354 { name: "review", description: "Review changes" },
355 ]),
356 ).toEqual({
357 slashCommands: [{ name: "review", description: "Review changes" }],
358 skills: [
359 {
360 name: "workspace-skill",
361 description: "Run the workspace skill",
362 path: "acp://skill/workspace-skill",
363 scope: "agent",
364 enabled: true,
365 },
366 ],
367 });
368 });
369
370 it.effect("captures commands advertised just after session creation", () =>
371 Effect.gen(function* () {
372 const result = yield* probeAcpRegistryConfiguration({
373 instanceId,
374 settings: decodeSettings({ agentId: "mock-agent" }),
375 cwd: process.cwd(),
376 environment: process.env,
377 });
378
379 expect(result.slashCommands).toEqual([
380 {
381 name: "review",
382 description: "Review the current changes",
383 input: { hint: "focus" },
384 },
385 ]);
386 expect(result.skills).toEqual([
387 {
388 name: "workspace-skill",
389 description: "Run the workspace skill",
390 path: "acp://skill/workspace-skill",
391 scope: "agent",
392 enabled: true,
393 },
394 ]);
395 }).pipe(
396 Effect.provideService(
397 AcpRegistrySupport.AcpRegistryCatalog,
398 AcpRegistrySupport.AcpRegistryCatalog.of({
399 search: () => Effect.die("unused search"),
400 prepare: () => Effect.die("unused prepare"),
401 inspect: () => Effect.die("unused inspect"),
402 uninstallManagedBinary: () => Effect.die("unused uninstall"),
403 resolve: () =>
404 Effect.succeed({
405 agent: {
406 id: "mock-agent",
407 name: "Mock Agent",
408 version: "1.0.0",
409 description: "ACP probe test agent",
410 distribution: { npx: { package: "mock-agent@1.0.0" } },
411 },
412 distribution: "npx",
413 spawn: {
414 command: "node",
415 args: [mockAgentPath],
416 env: {
417 ...process.env,
418 T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25",
419 },
420 },
421 }),
422 }),
423 ),
424 Effect.provide(NodeServices.layer),
425 Effect.scoped,
426 ),
427 );
428
429 it.effect("includes package resolution in the probe timeout", () =>
430 Effect.gen(function* () {
431 const resolveStarted = yield* Deferred.make<void>();
432 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
433 search: () => Effect.die("unused search"),
434 prepare: () => Effect.die("unused prepare"),
435 inspect: () => Effect.die("unused inspect"),
436 uninstallManagedBinary: () => Effect.die("unused uninstall"),
437 resolve: () =>
438 Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)),
439 });
440 const probe = yield* probeAcpRegistryConfiguration({
441 instanceId,
442 settings: decodeSettings({ agentId: "slow-agent" }),
443 cwd: process.cwd(),
444 environment: process.env,
445 }).pipe(
446 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
447 Effect.flip,
448 Effect.forkChild({ startImmediately: true }),
449 );
450
451 yield* Deferred.await(resolveStarted);
452 yield* TestClock.adjust("60 seconds");
453
454 expect(yield* Fiber.join(probe)).toMatchObject({
455 reason: "probe_failed",
456 message: expect.stringContaining("within 60 seconds"),
457 });
458 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
459 );
460
461 it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => {
462 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
463 search: () => Effect.die("unused search"),
464 prepare: () => Effect.die("unused prepare"),
465 inspect: () => Effect.die("unused inspect"),
466 uninstallManagedBinary: () => Effect.die("unused uninstall"),
467 resolve: () =>
468 Effect.succeed({
469 agent: {
470 id: "mock-agent",
471 name: "Mock Agent",
472 version: "1.0.0",
473 description: "ACP session management test agent",
474 distribution: { npx: { package: "mock-agent@1.0.0" } },
475 },
476 distribution: "npx" as const,
477 spawn: {
478 command: "node",
479 args: [mockAgentPath],
480 env: {
481 ...process.env,
482 T3_ACP_SESSION_LIFECYCLE: "1",
483 T3_ACP_AUTH_METHOD_ID: "mock-login",
484 },
485 },
486 }),
487 });
488 const input = {
489 instanceId,
490 settings: decodeSettings({ agentId: "mock-agent" }),
491 cwd: process.cwd(),
492 environment: process.env,
493 };
494
495 return Effect.gen(function* () {
496 const listed = yield* listAcpRegistrySessions(input);
497 expect(listed).toMatchObject({
498 canLoad: true,
499 canResume: true,
500 nextCursor: null,
501 sessions: [
502 {
503 sessionId: "mock-session-1",
504 cwd: process.cwd(),
505 importedThreadId: null,
506 },
507 ],
508 });
509 yield* logoutAcpRegistry(input);
510 }).pipe(
511 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
512 Effect.provide(NodeServices.layer),
513 Effect.scoped,
514 );
515 });
516
517 it.effect("preserves the ACP method error for unsupported session management", () => {
518 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
519 search: () => Effect.die("unused search"),
520 prepare: () => Effect.die("unused prepare"),
521 inspect: () => Effect.die("unused inspect"),
522 uninstallManagedBinary: () => Effect.die("unused uninstall"),
523 resolve: () =>
524 Effect.succeed({
525 agent: {
526 id: "mock-agent",
527 name: "Mock Agent",
528 version: "1.0.0",
529 description: "ACP unsupported management test agent",
530 distribution: { npx: { package: "mock-agent@1.0.0" } },
531 },
532 distribution: "npx" as const,
533 spawn: {
534 command: "node",
535 args: [mockAgentPath],
536 env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" },
537 },
538 }),
539 });
540
541 return Effect.gen(function* () {
542 const failure = yield* listAcpRegistrySessions({
543 instanceId,
544 settings: decodeSettings({ agentId: "mock-agent" }),
545 cwd: process.cwd(),
546 environment: process.env,
547 }).pipe(Effect.flip);
548
549 expect(failure).toMatchObject({
550 reason: "session_list_unsupported",
551 message: "The ACP agent does not advertise session listing.",
552 cause: { _tag: "AcpRequestError", code: -32601 },
553 });
554 }).pipe(
555 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
556 Effect.provide(NodeServices.layer),
557 Effect.scoped,
558 );
559 });
560
561 it("distinguishes authentication failures from generic probe failures", () => {
562 const advertised = normalizeAcpRegistryAuthMethods([
563 { id: "grok-login", name: "Log in with Grok" },
564 ]);
565 const cause = new EffectAcpErrors.AcpRequestError({
566 code: -32000,
567 errorMessage: "login required",
568 });
569 const authFailure = acpRegistryProbeFailure(cause, advertised);
570 expect(authFailure.reason).toBe("authentication_failed");
571 expect(authFailure.message).toBe("The ACP agent could not complete authentication.");
572 expect(authFailure.cause).toBe(cause);
573 expect(authFailure.authMethods).toEqual([
574 {
575 id: "grok-login",
576 name: "Log in with Grok",
577 description: null,
578 type: "agent",
579 },
580 ]);
581 expect(
582 acpRegistryProbeFailure(
583 new EffectAcpErrors.AcpTransportError({
584 detail: "Authentication required",
585 cause: "credentials missing",
586 }),
587 ).reason,
588 ).toBe("authentication_failed");
589 const genericCause = new EffectAcpErrors.AcpTransportError({
590 detail: "connection closed",
591 cause: "closed",
592 });
593 const genericFailure = acpRegistryProbeFailure(genericCause);
594 expect(genericFailure.reason).toBe("probe_failed");
595 expect(genericFailure.message).toBe("The ACP agent could not create a test session.");
596 expect(genericFailure.cause).toBe(genericCause);
597 expect(
598 acpRegistryProbeFailure(
599 new EffectAcpErrors.AcpTransportError({
600 detail: "Failed while logging request",
601 cause: "logging failed",
602 }),
603 ).reason,
604 ).toBe("probe_failed");
605 });
606});
apps/server/src/provider/CodexChatGptAuth.ts+2-2
......@@ -19,8 +19,8 @@ import * as Effect from "effect/Effect";
1919import * as Option from "effect/Option";
2020import * as Schema from "effect/Schema";
2121import * as Semaphore from "effect/Semaphore";
22import * as ProviderAuthFlow from "./ProviderAuthFlow.ts";
23import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts";
22import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow";
23import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/ProviderAuthFlow";
2424import { HttpClient, HttpClientRequest } from "effect/http";
2525import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
2626import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts deleted-459
......@@ -1,459 +0,0 @@
1import {
2 AcpRegistryOperationError,
3 AcpRegistrySettings,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10
11import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts";
12import {
13 acpRegistrySnapshotReadiness,
14 applyAcpRegistryAvailableCommands,
15 applyAcpRegistryLiveConfiguration,
16 buildCheckedAcpRegistrySnapshot,
17 checkAcpRegistryProviderReadiness,
18 checkAcpRegistryProviderStatus,
19} from "./AcpRegistryDriver.ts";
20
21const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
22const identity = {
23 instanceId: ProviderInstanceId.make("acpRegistry_test"),
24 displayName: "Test ACP",
25 accentColor: undefined,
26 continuationKey: "acpRegistry:instance:acpRegistry_test",
27};
28const noSessionManagement = {
29 canList: false,
30 canLoad: false,
31 canResume: false,
32 canLogout: false,
33 canDelete: false,
34 canConfigureProviders: false,
35} as const;
36
37function catalogWithInspection(
38 inspection: AcpRegistrySupport.AcpRegistryInspection,
39): AcpRegistrySupport.AcpRegistryCatalog["Service"] {
40 return {
41 search: () => Effect.die("unused search"),
42 prepare: () => Effect.die("unused prepare"),
43 inspect: () => Effect.succeed(inspection),
44 resolve: () => Effect.die("unused resolve"),
45 uninstallManagedBinary: () => Effect.die("unused uninstall"),
46 };
47}
48
49describe("acpRegistrySnapshotReadiness", () => {
50 it("treats a live empty command advertisement as an authoritative replacement", () => {
51 const provider = buildCheckedAcpRegistrySnapshot({
52 ...identity,
53 settings: decodeSettings({ agentId: "test-agent" }),
54 checkedAt: "2026-08-13T10:00:00.000Z",
55 inspection: {
56 status: "ready",
57 agentId: "test-agent",
58 version: "1.0.0",
59 distribution: "npx",
60 documentationUrl: "https://example.test/agent/setup",
61 },
62 probe: {
63 probe: {
64 instanceId: identity.instanceId,
65 ready: true,
66 icon: null,
67 authMethods: [],
68 models: [],
69 currentModelId: null,
70 configOptions: [],
71 sessionManagement: noSessionManagement,
72 },
73 slashCommands: [{ name: "stale" }],
74 skills: [{ name: "stale-skill", path: "stale", enabled: true }],
75 },
76 });
77
78 const replaced = applyAcpRegistryAvailableCommands(
79 provider,
80 Option.some({ slashCommands: [], skills: [] }),
81 );
82 expect(replaced.slashCommands).toEqual([]);
83 expect(replaced.skills).toEqual([]);
84 expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([
85 { name: "stale" },
86 ]);
87 expect(provider.iconUrl).toBe(
88 "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg",
89 );
90 expect(provider.setup).toMatchObject({
91 canAuthenticate: false,
92 documentationUrl: "https://example.test/agent/setup",
93 });
94 });
95
96 it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => {
97 const snapshot = buildCheckedAcpRegistrySnapshot({
98 ...identity,
99 settings: decodeSettings({ agentId: "test-agent" }),
100 checkedAt: "2026-08-13T10:00:00.000Z",
101 inspection: {
102 status: "ready",
103 agentId: "test-agent",
104 version: "1.0.0",
105 distribution: "binary",
106 },
107 probe: {
108 probe: {
109 instanceId: identity.instanceId,
110 ready: true,
111 icon: null,
112 authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }],
113 models: [],
114 currentModelId: null,
115 configOptions: [],
116 sessionManagement: noSessionManagement,
117 },
118 slashCommands: [],
119 skills: [],
120 },
121 });
122 expect(snapshot.auth.status).toBe("unknown");
123 expect(snapshot.setup?.canAuthenticate).toBe(true);
124 expect(
125 applyAcpRegistryLiveConfiguration(
126 snapshot,
127 { models: [], currentModelId: null, configOptions: [] },
128 [],
129 ).auth.status,
130 ).toBe("unknown");
131 });
132
133 it("overlays live configuration without dropping probe-owned session capabilities", () => {
134 const provider = buildCheckedAcpRegistrySnapshot({
135 ...identity,
136 settings: decodeSettings({ agentId: "test-agent" }),
137 checkedAt: "2026-08-13T10:00:00.000Z",
138 inspection: {
139 status: "ready",
140 agentId: "test-agent",
141 version: "1.0.0",
142 distribution: "npx",
143 },
144 probe: {
145 probe: {
146 instanceId: identity.instanceId,
147 ready: true,
148 icon: null,
149 authMethods: [],
150 models: [{ id: "probe-model", name: "Probe model", description: null }],
151 currentModelId: "probe-model",
152 configOptions: [],
153 sessionManagement: {
154 canList: true,
155 canLoad: true,
156 canResume: true,
157 canLogout: true,
158 canDelete: true,
159 canConfigureProviders: true,
160 },
161 },
162 slashCommands: [],
163 skills: [],
164 },
165 });
166
167 expect(
168 applyAcpRegistryLiveConfiguration(
169 provider,
170 {
171 models: [{ id: "live-model", name: "Live model", description: null }],
172 currentModelId: "live-model",
173 configOptions: [],
174 },
175 [],
176 ),
177 ).toMatchObject({
178 auth: { status: "unknown", canLogout: true },
179 nativeSessions: { canList: true, canLoad: true, canResume: true },
180 models: [{ slug: "live-model", isDefault: true }],
181 });
182 });
183
184 it("maps registry inspection status to provider readiness", () => {
185 expect(
186 acpRegistrySnapshotReadiness({
187 status: "ready",
188 agentId: "gemini-cli",
189 version: "1.2.3",
190 distribution: "npx",
191 }),
192 ).toEqual({ installed: true, version: "1.2.3", status: "ready" });
193
194 expect(
195 acpRegistrySnapshotReadiness({
196 status: "missing_runner",
197 agentId: "gemini-cli",
198 version: "1.2.3",
199 distribution: "npx",
200 runner: "npx",
201 }),
202 ).toMatchObject({ installed: false, version: "1.2.3", status: "error" });
203
204 expect(
205 acpRegistrySnapshotReadiness({
206 status: "missing_runner",
207 version: null,
208 distribution: "local",
209 }),
210 ).toEqual({
211 installed: false,
212 version: null,
213 status: "error",
214 message: "Local ACP executable is not available on this environment's PATH.",
215 });
216
217 expect(
218 acpRegistrySnapshotReadiness({
219 status: "unprepared",
220 agentId: "zed-agent",
221 version: "2.0.0",
222 distribution: "binary",
223 }),
224 ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" });
225
226 expect(
227 acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }),
228 ).toEqual({
229 installed: false,
230 version: null,
231 status: "error",
232 message: "Registry unavailable.",
233 });
234 });
235
236 it("projects discovery without claiming authentication when no login methods are advertised", () => {
237 const snapshot = buildCheckedAcpRegistrySnapshot({
238 ...identity,
239 settings: decodeSettings({
240 agentId: "test-agent",
241 customModels: [" custom-model ", "gpt-discovered"],
242 }),
243 checkedAt: "2026-08-13T10:00:00.000Z",
244 inspection: {
245 status: "ready",
246 agentId: "test-agent",
247 version: "1.0.0",
248 distribution: "npx",
249 },
250 probe: {
251 probe: {
252 instanceId: identity.instanceId,
253 ready: true,
254 icon: null,
255 authMethods: [],
256 models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }],
257 currentModelId: "gpt-discovered",
258 configOptions: [],
259 sessionManagement: noSessionManagement,
260 },
261 slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }],
262 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
263 },
264 });
265
266 expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false });
267 expect(snapshot.supportsTextGeneration).toBe(false);
268 expect(
269 snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({
270 slug,
271 name,
272 isCustom,
273 isDefault,
274 })),
275 ).toEqual([
276 {
277 slug: "gpt-discovered",
278 name: "GPT Discovered",
279 isCustom: false,
280 isDefault: true,
281 },
282 {
283 slug: "custom-model",
284 name: "custom-model",
285 isCustom: true,
286 isDefault: undefined,
287 },
288 ]);
289 expect(snapshot.slashCommands).toEqual([
290 { name: "plan", description: "Create a plan", input: { hint: "topic" } },
291 ]);
292 expect(snapshot.skills).toEqual([
293 { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true },
294 ]);
295 });
296
297 it("keeps a default model only when the agent advertises none", () => {
298 const snapshot = buildCheckedAcpRegistrySnapshot({
299 ...identity,
300 settings: decodeSettings({ agentId: "test-agent" }),
301 checkedAt: "2026-08-13T10:00:00.000Z",
302 inspection: {
303 status: "ready",
304 agentId: "test-agent",
305 version: "1.0.0",
306 distribution: "uvx",
307 },
308 probe: {
309 probe: {
310 instanceId: identity.instanceId,
311 ready: true,
312 icon: null,
313 authMethods: [],
314 models: [],
315 currentModelId: null,
316 configOptions: [],
317 sessionManagement: noSessionManagement,
318 },
319 slashCommands: [],
320 skills: [],
321 },
322 });
323
324 expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]);
325 expect(snapshot.models[0]?.isDefault).toBe(true);
326 });
327
328 it.each(["registry", "local"] as const)(
329 "reports failed authentication after successful %s inspection",
330 (source) => {
331 const snapshot = buildCheckedAcpRegistrySnapshot({
332 ...identity,
333 settings: decodeSettings({
334 source,
335 ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }),
336 authMethodId: "grok-login",
337 }),
338 checkedAt: "2026-08-13T10:00:00.000Z",
339 inspection: {
340 status: "ready",
341 agentId: "test-agent",
342 version: source === "local" ? null : "1.0.0",
343 distribution: source === "local" ? "local" : "binary",
344 },
345 probeError: new AcpRegistryOperationError({
346 reason: "authentication_failed",
347 message: "Login required.",
348 authMethods: [
349 {
350 id: "api-key",
351 name: "API key",
352 description: null,
353 type: "env_var",
354 },
355 {
356 id: "grok-login",
357 name: "Log in with Grok",
358 description: null,
359 type: "agent",
360 },
361 ],
362 }),
363 });
364
365 expect(snapshot).toMatchObject({
366 installed: true,
367 version: source === "local" ? null : "1.0.0",
368 status: "warning",
369 setup: { canAuthenticate: true },
370 auth: {
371 status: "unauthenticated",
372 type: "agent",
373 label: "Log in with Grok",
374 },
375 message: 'Sign in in provider settings using "Log in with Grok".',
376 });
377 },
378 );
379
380 it.effect("runs the disposable probe only after local inspection is ready", () =>
381 Effect.gen(function* () {
382 const settings = decodeSettings({ agentId: "test-agent" });
383 const environment = { PATH: "/provider/bin" };
384 let receivedEnvironment: NodeJS.ProcessEnv | undefined;
385 const snapshot = yield* checkAcpRegistryProviderStatus(
386 {
387 ...identity,
388 settings,
389 cwd: "/workspace",
390 environment,
391 },
392 (input) =>
393 Effect.sync(() => {
394 receivedEnvironment = input.environment;
395 return {
396 probe: {
397 instanceId: identity.instanceId,
398 ready: true as const,
399 icon: null,
400 authMethods: [],
401 models: [{ id: "agent-model", name: "Agent Model", description: null }],
402 currentModelId: "agent-model",
403 configOptions: [],
404 sessionManagement: noSessionManagement,
405 },
406 slashCommands: [{ name: "review" }],
407 skills: [],
408 };
409 }),
410 ).pipe(
411 Effect.provideService(
412 AcpRegistrySupport.AcpRegistryCatalog,
413 catalogWithInspection({
414 status: "ready",
415 agentId: "test-agent",
416 version: "1.0.0",
417 distribution: "npx",
418 }),
419 ),
420 );
421
422 expect(receivedEnvironment).toBe(environment);
423 expect(snapshot).toMatchObject({
424 auth: { status: "unknown" },
425 models: [{ slug: "agent-model" }],
426 slashCommands: [{ name: "review" }],
427 skills: [],
428 });
429 }),
430 );
431
432 it.effect("publishes concrete local readiness before background ACP discovery", () =>
433 Effect.gen(function* () {
434 const snapshot = yield* checkAcpRegistryProviderReadiness({
435 ...identity,
436 settings: decodeSettings({ agentId: "test-agent" }),
437 environment: { PATH: "/provider/bin" },
438 }).pipe(
439 Effect.provideService(
440 AcpRegistrySupport.AcpRegistryCatalog,
441 catalogWithInspection({
442 status: "ready",
443 agentId: "test-agent",
444 version: "1.0.0",
445 distribution: "npx",
446 }),
447 ),
448 );
449
450 expect(snapshot).toMatchObject({
451 installed: true,
452 status: "ready",
453 version: "1.0.0",
454 auth: { status: "unknown" },
455 message: "Checking ACP authentication, models, and commands in the background...",
456 });
457 }),
458 );
459});
apps/server/src/provider/Drivers/AcpRegistryDriver.ts deleted-906
......@@ -1,906 +0,0 @@
1import {
2 AcpRegistrySettings,
3 officialAcpRegistryIconUrlForAgentId,
4 ProviderDriverKind,
5 resolveOfficialAcpRegistryIconUrl,
6 TextGenerationError,
7 type AcpRegistryOperationError,
8 type ServerProvider,
9 type ServerProviderModel,
10} from "@t3tools/contracts";
11import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
12import { createModelCapabilities } from "@t3tools/shared/model";
13import * as Clock from "effect/Clock";
14import * as Crypto from "effect/Crypto";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import * as Result from "effect/Result";
20import * as Schema from "effect/Schema";
21import * as Semaphore from "effect/Semaphore";
22import { ChildProcessSpawner } from "effect/process";
23
24import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
25import {
26 AcpRegistryAdapterV2Driver,
27 type AcpRegistryAdapterV2DriverEnv,
28} from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts";
29import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
30import { ProviderDriverError } from "../Errors.ts";
31import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
32import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
33import {
34 defaultProviderContinuationIdentity,
35 type ProviderDriver,
36 type ProviderInstance,
37} from "@t3tools/provider-core/server/driver";
38import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe";
39import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
40import {
41 haveProviderSnapshotSettingsChanged,
42 makeProviderSnapshotSettingsSource,
43 type ProviderSnapshotSettings,
44} from "@t3tools/provider-core/server/snapshotSettings";
45import {
46 type AcpRegistryAvailableCommands,
47 type AcpRegistryConfigurationProbe,
48 type AcpRegistryConfigurationProbeResult,
49 type AcpRegistryLiveConfiguration,
50 deleteAcpRegistrySession,
51 disableAcpRegistryProvider,
52 listAcpRegistrySessions,
53 listAcpRegistryProviders,
54 logoutAcpRegistry,
55 probeAcpRegistryConfiguration,
56 setAcpRegistryProvider,
57} from "../acp/AcpRegistryProbe.ts";
58import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts";
59import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts";
60import * as AcpRegistryAuth from "../acp/AcpRegistryAuth.ts";
61import * as AcpRegistryAuthenticationState from "../acp/AcpRegistryAuthenticationState.ts";
62
63const DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
64const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
65// How long a successful discovery probe stays valid. Periodic health
66// refreshes reuse it instead of spawning a fresh disposable ACP session;
67// failed or unauthenticated probes are never cached so a completed sign-in
68// is detected on the next refresh.
69const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000;
70const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
71const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({
72 provider: DRIVER_KIND,
73 packageName: null,
74});
75
76const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => {
77 const unsupported = (operation: string) =>
78 Effect.fail(
79 new TextGenerationError({
80 operation,
81 detail: "ACP Registry instances do not provide application text generation.",
82 }),
83 );
84 return {
85 generateCommitMessage: () => unsupported("generateCommitMessage"),
86 generatePrContent: () => unsupported("generatePrContent"),
87 generateBranchName: () => unsupported("generateBranchName"),
88 generateThreadTitle: () => unsupported("generateThreadTitle"),
89 };
90};
91
92function modelsFromDiscovery(
93 discovery:
94 | Pick<AcpRegistryLiveConfiguration, "models" | "currentModelId" | "configOptions">
95 | undefined,
96 customModels: ReadonlyArray<string>,
97): ReadonlyArray<ServerProviderModel> {
98 const discovered = discovery?.models ?? [];
99 // Discovered session config options and modes ride on every model so the
100 // composer's generic option controls can drive them per thread.
101 const capabilities =
102 discovery === undefined || discovery.configOptions.length === 0
103 ? EMPTY_CAPABILITIES
104 : createModelCapabilities({ optionDescriptors: discovery.configOptions });
105 const builtInModels: ReadonlyArray<ServerProviderModel> =
106 discovered.length === 0
107 ? [
108 {
109 slug: "default",
110 name: "Default",
111 isCustom: false,
112 isDefault: true,
113 capabilities,
114 },
115 ]
116 : discovered.map((model) => ({
117 slug: model.id,
118 name: model.name,
119 isCustom: false,
120 ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}),
121 capabilities,
122 }));
123 return providerModelsFromSettings(builtInModels, customModels, capabilities);
124}
125
126export function acpRegistrySnapshotReadiness(
127 inspection:
128 | AcpRegistrySupport.AcpRegistryInspection
129 | { readonly status: "failed"; readonly message: string },
130): Pick<ServerProvider, "installed" | "version" | "status" | "message"> {
131 switch (inspection.status) {
132 case "ready":
133 return { installed: true, version: inspection.version, status: "ready" };
134 case "unconfigured":
135 return {
136 installed: false,
137 version: null,
138 status: "warning",
139 message:
140 "Select an ACP Registry agent or configure a local ACP executable before starting a thread.",
141 };
142 case "not_found":
143 return {
144 installed: false,
145 version: null,
146 status: "error",
147 message: `ACP Registry does not contain agent '${inspection.agentId}'.`,
148 };
149 case "unsupported":
150 return {
151 installed: false,
152 version: inspection.version,
153 status: "error",
154 message: `ACP Registry agent '${inspection.agentId}' has no compatible distribution for this environment.`,
155 };
156 case "missing_runner":
157 return {
158 installed: false,
159 version: inspection.version,
160 status: "error",
161 message:
162 inspection.distribution === "local"
163 ? "Local ACP executable is not available on this environment's PATH."
164 : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`,
165 };
166 case "unprepared":
167 return {
168 installed: false,
169 version: inspection.version,
170 status: "warning",
171 message: `ACP Registry agent '${inspection.agentId}' has not been prepared on this environment.`,
172 };
173 case "failed":
174 return {
175 installed: false,
176 version: null,
177 status: "error",
178 message: inspection.message,
179 };
180 }
181}
182
183interface SnapshotIdentity {
184 readonly instanceId: ProviderInstance["instanceId"];
185 readonly displayName: string | undefined;
186 readonly accentColor: string | undefined;
187 readonly continuationKey: string;
188}
189
190function baseSnapshot(
191 input: SnapshotIdentity & {
192 readonly settings: AcpRegistrySettings;
193 readonly checkedAt: string;
194 readonly installed: boolean;
195 readonly version: string | null;
196 readonly status: ServerProvider["status"];
197 readonly auth: ServerProvider["auth"];
198 readonly documentationUrl?: string;
199 readonly message?: string;
200 readonly probe?: AcpRegistryConfigurationProbeResult;
201 readonly probeError?: AcpRegistryOperationError;
202 },
203): ServerProvider {
204 const iconUrl =
205 input.settings.source === "local"
206 ? null
207 : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ??
208 officialAcpRegistryIconUrlForAgentId(input.settings.agentId));
209 return {
210 instanceId: input.instanceId,
211 driver: DRIVER_KIND,
212 ...(input.displayName ? { displayName: input.displayName } : {}),
213 ...(input.accentColor ? { accentColor: input.accentColor } : {}),
214 ...(iconUrl ? { iconUrl } : {}),
215 continuation: { groupKey: input.continuationKey },
216 // The registry driver rejects every application text-generation operation,
217 // so selectors must not offer these instances for commit, PR, branch, or
218 // title generation.
219 supportsTextGeneration: false,
220 enabled: input.settings.enabled,
221 installed: input.installed,
222 version: input.version,
223 status: input.settings.enabled ? input.status : "disabled",
224 auth: { ...input.auth, canLogout: input.auth.canLogout ?? false },
225 checkedAt: input.checkedAt,
226 setup: {
227 canInstall: false,
228 ...(input.documentationUrl ? { documentationUrl: input.documentationUrl } : {}),
229 canAuthenticate:
230 input.installed &&
231 (input.probe
232 ? input.probe.probe.authMethods.length > 0
233 : input.settings.source === "local"
234 ? (input.probeError?.authMethods?.length ?? 0) > 0
235 : input.settings.agentId.length > 0),
236 },
237 ...(input.message ? { message: input.message } : {}),
238 models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels),
239 ...(input.probe === undefined
240 ? {}
241 : {
242 nativeSessions: {
243 canList: input.probe.probe.sessionManagement.canList,
244 canLoad: input.probe.probe.sessionManagement.canLoad,
245 canResume: input.probe.probe.sessionManagement.canResume,
246 canDelete: input.probe.probe.sessionManagement.canDelete,
247 },
248 configurableProviders: input.probe.probe.sessionManagement.canConfigureProviders,
249 }),
250 slashCommands: input.probe?.slashCommands ?? [],
251 skills: input.probe?.skills ?? [],
252 };
253}
254
255export function applyAcpRegistryAvailableCommands(
256 provider: ServerProvider,
257 commands: Option.Option<AcpRegistryAvailableCommands>,
258): ServerProvider {
259 return Option.match(commands, {
260 onNone: () => provider,
261 onSome: ({ slashCommands, skills }) => ({ ...provider, slashCommands, skills }),
262 });
263}
264
265export function applyAcpRegistryLiveConfiguration(
266 provider: ServerProvider,
267 configuration: AcpRegistryLiveConfiguration,
268 customModels: ReadonlyArray<string>,
269): ServerProvider {
270 const { message: _staleProbeMessage, ...snapshot } = provider;
271 return {
272 ...snapshot,
273 status: provider.enabled ? "ready" : provider.status,
274 models: modelsFromDiscovery(configuration, customModels),
275 };
276}
277
278function applyAcpRegistryUrlAuthAction(
279 provider: ServerProvider,
280 action: Option.Option<NonNullable<ServerProvider["auth"]["action"]>>,
281): ServerProvider {
282 const { action: _previousAction, ...auth } = provider.auth;
283 return {
284 ...provider,
285 auth: Option.match(action, {
286 onNone: () => auth,
287 onSome: (nextAction) => ({ ...auth, action: nextAction }),
288 }),
289 };
290}
291
292const buildInitialAcpRegistrySnapshot = Effect.fn("AcpRegistryDriver.buildInitialSnapshot")(
293 function* (input: SnapshotIdentity & { readonly settings: AcpRegistrySettings }) {
294 const checkedAt = DateTime.formatIso(yield* DateTime.now);
295 return baseSnapshot({
296 ...input,
297 checkedAt,
298 installed: false,
299 version: null,
300 status: "warning",
301 auth: { status: "unknown" },
302 message: input.settings.enabled
303 ? "Checking ACP Registry agent readiness..."
304 : "ACP Registry is disabled in T3 Code settings.",
305 });
306 },
307);
308
309export function buildCheckedAcpRegistrySnapshot(
310 input: SnapshotIdentity & {
311 readonly settings: AcpRegistrySettings;
312 readonly checkedAt: string;
313 readonly inspection:
314 | AcpRegistrySupport.AcpRegistryInspection
315 | { readonly status: "failed"; readonly message: string };
316 readonly probe?: AcpRegistryConfigurationProbeResult;
317 readonly probeError?: AcpRegistryOperationError;
318 },
319): ServerProvider {
320 const readiness = acpRegistrySnapshotReadiness(input.inspection);
321 const probeFailed = input.probeError !== undefined;
322 const advertisedAuthMethod =
323 input.probeError?.reason === "authentication_failed"
324 ? (input.probeError.authMethods?.find(
325 (method) => method.id === input.settings.authMethodId,
326 ) ?? input.probeError.authMethods?.[0])
327 : undefined;
328 const authenticationMessage = advertisedAuthMethod
329 ? advertisedAuthMethod.type === "terminal" && advertisedAuthMethod.command
330 ? `Sign in in provider settings using "${advertisedAuthMethod.name}". The login terminal runs on this environment.`
331 : advertisedAuthMethod.type === "env_var" &&
332 (advertisedAuthMethod.envVarNames?.length ?? 0) > 0
333 ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.`
334 : `Sign in in provider settings using "${advertisedAuthMethod.name}".`
335 : undefined;
336 return baseSnapshot({
337 ...input,
338 ...(input.inspection.status === "ready" && input.inspection.documentationUrl
339 ? { documentationUrl: input.inspection.documentationUrl }
340 : {}),
341 installed: readiness.installed,
342 version: readiness.version,
343 // A failed discovery probe on a ready installation is a warning, not an
344 // outage: the agent binary is present and a real turn may still work
345 // (for example after the user completes authentication).
346 status: probeFailed ? "warning" : readiness.status,
347 auth: input.probe
348 ? {
349 // Discovery sessions and an empty auth-method list do not prove sign-in.
350 status: "unknown",
351 canLogout: input.probe.probe.sessionManagement.canLogout,
352 }
353 : input.probeError?.reason === "authentication_failed"
354 ? {
355 status: "unauthenticated",
356 ...(advertisedAuthMethod
357 ? { type: advertisedAuthMethod.type, label: advertisedAuthMethod.name }
358 : {}),
359 ...(input.probeError.authAction === undefined
360 ? {}
361 : { action: input.probeError.authAction }),
362 }
363 : { status: "unknown" },
364 ...(input.probeError
365 ? { message: authenticationMessage ?? input.probeError.message }
366 : readiness.message
367 ? { message: readiness.message }
368 : {}),
369 });
370}
371
372export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")(
373 function* <Requirements>(
374 input: SnapshotIdentity & {
375 readonly settings: AcpRegistrySettings;
376 readonly cwd: string;
377 readonly environment: NodeJS.ProcessEnv;
378 },
379 probeConfiguration: AcpRegistryConfigurationProbe<Requirements>,
380 ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog | Requirements> {
381 const checkedAt = DateTime.formatIso(yield* DateTime.now);
382 if (!input.settings.enabled) {
383 return yield* buildInitialAcpRegistrySnapshot(input);
384 }
385 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
386 const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment));
387 if (Result.isFailure(inspected)) {
388 return buildCheckedAcpRegistrySnapshot({
389 ...input,
390 checkedAt,
391 inspection: {
392 status: "failed",
393 message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`,
394 },
395 });
396 }
397 if (inspected.success.status !== "ready") {
398 return buildCheckedAcpRegistrySnapshot({
399 ...input,
400 checkedAt,
401 inspection: inspected.success,
402 });
403 }
404 const probed = yield* Effect.result(
405 probeConfiguration({
406 instanceId: input.instanceId,
407 settings: input.settings,
408 cwd: input.cwd,
409 environment: input.environment,
410 }),
411 );
412 return Result.isFailure(probed)
413 ? buildCheckedAcpRegistrySnapshot({
414 ...input,
415 checkedAt,
416 inspection: inspected.success,
417 probeError: probed.failure,
418 })
419 : buildCheckedAcpRegistrySnapshot({
420 ...input,
421 checkedAt,
422 inspection: inspected.success,
423 probe: probed.success,
424 });
425 },
426);
427
428/** Publishes local executable readiness without starting an ACP process. */
429export const checkAcpRegistryProviderReadiness = Effect.fn(
430 "AcpRegistryDriver.checkProviderReadiness",
431)(function* (
432 input: SnapshotIdentity & {
433 readonly settings: AcpRegistrySettings;
434 readonly environment: NodeJS.ProcessEnv;
435 },
436): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog> {
437 const checkedAt = DateTime.formatIso(yield* DateTime.now);
438 if (!input.settings.enabled) {
439 return yield* buildInitialAcpRegistrySnapshot(input);
440 }
441 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
442 const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment));
443 const snapshot = buildCheckedAcpRegistrySnapshot({
444 ...input,
445 checkedAt,
446 inspection: Result.isFailure(inspected)
447 ? {
448 status: "failed",
449 message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`,
450 }
451 : inspected.success,
452 });
453 return inspected._tag === "Success" && inspected.success.status === "ready"
454 ? {
455 ...snapshot,
456 message: "Checking ACP authentication, models, and commands in the background...",
457 }
458 : snapshot;
459});
460
461export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost;
462
463/** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */
464export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryDriverEnv> = {
465 driverKind: DRIVER_KIND,
466 metadata: {
467 displayName: "ACP Registry",
468 supportsMultipleInstances: true,
469 hasDefaultInstance: false,
470 },
471 configSchema: AcpRegistrySettings,
472 defaultConfig: () => decodeSettings({}),
473 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
474 Effect.gen(function* () {
475 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
476 const runtimeCoordinator = yield* Effect.serviceOption(
477 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
478 );
479 if (Option.isSome(runtimeCoordinator)) {
480 yield* runtimeCoordinator.value.clearAvailableCommands(instanceId);
481 yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId);
482 yield* Effect.addFinalizer(() =>
483 runtimeCoordinator.value
484 .clearAvailableCommands(instanceId)
485 .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))),
486 );
487 }
488 const crypto = yield* Crypto.Crypto;
489 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
490 const hostEnvironment = yield* HostProcessEnvironment;
491 const host = yield* ProviderHost;
492 const continuationIdentity = defaultProviderContinuationIdentity({
493 driverKind: DRIVER_KIND,
494 instanceId,
495 });
496 const identity = {
497 instanceId,
498 displayName,
499 accentColor,
500 continuationKey: continuationIdentity.continuationKey,
501 };
502 const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings;
503 const processEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
504 const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({
505 instanceId,
506 displayName,
507 accentColor,
508 environment,
509 enabled,
510 config,
511 }).pipe(
512 Effect.mapError(
513 (cause) =>
514 new ProviderDriverError({
515 driver: DRIVER_KIND,
516 instanceId,
517 detail: "Failed to build ACP Registry orchestration adapter.",
518 cause,
519 }),
520 ),
521 );
522 const readinessInput = {
523 ...identity,
524 settings: effectiveConfig,
525 environment: processEnvironment,
526 };
527 const confirmedAuthentication =
528 yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({
529 cacheDir: host.paths.providerStatusCacheDir,
530 instanceId,
531 settings: effectiveConfig,
532 environment,
533 processEnvironment,
534 });
535 const withLiveRuntimeState = (input: ServerProvider) =>
536 Effect.gen(function* () {
537 if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false);
538 const provider =
539 input.enabled &&
540 input.installed &&
541 input.auth.status === "unknown" &&
542 (yield* confirmedAuthentication.get)
543 ? { ...input, auth: { ...input.auth, status: "authenticated" as const } }
544 : input;
545 return yield* Option.isNone(runtimeCoordinator)
546 ? Effect.succeed(provider)
547 : Effect.all({
548 commands: runtimeCoordinator.value.getAvailableCommands(instanceId),
549 configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId),
550 authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId),
551 }).pipe(
552 Effect.map(({ commands, configuration, authAction }) => {
553 const withCommands = applyAcpRegistryAvailableCommands(provider, commands);
554 const withConfiguration = Option.match(configuration, {
555 onNone: () => withCommands,
556 onSome: (liveConfiguration) =>
557 applyAcpRegistryLiveConfiguration(
558 withCommands,
559 liveConfiguration,
560 effectiveConfig.customModels,
561 ),
562 });
563 return applyAcpRegistryUrlAuthAction(withConfiguration, authAction);
564 }),
565 );
566 });
567 const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe(
568 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
569 Effect.flatMap(withLiveRuntimeState),
570 );
571 const enrichProvider = checkAcpRegistryProviderStatus(
572 {
573 ...readinessInput,
574 cwd: host.paths.cwd,
575 },
576 probeAcpRegistryConfiguration,
577 ).pipe(
578 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
579 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
580 Effect.provideService(Crypto.Crypto, crypto),
581 );
582 const enrichmentCache = yield* Ref.make<{
583 readonly generation: number;
584 readonly entry: {
585 readonly provider: ServerProvider;
586 readonly expiresAt: number;
587 } | null;
588 }>({ generation: 0, entry: null });
589 const liveSnapshotSemaphore = yield* Semaphore.make(1);
590 const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({
591 generation: current.generation + 1,
592 entry: null,
593 }));
594 const enrichProviderCached = (baseSnapshot: ServerProvider) =>
595 Effect.gen(function* () {
596 const now = yield* Clock.currentTimeMillis;
597 const cacheState = yield* Ref.get(enrichmentCache);
598 const cached = cacheState.entry;
599 if (
600 cached !== null &&
601 cached.expiresAt > now &&
602 cached.provider.version === baseSnapshot.version
603 ) {
604 return {
605 provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt },
606 generation: cacheState.generation,
607 };
608 }
609 const enriched = yield* enrichProvider;
610 if (enriched.status === "ready") {
611 yield* Ref.update(enrichmentCache, (current) =>
612 current.generation === cacheState.generation
613 ? {
614 ...current,
615 entry: {
616 provider: enriched,
617 expiresAt: now + PROBE_SUCCESS_TTL_MS,
618 },
619 }
620 : current,
621 );
622 }
623 return { provider: enriched, generation: cacheState.generation };
624 });
625 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
626 const snapshot = yield* makeManagedServerProvider<
627 ProviderSnapshotSettings<AcpRegistrySettings>
628 >({
629 resolveMaintenance: () => Effect.succeed(MAINTENANCE),
630 getSettings: snapshotSettings.getSettings,
631 streamSettings: snapshotSettings.streamSettings,
632 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
633 initialSnapshot: () => checkProvider,
634 checkProvider,
635 enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => {
636 if (!snapshot.installed) return Effect.void;
637 const publishEnrichment = (
638 Option.isSome(runtimeCoordinator)
639 ? runtimeCoordinator.value.runBackgroundProbe(
640 effectiveConfig.source === "local"
641 ? `local:${instanceId}`
642 : effectiveConfig.agentId,
643 enrichProviderCached(snapshot),
644 )
645 : enrichProviderCached(snapshot).pipe(Effect.map(Option.some))
646 ).pipe(
647 Effect.flatMap(
648 Option.match({
649 onNone: () => Effect.void,
650 onSome: ({ provider, generation }) =>
651 liveSnapshotSemaphore.withPermit(
652 Ref.get(enrichmentCache).pipe(
653 Effect.flatMap((current) =>
654 current.generation === generation
655 ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot))
656 : Effect.void,
657 ),
658 ),
659 ),
660 }),
661 ),
662 );
663 if (Option.isNone(runtimeCoordinator)) return publishEnrichment;
664
665 const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands(
666 instanceId,
667 ({ slashCommands, skills }) =>
668 liveSnapshotSemaphore.withPermit(
669 getSnapshot.pipe(
670 Effect.flatMap((current) =>
671 publishSnapshot({
672 ...current,
673 slashCommands,
674 skills,
675 }),
676 ),
677 ),
678 ),
679 );
680 const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration(
681 instanceId,
682 (configuration) =>
683 liveSnapshotSemaphore.withPermit(
684 getSnapshot.pipe(
685 Effect.flatMap((current) =>
686 publishSnapshot(
687 applyAcpRegistryLiveConfiguration(
688 current,
689 configuration,
690 effectiveConfig.customModels,
691 ),
692 ),
693 ),
694 ),
695 ),
696 );
697 const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction(
698 instanceId,
699 (action) =>
700 liveSnapshotSemaphore.withPermit(
701 getSnapshot.pipe(
702 Effect.flatMap((current) =>
703 publishSnapshot(
704 applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)),
705 ),
706 ),
707 ),
708 ),
709 );
710 return Effect.all(
711 [
712 publishEnrichment,
713 publishLiveCommands,
714 publishLiveConfiguration,
715 publishUrlAuthAction,
716 ],
717 {
718 concurrency: "unbounded",
719 discard: true,
720 },
721 );
722 },
723 }).pipe(
724 Effect.mapError(
725 (cause) =>
726 new ProviderDriverError({
727 driver: DRIVER_KIND,
728 instanceId,
729 detail: "Failed to build the ACP Registry provider snapshot.",
730 cause,
731 }),
732 ),
733 );
734
735 // The management probes read the coordinator via serviceOption, so it is
736 // provided only when this driver instance actually has one.
737 const provideAcpManagementServices = <A, E>(
738 effect: Effect.Effect<
739 A,
740 E,
741 | AcpRegistrySupport.AcpRegistryCatalog
742 | ChildProcessSpawner.ChildProcessSpawner
743 | Crypto.Crypto
744 >,
745 ): Effect.Effect<A, E> => {
746 const provided = effect.pipe(
747 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
748 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
749 Effect.provideService(Crypto.Crypto, crypto),
750 );
751 return Option.isSome(runtimeCoordinator)
752 ? provided.pipe(
753 Effect.provideService(
754 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
755 runtimeCoordinator.value,
756 ),
757 )
758 : provided;
759 };
760
761 const clearLiveState = Option.isSome(runtimeCoordinator)
762 ? runtimeCoordinator.value
763 .clearLiveConfiguration(instanceId)
764 .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId)))
765 : Effect.void;
766 const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({
767 instanceId,
768 settings: effectiveConfig,
769 cwd: host.paths.cwd,
770 environment: processEnvironment,
771 onChanged: (authenticated) =>
772 confirmedAuthentication
773 .set(authenticated)
774 .pipe(
775 Effect.andThen(authenticated ? Effect.void : clearLiveState),
776 Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)),
777 Effect.andThen(snapshot.refresh),
778 Effect.asVoid,
779 ),
780 }).pipe(
781 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
782 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
783 Effect.provideService(Crypto.Crypto, crypto),
784 );
785 const auth = {
786 ...controller,
787 invalidate: (controller.invalidate ?? Effect.void).pipe(
788 Effect.andThen(confirmedAuthentication.set(false)),
789 Effect.andThen(clearLiveState),
790 Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)),
791 Effect.andThen(snapshot.refresh),
792 Effect.asVoid,
793 ),
794 };
795
796 return {
797 instanceId,
798 driverKind: DRIVER_KIND,
799 continuationIdentity,
800 displayName,
801 accentColor,
802 enabled,
803 auth,
804 snapshot: {
805 ...snapshot,
806 refresh: snapshot.refresh.pipe(
807 Effect.tap(() => controller.refreshMethods ?? Effect.void),
808 ),
809 },
810 orchestrationAdapter,
811 textGeneration: makeUnsupportedTextGeneration(),
812 acpSessionManagement: {
813 listSessions: ({ cwd, cursor }) =>
814 provideAcpManagementServices(
815 listAcpRegistrySessions({
816 instanceId,
817 settings: effectiveConfig,
818 cwd,
819 environment: processEnvironment,
820 ...(cursor === undefined ? {} : { cursor }),
821 }),
822 ),
823 deleteSession: ({ cwd, sessionId }) =>
824 provideAcpManagementServices(
825 deleteAcpRegistrySession({
826 instanceId,
827 settings: effectiveConfig,
828 cwd,
829 environment: processEnvironment,
830 sessionId,
831 }),
832 ),
833 listProviders: (cwd) =>
834 provideAcpManagementServices(
835 listAcpRegistryProviders({
836 instanceId,
837 settings: effectiveConfig,
838 cwd,
839 environment: processEnvironment,
840 }),
841 ),
842 setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) =>
843 provideAcpManagementServices(
844 setAcpRegistryProvider({
845 instanceId,
846 settings: effectiveConfig,
847 cwd,
848 environment: processEnvironment,
849 providerId,
850 apiType,
851 baseUrl,
852 ...(headers === undefined ? {} : { headers }),
853 }),
854 ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))),
855 disableProvider: ({ cwd, providerId }) =>
856 provideAcpManagementServices(
857 disableAcpRegistryProvider({
858 instanceId,
859 settings: effectiveConfig,
860 cwd,
861 environment: processEnvironment,
862 providerId,
863 }),
864 ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))),
865 logout: (cwd) => {
866 const logout = logoutAcpRegistry({
867 instanceId,
868 settings: effectiveConfig,
869 cwd,
870 environment: processEnvironment,
871 }).pipe(
872 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
873 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
874 Effect.provideService(Crypto.Crypto, crypto),
875 Effect.tap(() => confirmedAuthentication.set(false)),
876 Effect.tap(() =>
877 liveSnapshotSemaphore.withPermit(
878 invalidateEnrichmentCache.pipe(
879 Effect.andThen(
880 Option.isSome(runtimeCoordinator)
881 ? Effect.all(
882 [
883 runtimeCoordinator.value.clearAvailableCommands(instanceId),
884 runtimeCoordinator.value.clearLiveConfiguration(instanceId),
885 ],
886 { concurrency: "unbounded", discard: true },
887 )
888 : Effect.void,
889 ),
890 ),
891 ),
892 ),
893 );
894 return Option.isSome(runtimeCoordinator)
895 ? logout.pipe(
896 Effect.provideService(
897 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
898 runtimeCoordinator.value,
899 ),
900 )
901 : logout;
902 },
903 },
904 } satisfies ProviderInstance;
905 }),
906};
apps/server/src/provider/ProviderAuthFlow.test.ts deleted-559
......@@ -1,559 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 ProviderInstanceId,
5 ProviderSetupError,
6 type ProviderAuthInteraction,
7 type ProviderAuthResponse,
8 type ProviderAuthState,
9} from "@t3tools/contracts";
10import * as Fiber from "effect/Fiber";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as ProviderAuthFlow from "./ProviderAuthFlow.ts";
20
21const instanceId = ProviderInstanceId.make("auth-flow-test");
22const method = { id: "browser", name: "Browser", description: null, type: "agent" as const };
23
24it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () =>
25 Effect.gen(function* () {
26 const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>();
27 const controller = yield* ProviderAuthFlow.make({
28 instanceId,
29 credentialBinding: { owner: "provider", key: "shared-agent" },
30 methods: Deferred.await(discovered),
31 authenticate: () => Effect.void,
32 logout: Effect.void,
33 });
34 const pending = yield* controller
35 .subscribe("owner")
36 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
37 assert.isUndefined(pending.methods);
38 yield* Deferred.succeed(discovered, []);
39 const ready = yield* controller.subscribe("owner").pipe(
40 Stream.filter((state) => state.methods !== undefined),
41 Stream.runHead,
42 Effect.map(Option.getOrThrow),
43 );
44 assert.deepEqual(ready.methods, []);
45 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
46);
47
48const makeHarness = Effect.gen(function* () {
49 const approved = yield* Deferred.make<void>();
50 const verified = yield* Deferred.make<void>();
51 const started = yield* Deferred.make<void>();
52 let attempts = 0;
53 const controller = yield* ProviderAuthFlow.make({
54 instanceId,
55 credentialBinding: { owner: "provider", key: "shared-agent" },
56 methods: Effect.succeed([method]),
57 authenticate: (_, context) =>
58 Effect.gen(function* () {
59 attempts++;
60 yield* context.setInteraction(
61 {
62 type: "browser",
63 id: "consent",
64 url: "https://example.com/login",
65 requiresConsent: true,
66 },
67 (response) =>
68 response.type === "browser" && response.action === "accept"
69 ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid)
70 : Effect.void,
71 );
72 yield* Deferred.succeed(started, undefined);
73 yield* Deferred.await(approved);
74 yield* context.verifying;
75 yield* Deferred.await(verified);
76 }),
77 logout: Effect.void,
78 });
79 const phase = (phase: ProviderAuthState["phase"], owner = "owner") =>
80 controller.subscribe(owner).pipe(
81 Stream.filter((state) => state.phase === phase),
82 Stream.runHead,
83 Effect.map(Option.getOrThrow),
84 );
85 return { controller, phase, started, verified, attempts: () => attempts };
86});
87
88it.effect("requires owner consent and provider verification before success", () =>
89 Effect.gen(function* () {
90 const { controller, phase, started, verified, attempts } = yield* makeHarness;
91 const state = yield* controller.start("owner");
92 yield* Deferred.await(started);
93 const response = {
94 instanceId,
95 flowId: state.flowId!,
96 interactionId: "consent",
97 response: { type: "browser" as const, action: "accept" as const },
98 };
99 const other = yield* phase("waiting", "other");
100 assert.isNull(other.interaction);
101 assert.isNull(other.flowId);
102 assert.isTrue(
103 (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure",
104 );
105 assert.isTrue(
106 (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe(
107 Effect.result,
108 ))._tag === "Failure",
109 );
110 yield* controller.start("owner");
111 assert.strictEqual(attempts(), 1);
112 yield* controller.respond!("owner", response);
113 yield* phase("verifying");
114 yield* Deferred.succeed(verified, undefined);
115 assert.strictEqual((yield* phase("succeeded")).phase, "succeeded");
116 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
117);
118
119it.effect("cancellation rejects late consent and permits another login", () =>
120 Effect.gen(function* () {
121 const { controller, started, phase } = yield* makeHarness;
122 const state = yield* controller.start("owner");
123 yield* Deferred.await(started);
124 yield* controller.cancel("owner", state.flowId!);
125 assert.strictEqual((yield* phase("cancelled")).phase, "cancelled");
126 assert.isTrue(
127 (yield* controller.respond!("owner", {
128 instanceId,
129 flowId: state.flowId!,
130 interactionId: "consent",
131 response: { type: "browser", action: "accept" },
132 }).pipe(Effect.result))._tag === "Failure",
133 );
134 const next = yield* controller.start("other");
135 assert.notStrictEqual(next.flowId, state.flowId);
136 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
137);
138
139it.effect("expires pending login and rejects its response", () =>
140 Effect.gen(function* () {
141 const { controller, started, phase } = yield* makeHarness;
142 const state = yield* controller.start("owner");
143 yield* Deferred.await(started);
144 yield* TestClock.adjust(300_001);
145 assert.include((yield* phase("failed")).message ?? "", "expired");
146 assert.isTrue(
147 (yield* controller.respond!("owner", {
148 instanceId,
149 flowId: state.flowId!,
150 interactionId: "consent",
151 response: { type: "browser", action: "accept" },
152 }).pipe(Effect.result))._tag === "Failure",
153 );
154 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
155);
156
157it.effect("closes admitted provider processes and blocks new ones while login is pending", () =>
158 Effect.gen(function* () {
159 const { controller, started } = yield* makeHarness;
160 let closed = false;
161 yield* controller.withAccess!(
162 Effect.addFinalizer(() =>
163 Effect.sync(() => {
164 closed = true;
165 }),
166 ),
167 );
168 yield* controller.start("owner");
169 yield* Deferred.await(started);
170 assert.isTrue(closed);
171 assert.isTrue(
172 (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure",
173 );
174 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
175);
176
177it.effect("shared credential invalidation closes admitted processes before permitting reuse", () =>
178 Effect.gen(function* () {
179 const { controller } = yield* makeHarness;
180 let closed = false;
181 yield* controller.withAccess!(
182 Effect.addFinalizer(() =>
183 Effect.sync(() => {
184 closed = true;
185 }),
186 ),
187 );
188 yield* controller.invalidate!;
189 assert.isTrue(closed);
190 assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process");
191 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
192);
193
194it.effect.each([
195 {
196 interaction: { type: "terminal", id: "terminal", output: "Log in" },
197 response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } },
198 },
199 {
200 interaction: {
201 type: "credentials",
202 id: "credentials",
203 fields: [{ name: "token", label: "Token", secret: true }],
204 },
205 response: { type: "credentials", values: { token: "private-token" } },
206 },
207] satisfies ReadonlyArray<{
208 interaction: ProviderAuthInteraction;
209 response: ProviderAuthResponse;
210}>)(
211 "keeps $interaction.type responses private and waits for adapter verification",
212 ({ interaction, response }) =>
213 Effect.gen(function* () {
214 const received = yield* Deferred.make<ProviderAuthResponse>();
215 const controller = yield* ProviderAuthFlow.make({
216 instanceId,
217 credentialBinding: { owner: "t3", key: "binding" },
218 methods: Effect.succeed([method]),
219 authenticate: (_, context) =>
220 Effect.gen(function* () {
221 yield* context.setInteraction(interaction, (response) =>
222 Deferred.succeed(received, response).pipe(Effect.asVoid),
223 );
224 yield* Deferred.await(received);
225 yield* context.verifying;
226 return yield* Effect.never;
227 }),
228 logout: Effect.void,
229 });
230 const state = yield* controller.start("owner");
231 const pending = yield* controller.subscribe("owner").pipe(
232 Stream.filter((state) => state.phase === "waiting"),
233 Stream.runHead,
234 Effect.map(Option.getOrThrow),
235 );
236 assert.deepStrictEqual(pending.interaction, interaction);
237 const hidden = yield* controller
238 .subscribe("other")
239 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
240 assert.isNull(hidden.interaction);
241 yield* controller.respond!("owner", {
242 instanceId,
243 flowId: state.flowId!,
244 interactionId: interaction.id,
245 response,
246 });
247 assert.deepStrictEqual(yield* Deferred.await(received), response);
248 const verifying = yield* controller.subscribe("owner").pipe(
249 Stream.filter((state) => state.phase === "verifying"),
250 Stream.runHead,
251 Effect.map(Option.getOrThrow),
252 );
253 assert.isNull(verifying.interaction);
254 assert.notInclude(verifying.message ?? "", "private-token");
255 yield* controller.cancel("owner", state.flowId!);
256 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
257);
258
259it.effect("shows a device code only to its owner without treating it as authenticated", () =>
260 Effect.gen(function* () {
261 const controller = yield* ProviderAuthFlow.make({
262 instanceId,
263 credentialBinding: { owner: "provider", key: "device" },
264 methods: Effect.succeed([method]),
265 authenticate: (_, context) =>
266 context
267 .setInteraction({
268 type: "deviceCode",
269 id: "code",
270 url: "https://example.com/device",
271 userCode: "ABCD-EFGH",
272 })
273 .pipe(Effect.andThen(Effect.never)),
274 logout: Effect.void,
275 });
276 const start = yield* controller.start("owner");
277 const waiting = yield* controller.subscribe("owner").pipe(
278 Stream.filter((state) => state.phase === "waiting"),
279 Stream.runHead,
280 Effect.map(Option.getOrThrow),
281 );
282 assert.strictEqual(waiting.interaction?.type, "deviceCode");
283 assert.strictEqual(waiting.authorizationUrl, "https://example.com/device");
284 const other = yield* controller
285 .subscribe("other")
286 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
287 assert.isNull(other.interaction);
288 assert.isNull(other.authorizationUrl);
289 yield* controller.cancel("owner", start.flowId!);
290 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
291);
292
293it.effect.each([
294 {
295 failure: Effect.fail(
296 new ProviderSetupError({
297 instanceId,
298 operation: "authenticate",
299 detail: "This account cannot sign in. Choose another account.",
300 }),
301 ),
302 message: "This account cannot sign in. Choose another account.",
303 },
304 {
305 failure: Effect.die(new Error("private-token in native diagnostics")),
306 message: "Sign-in failed. Start again.",
307 },
308])("publishes only safe authentication failure text %#", ({ failure, message }) =>
309 Effect.gen(function* () {
310 const controller = yield* ProviderAuthFlow.make({
311 instanceId,
312 credentialBinding: { owner: "t3", key: "failure" },
313 methods: Effect.succeed([method]),
314 authenticate: () => failure,
315 logout: Effect.void,
316 });
317 yield* controller.start("owner");
318 const failed = yield* controller.subscribe("owner").pipe(
319 Stream.filter((state) => state.phase === "failed"),
320 Stream.runHead,
321 Effect.map(Option.getOrThrow),
322 );
323 assert.strictEqual(failed.message, message);
324 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
325);
326
327const makeBlockingResponseHarness = Effect.gen(function* () {
328 const entered = yield* Deferred.make<void>();
329 const cleanupStarted = yield* Deferred.make<void>();
330 const cleanupReleased = yield* Deferred.make<void>();
331 const cleanupFinished = yield* Deferred.make<void>();
332 const authenticationFinished = yield* Deferred.make<void>();
333 let responses = 0;
334 const controller = yield* ProviderAuthFlow.make({
335 instanceId,
336 credentialBinding: { owner: "t3", key: "blocked-response" },
337 methods: Effect.succeed([method]),
338 authenticate: (_, context) =>
339 Effect.gen(function* () {
340 yield* context.setInteraction(
341 {
342 type: "browser",
343 id: "blocked",
344 url: "https://example.com/login",
345 requiresConsent: true,
346 },
347 () =>
348 Effect.gen(function* () {
349 responses++;
350 yield* Deferred.succeed(entered, undefined);
351 return yield* Effect.never;
352 }).pipe(
353 Effect.ensuring(
354 Effect.gen(function* () {
355 yield* Deferred.succeed(cleanupStarted, undefined);
356 yield* Deferred.await(cleanupReleased);
357 yield* Deferred.succeed(cleanupFinished, undefined);
358 }),
359 ),
360 ),
361 );
362 yield* Deferred.await(authenticationFinished);
363 }),
364 logout: Effect.void,
365 });
366 const start = yield* controller.start("owner");
367 yield* controller.subscribe("owner").pipe(
368 Stream.filter((state) => state.phase === "waiting"),
369 Stream.runHead,
370 );
371 const input = {
372 instanceId,
373 flowId: start.flowId!,
374 interactionId: "blocked",
375 response: { type: "browser" as const, action: "accept" as const },
376 };
377 const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild);
378 yield* Deferred.await(entered);
379 return {
380 controller,
381 input,
382 response,
383 cleanupStarted,
384 cleanupReleased,
385 cleanupFinished,
386 authenticationFinished,
387 responses: () => responses,
388 };
389});
390
391it.effect.each(["cancel", "logout"] as const)(
392 "%s interrupts and awaits a blocked adapter response before admitting another sign-in",
393 (action) =>
394 Effect.gen(function* () {
395 const harness = yield* makeBlockingResponseHarness;
396 const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input));
397 assert.include(duplicate.detail, "already in progress");
398 assert.equal(harness.responses(), 1);
399 const stopping = yield* (
400 action === "cancel"
401 ? harness.controller.cancel("owner", harness.input.flowId)
402 : harness.controller.logout(Effect.void)
403 ).pipe(Effect.forkChild);
404 yield* Deferred.await(harness.cleanupStarted);
405 assert.isTrue(yield* harness.controller.isChangingCredentials!);
406 assert.isUndefined(stopping.pollUnsafe());
407 const premature = yield* Effect.flip(harness.controller.start("other"));
408 assert.include(premature.detail, "in progress");
409 yield* Deferred.succeed(harness.cleanupReleased, undefined);
410 yield* Fiber.join(stopping);
411 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
412 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
413 assert.isFalse(yield* harness.controller.isChangingCredentials!);
414 assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId);
415 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
416);
417
418it.effect("timeout interrupts and drains a blocked response before publishing failure", () =>
419 Effect.gen(function* () {
420 const harness = yield* makeBlockingResponseHarness;
421 yield* TestClock.adjust(300_001);
422 yield* Deferred.await(harness.cleanupStarted);
423 assert.isTrue(yield* harness.controller.isChangingCredentials!);
424 const duringCleanup = yield* harness.controller
425 .subscribe("owner")
426 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
427 assert.notEqual(duringCleanup.phase, "failed");
428 yield* Deferred.succeed(harness.cleanupReleased, undefined);
429 const failed = yield* harness.controller.subscribe("owner").pipe(
430 Stream.filter((state) => state.phase === "failed"),
431 Stream.runHead,
432 Effect.map(Option.getOrThrow),
433 );
434 assert.include(failed.message ?? "", "expired");
435 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
436 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
437 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
438);
439
440it.effect(
441 "successful authentication drains a still-running response before admitting provider access",
442 () =>
443 Effect.gen(function* () {
444 const harness = yield* makeBlockingResponseHarness;
445 yield* Deferred.succeed(harness.authenticationFinished, undefined);
446 yield* Deferred.await(harness.cleanupStarted);
447 const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void));
448 assert.include(denied.detail, "changing");
449 yield* Deferred.succeed(harness.cleanupReleased, undefined);
450 const succeeded = yield* harness.controller.subscribe("owner").pipe(
451 Stream.filter((state) => state.phase === "succeeded"),
452 Stream.runHead,
453 Effect.map(Option.getOrThrow),
454 );
455 assert.equal(succeeded.phase, "succeeded");
456 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
457 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
458 yield* harness.controller.withAccess!(Effect.void);
459 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
460);
461
462it.effect("closing the controller scope interrupts and drains its adapter response", () =>
463 Effect.gen(function* () {
464 const scope = yield* Scope.make();
465 const harness = yield* makeBlockingResponseHarness.pipe(
466 Effect.provideService(Scope.Scope, scope),
467 );
468 const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild);
469 yield* Deferred.await(harness.cleanupStarted);
470 assert.isUndefined(closing.pollUnsafe());
471 yield* Deferred.succeed(harness.cleanupReleased, undefined);
472 yield* Fiber.join(closing);
473 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
474 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
475 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
476);
477
478it.effect("a failed method refresh keeps the last discovered methods", () =>
479 Effect.gen(function* () {
480 let fail = false;
481 const controller = yield* ProviderAuthFlow.make({
482 instanceId,
483 credentialBinding: { owner: "provider", key: "shared-agent" },
484 methods: Effect.suspend(() =>
485 fail
486 ? Effect.fail(
487 new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }),
488 )
489 : Effect.succeed([method]),
490 ),
491 authenticate: () => Effect.void,
492 logout: Effect.void,
493 });
494 yield* controller.refreshMethods!;
495 fail = true;
496 yield* controller.refreshMethods!;
497 const state = yield* controller
498 .subscribe("owner")
499 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
500 assert.deepEqual(state.methods, [method]);
501 assert.strictEqual(state.message, "interrupted");
502 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
503);
504
505it.effect("rebuilding a controller leaves sessions it admitted to their owners", () =>
506 Effect.gen(function* () {
507 const scope = yield* Scope.make();
508 const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope));
509 let closed = false;
510 yield* controller.withAccess!(
511 Effect.addFinalizer(() =>
512 Effect.sync(() => {
513 closed = true;
514 }),
515 ),
516 );
517 yield* Scope.close(scope, Exit.void);
518 assert.isFalse(closed);
519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
520);
521
522it.effect("profile import stops owned sessions and gates access until credentials are saved", () =>
523 Effect.gen(function* () {
524 const controller = yield* ProviderAuthFlow.make({
525 instanceId,
526 credentialBinding: { owner: "t3", key: "handoff-binding" },
527 methods: Effect.succeed([method]),
528 authenticate: () => Effect.void,
529 logout: Effect.void,
530 });
531 let closed = false;
532 yield* controller.withAccess!(
533 Effect.addFinalizer(() =>
534 Effect.sync(() => {
535 closed = true;
536 }),
537 ),
538 );
539 const writing = yield* Deferred.make<void>();
540 const release = yield* Deferred.make<void>();
541 const imported = yield* controller.adoptCredentials!(
542 Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
543 Effect.sync(() => {
544 assert.isTrue(closed);
545 }),
546 ).pipe(Effect.forkChild);
547 yield* Deferred.await(writing);
548 const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe(
549 Effect.result,
550 );
551 assert.strictEqual(denied._tag, "Failure");
552 yield* Deferred.succeed(release, undefined);
553 assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded");
554 assert.strictEqual(
555 yield* controller.withAccess!(Effect.succeed("new credential process")),
556 "new credential process",
557 );
558 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
559);
apps/server/src/provider/ProviderAuthFlow.ts deleted-527
......@@ -1,527 +0,0 @@
1import {
2 ProviderSetupError,
3 type ProviderAuthInteraction,
4 type ProviderAuthMethod,
5 type ProviderAuthResponse,
6 type ProviderAuthState,
7 type ProviderInstanceId,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Clock from "effect/Clock";
13import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Deferred from "effect/Deferred";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as Scope from "effect/Scope";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";
23
24import type * as ProviderAuthService from "./ProviderAuthService.ts";
25
26export interface ProviderAuthFlowContext {
27 readonly flowId: string;
28 /** The lifetime timeout interrupts authenticate before publishing its expired result. */
29 readonly expiresAt: number;
30 readonly returnUrl?: string;
31 readonly callbackMode?: "server" | "client";
32 readonly setInteraction: (
33 interaction: ProviderAuthInteraction,
34 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,
35 complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>,
36 ) => Effect.Effect<void>;
37 readonly verifying: Effect.Effect<void>;
38}
39
40const isSetupError = Schema.is(ProviderSetupError);
41
42const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => {
43 const error = Cause.findErrorOption(cause);
44 return Option.isSome(error) && isSetupError(error.value)
45 ? error.value.detail
46 : "Sign-in failed. Start again.";
47};
48
49interface Flow {
50 readonly id: string;
51 readonly owner: string;
52 readonly expiresAt: number;
53 complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined;
54 fiber?: Fiber.Fiber<void>;
55 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;
56 respond:
57 | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>)
58 | undefined;
59}
60
61/** Adapters do login and credential handling; this owns client consent and flow lifetime. */
62export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
63 readonly instanceId: ProviderInstanceId;
64 readonly credentialBinding: NonNullable<
65 ProviderAuthService.ProviderAuthController["credentialBinding"]
66 >;
67 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;
68 readonly defaultMethodId?: string;
69 /** Stored account profiles can change the advertised methods after auth/logout. */
70 readonly refreshMethodsAfterAuth?: boolean;
71 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */
72 readonly authenticate: (
73 methodId: string,
74 context: ProviderAuthFlowContext,
75 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;
76 readonly logout: Effect.Effect<void | string, ProviderSetupError>;
77 readonly timeoutMs?: number;
78}) {
79 const scope = yield* Scope.Scope;
80 const crypto = yield* Crypto.Crypto;
81 const lock = yield* Semaphore.make(1);
82 const timeoutMs = options.timeoutMs ?? 300_000;
83 const empty: ProviderAuthState = {
84 instanceId: options.instanceId,
85 phase: "idle",
86 flowId: null,
87 authorizationUrl: null,
88 expiresAt: null,
89 message: null,
90 interaction: null,
91 credentialOwner: options.credentialBinding.owner,
92 };
93 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty });
94 let active: Flow | undefined;
95 let operation: "idle" | "auth" | "stopping" | "closed" = "idle";
96 const sessions = new Set<Scope.Closeable>();
97 const stopOwnedSessions = Effect.suspend(() =>
98 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
99 discard: true,
100 concurrency: "unbounded",
101 }),
102 );
103 const publish = (flow: Flow, patch: Partial<ProviderAuthState>) =>
104 Effect.suspend(() =>
105 active === flow
106 ? SubscriptionRef.update(snapshot, (current) => ({
107 owner: flow.owner,
108 state: { ...current.state, ...patch },
109 }))
110 : Effect.void,
111 );
112
113 const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) {
114 if (
115 operation !== "auth" ||
116 !active ||
117 active.owner !== owner ||
118 active.id !== id ||
119 (yield* Clock.currentTimeMillis) >= active.expiresAt
120 ) {
121 return yield* new ProviderSetupError({
122 instanceId: options.instanceId,
123 operation: "respond",
124 detail: "This sign-in is no longer active in this client.",
125 });
126 }
127 return active;
128 });
129
130 // Method discovery does not start a sign-in flow.
131 const refreshMethods = options.methods.pipe(
132 Effect.flatMap((methods) =>
133 SubscriptionRef.update(snapshot, (current) => ({
134 ...current,
135 state: { ...current.state, methods },
136 })),
137 ),
138 // Keep the last known methods when a refresh fails or is interrupted.
139 Effect.catch((error) =>
140 SubscriptionRef.update(snapshot, (current) => ({
141 ...current,
142 state: { ...current.state, methods: current.state.methods ?? [], message: error.detail },
143 })),
144 ),
145 );
146 yield* refreshMethods.pipe(Effect.forkIn(scope));
147
148 const controller: ProviderAuthService.ProviderAuthController = {
149 credentialBinding: options.credentialBinding,
150 adoptCredentials: (update, stopSessions) =>
151 Effect.gen(function* () {
152 yield* lock.withPermit(
153 Effect.gen(function* () {
154 if (operation !== "idle")
155 return yield* new ProviderSetupError({
156 instanceId: options.instanceId,
157 operation: "import",
158 detail: "Finish or cancel the existing sign-in first.",
159 });
160 operation = "stopping";
161 }),
162 );
163 const result = yield* Effect.gen(function* () {
164 yield* stopOwnedSessions;
165 yield* stopSessions;
166 yield* update;
167 yield* refreshMethods;
168 }).pipe(Effect.exit);
169 const state: ProviderAuthState = {
170 ...empty,
171 methods: snapshot.value.state.methods ?? [],
172 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
173 message: Exit.isSuccess(result) ? null : failureMessage(result.cause),
174 };
175 yield* lock.withPermit(
176 Effect.gen(function* () {
177 yield* SubscriptionRef.set(snapshot, { owner: null, state });
178 operation = "idle";
179 }),
180 );
181 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
182 return state;
183 }).pipe(Effect.uninterruptible),
184 refreshMethods,
185 invalidate: lock.withPermit(
186 Effect.gen(function* () {
187 if (operation !== "idle") return;
188 yield* stopOwnedSessions;
189 yield* SubscriptionRef.set(snapshot, {
190 owner: null,
191 state: {
192 ...empty,
193 methods: snapshot.value.state.methods ?? [],
194 message: "This provider's shared sign-in changed.",
195 },
196 });
197 }),
198 ),
199 isChangingCredentials: Effect.sync(() => operation !== "idle"),
200 withAccess: (task) =>
201 Effect.uninterruptibleMask((restore) =>
202 Effect.gen(function* () {
203 const parent = yield* Scope.Scope;
204 const child = yield* lock.withPermit(
205 Effect.gen(function* () {
206 if (operation !== "idle")
207 return yield* new ProviderSetupError({
208 instanceId: options.instanceId,
209 operation: "session",
210 detail: "Provider sign-in is changing. Try again after it finishes.",
211 });
212 const child = yield* Scope.make();
213 sessions.add(child);
214 yield* Scope.addFinalizer(
215 child,
216 Effect.sync(() => {
217 sessions.delete(child);
218 }),
219 );
220 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
221 return child;
222 }),
223 );
224 const fiber = yield* restore(task).pipe(
225 Effect.provideService(Scope.Scope, child),
226 Effect.forkIn(child),
227 );
228 return yield* restore(Fiber.await(fiber)).pipe(
229 Effect.flatMap((result) => result),
230 Effect.onExit((result) =>
231 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
232 ),
233 );
234 }),
235 ),
236 start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) =>
237 lock.withPermit(
238 Effect.gen(function* () {
239 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;
240 if (operation !== "idle")
241 return yield* new ProviderSetupError({
242 instanceId: options.instanceId,
243 operation: "start",
244 detail: "Provider setup is already in progress.",
245 });
246 const id = yield* crypto.randomUUIDv4.pipe(
247 Effect.mapError(
248 () =>
249 new ProviderSetupError({
250 instanceId: options.instanceId,
251 operation: "start",
252 detail: "Could not start sign-in. Try again.",
253 }),
254 ),
255 );
256 const flow: Flow = {
257 id,
258 owner,
259 expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs,
260 respond: undefined,
261 };
262 active = flow;
263 operation = "auth";
264 const state: ProviderAuthState = {
265 ...empty,
266 methods: snapshot.value.state.methods ?? [],
267 phase: "starting",
268 flowId: id,
269 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)),
270 message: "Starting sign-in.",
271 };
272 yield* SubscriptionRef.set(snapshot, { owner, state });
273 flow.fiber = yield* Effect.gen(function* () {
274 const methods = yield* options.methods;
275 yield* publish(flow, { methods });
276 const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id;
277 if (!methodId || !methods.some((method) => method.id === methodId))
278 return yield* new ProviderSetupError({
279 instanceId: options.instanceId,
280 operation: "start",
281 detail: "The provider did not advertise this sign-in method.",
282 });
283 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
284 yield* options.authenticate(methodId, {
285 flowId: id,
286 expiresAt: flow.expiresAt,
287 ...(returnUrl ? { returnUrl } : {}),
288 ...(callbackMode ? { callbackMode } : {}),
289 setInteraction: (interaction, respond, complete) =>
290 Effect.gen(function* () {
291 if (active !== flow) return;
292 flow.respond = respond;
293 flow.complete = complete;
294 yield* publish(flow, {
295 phase: "waiting",
296 interaction,
297 authorizationUrl:
298 interaction.type === "browser" || interaction.type === "deviceCode"
299 ? interaction.url
300 : null,
301 message: "Complete sign-in to continue.",
302 });
303 }),
304 verifying: Effect.gen(function* () {
305 flow.respond = undefined;
306 flow.complete = undefined;
307 yield* publish(flow, {
308 phase: "verifying",
309 interaction: null,
310 authorizationUrl: null,
311 message: "Checking provider sign-in.",
312 });
313 }),
314 });
315 }).pipe(
316 Effect.scoped,
317 Effect.timeoutOrElse({
318 duration: timeoutMs,
319 orElse: () =>
320 Effect.fail(
321 new ProviderSetupError({
322 instanceId: options.instanceId,
323 operation: "start",
324 detail: "Sign-in expired. Start again.",
325 }),
326 ),
327 }),
328 Effect.exit,
329 Effect.flatMap((result) =>
330 Effect.gen(function* () {
331 const finishing = yield* lock.withPermit(
332 Effect.sync(() => {
333 if (active !== flow) return false;
334 operation = "stopping";
335 return true;
336 }),
337 );
338 if (!finishing) return;
339 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
340 yield* lock.withPermit(
341 Effect.gen(function* () {
342 if (active !== flow) return;
343 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
344 yield* publish(flow, {
345 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
346 interaction: null,
347 authorizationUrl: null,
348 expiresAt: null,
349 message: Exit.isSuccess(result)
350 ? "Sign-in complete."
351 : failureMessage(result.cause),
352 });
353 active = undefined;
354 operation = "idle";
355 }),
356 );
357 }),
358 ),
359 Effect.interruptible,
360 Effect.forkIn(scope),
361 );
362 return state;
363 }).pipe(Effect.uninterruptible),
364 ),
365 respond: (owner, input) =>
366 Effect.uninterruptibleMask((restore) =>
367 Effect.gen(function* () {
368 const { flow, fiber, ready } = yield* lock.withPermit(
369 Effect.gen(function* () {
370 const flow = yield* requireFlow(owner, input.flowId);
371 const interaction = snapshot.value.state.interaction;
372 if (
373 !interaction ||
374 interaction.id !== input.interactionId ||
375 interaction.type !== input.response.type ||
376 !flow.respond
377 )
378 return yield* new ProviderSetupError({
379 instanceId: options.instanceId,
380 operation: "respond",
381 detail: "This sign-in interaction is no longer available.",
382 });
383 if (flow.responseFiber)
384 return yield* new ProviderSetupError({
385 instanceId: options.instanceId,
386 operation: "respond",
387 detail: "A sign-in response is already in progress.",
388 });
389 const ready = yield* Deferred.make<void>();
390 const callback = flow.respond;
391 const fiber = yield* Deferred.await(ready).pipe(
392 Effect.andThen(Effect.suspend(() => callback(input.response))),
393 Effect.interruptible,
394 Effect.forkIn(scope),
395 );
396 flow.responseFiber = fiber;
397 return { flow, fiber, ready };
398 }),
399 );
400 yield* Deferred.succeed(ready, undefined);
401 yield* restore(Fiber.join(fiber)).pipe(
402 Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)),
403 Effect.ensuring(
404 lock.withPermit(
405 Effect.sync(() => {
406 if (flow.responseFiber === fiber) delete flow.responseFiber;
407 }),
408 ),
409 ),
410 );
411 return snapshot.value.state;
412 }),
413 ),
414 complete: (owner, input) =>
415 lock.withPermit(
416 Effect.gen(function* () {
417 const flow = yield* requireFlow(owner, input.flowId);
418 const interaction = snapshot.value.state.interaction;
419 if (
420 snapshot.value.state.phase !== "waiting" ||
421 interaction?.type !== "browser" ||
422 !interaction.acceptsCallback ||
423 !flow.complete
424 )
425 return yield* new ProviderSetupError({
426 instanceId: options.instanceId,
427 operation: "complete",
428 detail: "This sign-in does not accept a redirect URL.",
429 });
430 yield* flow.complete(input.callbackUrl);
431 return snapshot.value.state;
432 }),
433 ),
434 cancel: (owner, id) =>
435 Effect.gen(function* () {
436 const flow = yield* lock.withPermit(
437 Effect.gen(function* () {
438 const flow = yield* requireFlow(owner, id);
439 yield* publish(flow, {
440 phase: "cancelled",
441 interaction: null,
442 authorizationUrl: null,
443 expiresAt: null,
444 message: "Sign-in cancelled.",
445 });
446 active = undefined;
447 operation = "stopping";
448 return flow;
449 }),
450 );
451 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
452 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
453 operation = "idle";
454 return snapshot.value.state;
455 }).pipe(Effect.uninterruptible),
456 logout: (stopSessions) =>
457 Effect.gen(function* () {
458 const flow = yield* lock.withPermit(
459 Effect.gen(function* () {
460 if (operation !== "idle" && operation !== "auth")
461 return yield* new ProviderSetupError({
462 instanceId: options.instanceId,
463 operation: "logout",
464 detail: "Provider setup is already stopping.",
465 });
466 operation = "stopping";
467 const flow = active;
468 active = undefined;
469 return flow;
470 }),
471 );
472 const result = yield* Effect.gen(function* () {
473 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
474 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
475 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
476 const message = yield* options.logout;
477 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
478 return message;
479 }).pipe(Effect.exit);
480 const state: ProviderAuthState = {
481 ...empty,
482 methods: snapshot.value.state.methods ?? [],
483 phase: Exit.isSuccess(result) ? "idle" : "failed",
484 message: Exit.isSuccess(result)
485 ? (result.value ?? "Signed out.")
486 : "Could not sign out. Try again.",
487 };
488 yield* lock.withPermit(
489 Effect.gen(function* () {
490 yield* SubscriptionRef.set(snapshot, { owner: null, state });
491 operation = "idle";
492 }),
493 );
494 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
495 return state;
496 }).pipe(Effect.uninterruptible),
497 subscribe: (owner) =>
498 SubscriptionRef.changes(snapshot).pipe(
499 Stream.map((current) =>
500 current.owner === null || current.owner === owner
501 ? current.state
502 : {
503 ...current.state,
504 flowId: null,
505 authorizationUrl: null,
506 interaction: null,
507 expiresAt: null,
508 message: active
509 ? "Sign-in is in progress in another client."
510 : current.state.message,
511 },
512 ),
513 ),
514 };
515 yield* Effect.addFinalizer(() =>
516 Effect.gen(function* () {
517 operation = "closed";
518 const flow = active;
519 active = undefined;
520 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
521 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
522 // Settings edits rebuild instances; admitted sessions (including those of
523 // peers sharing this binding) end with their own scopes, not this one.
524 }),
525 );
526 return controller;
527});
apps/server/src/provider/ProviderHostLive.ts+1
......@@ -35,6 +35,7 @@ export const layer = Layer.effect(
3535 },
3636 settings: {
3737 get: serverSettings.getSettings,
38 withSnapshot: serverSettings.withSettingsSnapshot,
3839 changes: serverSettings.streamChanges,
3940 subscribe: serverSettings.subscribeChanges,
4041 },
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+2-3
......@@ -38,8 +38,7 @@ import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts";
3838import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts";
3939import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
4040import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
41import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts";
42import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts";
41import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
4342import * as ProviderHostLive from "./ProviderHostLive.ts";
4443import type { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
4544import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
......@@ -144,7 +143,7 @@ export const layer: Layer.Layer<
144143 configMap: initialConfigMap,
145144 }).pipe(
146145 Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer),
147 Layer.provide(AcpRegistryCatalog.layer),
146 Layer.provide(AcpRegistrySupport.layerFromHost),
148147 Layer.provide(ProviderHostLive.layer),
149148 );
150149
apps/server/src/provider/acp/AcpRegistryAuth.test.ts deleted-367
......@@ -1,367 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 AcpRegistrySettings,
5 ProviderInstanceId,
6 type ProviderAuthState,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import type * as AcpSchema from "effect-acp/compat";
14import { AcpRequestError } from "effect-acp/errors";
15
16import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
17import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts";
18import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
19import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
20
21const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
22const instanceId = ProviderInstanceId.make("acp-auth-test");
23const browserMethod = { id: "browser", name: "Browser", type: "agent" as const };
24const terminalMethod = {
25 id: "terminal",
26 name: "Terminal",
27 type: "terminal" as const,
28 args: ["login"],
29 env: { LOGIN: "yes", OVERRIDE: "method" },
30};
31const resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent = {
32 agent: {
33 id: "test-agent",
34 name: "Test",
35 version: "1.0.0",
36 description: "Test",
37 distribution: {},
38 },
39 distribution: "binary",
40 spawn: {
41 command: "/managed/agent",
42 args: ["--acp"],
43 env: { OVERRIDE: "spawn", AGENT_HOME: "/custom/home" },
44 },
45};
46const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = {
47 inspect: () =>
48 Effect.succeed({
49 status: "ready",
50 agentId: "test-agent",
51 version: "1.0.0",
52 distribution: "binary",
53 }),
54 resolve: () => Effect.succeed(resolved),
55 search: () => Effect.die("unused"),
56 prepare: () => Effect.die("unused"),
57 uninstallManagedBinary: () => Effect.die("unused"),
58};
59
60const makeHarness = (
61 method: AcpSchema.AuthMethod,
62 failVerification = false,
63 settings = decodeSettings({ agentId: "test-agent" }),
64 providerInstanceId = instanceId,
65) =>
66 Effect.gen(function* () {
67 const verify = yield* Deferred.make<void>();
68 const changed: boolean[] = [];
69 const closed: number[] = [];
70 const authenticated: string[] = [];
71 const started: number[] = [];
72 let runtimes = 0;
73 let version = "1.0.0";
74 let terminalSpawn: PtyAdapter.PtySpawnInput | undefined;
75 let data: ((data: string) => void) | undefined;
76 let exit: ((event: PtyAdapter.PtyExitEvent) => void) | undefined;
77 let killed = false;
78 let closedBeforeTerminal = false;
79 const written: string[] = [];
80 const sizes: number[][] = [];
81 const controller = yield* makeAcpRegistryAuth({
82 instanceId: providerInstanceId,
83 settings,
84 cwd: "/workspace",
85 environment: { PATH: "/tools", OVERRIDE: "base" },
86 onChanged: (value) =>
87 Effect.sync(() => {
88 changed.push(value);
89 }),
90 makeRuntime: () =>
91 Effect.gen(function* () {
92 const id = ++runtimes;
93 yield* Effect.addFinalizer(() =>
94 Effect.sync(() => {
95 closed.push(id);
96 }),
97 );
98 const initialized: AcpSchema.InitializeResponse = {
99 protocolVersion: 2,
100 agentCapabilities: {},
101 authMethods: [{ ...method, name: `${method.name} ${version}` }],
102 };
103 let elicitation:
104 | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0]
105 | undefined;
106 return {
107 initialize: () => Effect.succeed(initialized),
108 handleElicitation: (handler) =>
109 Effect.sync(() => {
110 elicitation = handler;
111 }),
112 authenticate: (methodId) =>
113 Effect.gen(function* () {
114 authenticated.push(methodId);
115 if (elicitation)
116 yield* elicitation(
117 {
118 mode: "url",
119 url: "https://example.com/login",
120 elicitationId: "consent",
121 requestId: "login",
122 message: "Sign in",
123 },
124 { requestId: "login", method: "elicitation/create" },
125 );
126 }),
127 start: () =>
128 Effect.gen(function* () {
129 started.push(id);
130 yield* Deferred.await(verify);
131 if (failVerification)
132 return yield* new AcpRequestError({
133 method: "session/new",
134 code: -32000,
135 errorMessage: "Authentication required",
136 });
137 return {
138 sessionId: "verified",
139 initializeResult: initialized,
140 sessionSetupResult: { sessionId: "verified" },
141 modelConfigId: undefined,
142 };
143 }),
144 logout: Effect.succeed({}),
145 };
146 }),
147 }).pipe(
148 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, {
149 ...catalog,
150 inspect: () =>
151 Effect.sync(() => ({
152 status: "ready" as const,
153 agentId: "test-agent",
154 version,
155 distribution: "binary" as const,
156 })),
157 }),
158 Effect.provideService(PtyAdapter.PtyAdapter, {
159 spawn: (input) =>
160 Effect.sync(() => {
161 terminalSpawn = input;
162 closedBeforeTerminal = closed.includes(runtimes);
163 return {
164 pid: 123,
165 write: (input) => {
166 written.push(input);
167 },
168 resize: (cols, rows) => {
169 sizes.push([cols, rows]);
170 },
171 kill: () => {
172 killed = true;
173 },
174 onData: (callback) => {
175 data = callback;
176 return () => {
177 data = undefined;
178 };
179 },
180 onExit: (callback) => {
181 exit = callback;
182 return () => {
183 exit = undefined;
184 };
185 },
186 };
187 }),
188 }),
189 );
190 const state = (predicate: (state: ProviderAuthState) => boolean) =>
191 controller
192 .subscribe("owner")
193 .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow));
194 const phase = (phase: ProviderAuthState["phase"]) => state((value) => value.phase === phase);
195 yield* state((value) => (value.methods?.length ?? 0) > 0);
196 return {
197 controller,
198 verify,
199 changed,
200 closed,
201 authenticated,
202 started,
203 phase,
204 state,
205 terminalSpawn: () => terminalSpawn,
206 closedBeforeTerminal: () => closedBeforeTerminal,
207 data: (value: string) => data?.(value),
208 exit: (exitCode: number) => exit?.({ exitCode, signal: null }),
209 killed: () => killed,
210 written,
211 sizes,
212 runtimes: () => runtimes,
213 setVersion: (value: string) => {
214 version = value;
215 },
216 };
217 }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog));
218
219it.effect("keeps local credentials separate from a matching registry agent ID", () =>
220 Effect.gen(function* () {
221 const registry = yield* makeHarness(browserMethod);
222 const local = yield* makeHarness(
223 browserMethod,
224 false,
225 decodeSettings({ source: "local", commandPath: "/different/agent" }),
226 ProviderInstanceId.make("test-agent"),
227 );
228 assert.deepEqual(registry.controller.credentialBinding, {
229 owner: "provider",
230 key: "acp:test-agent",
231 });
232 assert.deepEqual(local.controller.credentialBinding, {
233 owner: "provider",
234 key: "acp:local:test-agent",
235 });
236 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
237);
238
239it.effect(
240 "discovers methods without signing in, then waits for browser consent and session verification",
241 () =>
242 Effect.gen(function* () {
243 const h = yield* makeHarness(browserMethod);
244 assert.deepEqual(h.authenticated, []);
245 assert.deepEqual(h.started, []);
246 assert.deepEqual(h.closed, [1]);
247 const flow = yield* h.controller.start("owner", Effect.void, "browser");
248 const waiting = yield* h.phase("waiting");
249 assert.deepEqual(waiting.interaction, {
250 type: "browser",
251 id: "consent",
252 url: "https://example.com/login",
253 requiresConsent: true,
254 });
255 yield* h.controller.respond!("owner", {
256 instanceId,
257 flowId: flow.flowId!,
258 interactionId: "consent",
259 response: { type: "browser", action: "accept" },
260 });
261 yield* h.phase("verifying");
262 assert.deepEqual(h.changed, []);
263 yield* Deferred.succeed(h.verify, undefined);
264 yield* h.phase("succeeded");
265 assert.deepEqual(h.authenticated, ["browser"]);
266 assert.deepEqual(h.changed, [true]);
267 assert.deepEqual(h.closed, [1, 2]);
268 yield* h.controller.logout(Effect.void);
269 assert.deepEqual(h.changed, [true, false]);
270 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
271);
272
273it.effect(
274 "runs terminal auth directly with the resolved environment and reconnects to verify",
275 () =>
276 Effect.gen(function* () {
277 const h = yield* makeHarness(terminalMethod);
278 const flow = yield* h.controller.start("owner", Effect.void, "terminal");
279 yield* h.phase("waiting");
280 assert.isTrue(h.closedBeforeTerminal());
281 assert.deepEqual(h.terminalSpawn(), {
282 shell: "/managed/agent",
283 args: ["--acp", "login"],
284 cwd: "/workspace",
285 cols: 80,
286 rows: 24,
287 env: { PATH: "/tools", OVERRIDE: "method", AGENT_HOME: "/custom/home", LOGIN: "yes" },
288 });
289 h.data("Enter code: ");
290 const output = yield* h.state(
291 (value) =>
292 value.interaction?.type === "terminal" && value.interaction.output === "Enter code: ",
293 );
294 assert.strictEqual(
295 output.interaction?.type === "terminal" && output.interaction.outputOffset,
296 12,
297 );
298 yield* h.controller.respond!("owner", {
299 instanceId,
300 flowId: flow.flowId!,
301 interactionId: "terminal",
302 response: { type: "terminal", data: "123\r", size: { cols: 100, rows: 30 } },
303 });
304 assert.deepEqual(h.written, ["123\r"]);
305 assert.deepEqual(h.sizes, [[100, 30]]);
306 h.exit(0);
307 yield* h.phase("verifying");
308 assert.isTrue(h.killed());
309 assert.deepEqual(h.authenticated, []);
310 yield* Deferred.succeed(h.verify, undefined);
311 yield* h.phase("succeeded");
312 assert.deepEqual(h.started, [3]);
313 assert.deepEqual(h.changed, [true]);
314 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
315);
316
317it.effect("cancels the login PTY and rejects further terminal input", () =>
318 Effect.gen(function* () {
319 const h = yield* makeHarness(terminalMethod);
320 const flow = yield* h.controller.start("owner");
321 yield* h.phase("waiting");
322 yield* h.controller.cancel("owner", flow.flowId!);
323 assert.isTrue(h.killed());
324 assert.deepEqual(h.started, []);
325 assert.deepEqual(h.changed, []);
326 assert.strictEqual(
327 (yield* h.controller.respond!("owner", {
328 instanceId,
329 flowId: flow.flowId!,
330 interactionId: "terminal",
331 response: { type: "terminal", data: "late\r" },
332 }).pipe(Effect.result))._tag,
333 "Failure",
334 );
335 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
336);
337
338it.effect("does not report authentication when post-login verification fails", () =>
339 Effect.gen(function* () {
340 const h = yield* makeHarness(terminalMethod, true);
341 yield* h.controller.start("owner");
342 yield* h.phase("waiting");
343 h.exit(0);
344 yield* h.phase("verifying");
345 yield* Deferred.succeed(h.verify, undefined);
346 yield* h.phase("failed");
347 assert.deepEqual(h.changed, []);
348 assert.isTrue(h.killed());
349 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
350);
351
352it.effect("refreshes advertised methods after a registry version change without logging in", () =>
353 Effect.gen(function* () {
354 const h = yield* makeHarness(browserMethod);
355 const count = h.runtimes();
356 yield* h.controller.refreshMethods!;
357 assert.strictEqual(h.runtimes(), count);
358 h.setVersion("2.0.0");
359 yield* h.controller.refreshMethods!;
360 const state = yield* h.state((value) => value.methods?.[0]?.name === "Browser 2.0.0");
361 assert.strictEqual(state.phase, "idle");
362 assert.strictEqual(h.runtimes(), count + 1);
363 assert.deepEqual(h.authenticated, []);
364 assert.deepEqual(h.started, []);
365 assert.deepEqual(h.closed, [1, 2]);
366 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
367);
apps/server/src/provider/acp/AcpRegistryAuth.ts deleted-370
......@@ -1,370 +0,0 @@
1import {
2 ProviderSetupError,
3 type AcpRegistrySettings,
4 type ProviderAuthMethod,
5 type ProviderInstanceId,
6} from "@t3tools/contracts";
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 { ChildProcessSpawner } from "effect/process";
16import type * as AcpSchema from "effect-acp/compat";
17
18import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
19import * as ProviderAuthFlow from "../ProviderAuthFlow.ts";
20import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.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});
apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts deleted-99
......@@ -1,99 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Schema from "effect/Schema";
7
8import { makeAcpRegistryAuthenticationState } from "./AcpRegistryAuthenticationState.ts";
9
10const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" });
11
12it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => {
13 it.effect("restores explicit sign-in across driver recreation and server restart", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const cacheDir = yield* fs.makeTempDirectoryScoped();
17 const input = {
18 cacheDir,
19 instanceId: ProviderInstanceId.make("acp_devin"),
20 settings,
21 environment: [],
22 processEnvironment: { HOME: "/home/test" },
23 };
24 const first = yield* makeAcpRegistryAuthenticationState(input);
25 assert.isFalse(yield* first.get);
26 yield* first.set(true);
27 const recreated = yield* makeAcpRegistryAuthenticationState({
28 ...input,
29 settings: { ...settings, customModels: ["new-model"] },
30 });
31 assert.isTrue(yield* recreated.get);
32 // Both logout and an explicit authentication failure revoke confirmation.
33 yield* recreated.set(false);
34 const restarted = yield* makeAcpRegistryAuthenticationState(input);
35 assert.isFalse(yield* restarted.get);
36 }),
37 );
38
39 it.effect(
40 "does not carry confirmation to a different agent, method, executable, or credential environment",
41 () =>
42 Effect.gen(function* () {
43 const fs = yield* FileSystem.FileSystem;
44 const cacheDir = yield* fs.makeTempDirectoryScoped();
45 const input = {
46 cacheDir,
47 instanceId: ProviderInstanceId.make("acp_devin"),
48 settings,
49 environment: [{ name: "DEVIN_TOKEN", value: "test-only-token", sensitive: true }],
50 processEnvironment: { HOME: "/home/test" },
51 };
52 for (const change of [
53 { settings: { ...settings, agentId: "other-agent" } },
54 { settings: { ...settings, authMethodId: "enterprise" } },
55 { settings: { ...settings, commandPath: "/other/devin" } },
56 { environment: [{ name: "DEVIN_TOKEN", value: "different-token", sensitive: true }] },
57 { processEnvironment: { HOME: "/home/other" } },
58 ]) {
59 const signedIn = yield* makeAcpRegistryAuthenticationState(input);
60 yield* signedIn.set(true);
61 const changed = yield* makeAcpRegistryAuthenticationState({ ...input, ...change });
62 assert.isFalse(yield* changed.get);
63 const restoredConfig = yield* makeAcpRegistryAuthenticationState(input);
64 assert.isFalse(yield* restoredConfig.get);
65 }
66 for (const name of yield* fs.readDirectory(cacheDir)) {
67 const contents = yield* fs.readFileString(`${cacheDir}/${name}`);
68 assert.isFalse(contents.includes("test-only-token"));
69 assert.isFalse(contents.includes("different-token"));
70 }
71 }),
72 );
73
74 it.effect("ignores damaged confirmation and keeps another instance independent", () =>
75 Effect.gen(function* () {
76 const fs = yield* FileSystem.FileSystem;
77 const cacheDir = yield* fs.makeTempDirectoryScoped();
78 const input = {
79 cacheDir,
80 instanceId: ProviderInstanceId.make("acp_devin"),
81 settings,
82 environment: [],
83 processEnvironment: {},
84 };
85 const first = yield* makeAcpRegistryAuthenticationState(input);
86 yield* first.set(true);
87 const other = yield* makeAcpRegistryAuthenticationState({
88 ...input,
89 instanceId: ProviderInstanceId.make("acp_other"),
90 });
91 assert.isFalse(yield* other.get);
92 for (const name of yield* fs.readDirectory(cacheDir)) {
93 yield* fs.writeFileString(`${cacheDir}/${name}`, "invalid");
94 }
95 const restored = yield* makeAcpRegistryAuthenticationState(input);
96 assert.isFalse(yield* restored.get);
97 }),
98 );
99});
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts deleted-85
......@@ -1,85 +0,0 @@
1import type {
2 AcpRegistrySettings,
3 ProviderInstanceEnvironment,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
12import * as Schema from "effect/Schema";
13import * as Semaphore from "effect/Semaphore";
14
15import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
16
17const decodeState = Schema.decodeUnknownEffect(
18 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
19);
20const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
21const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) {
22 const crypto = yield* Crypto.Crypto;
23 const json = yield* encodeJson(value).pipe(Effect.orDie);
24 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie);
25 return Hex.encode(digest);
26});
27
28/** Remember explicit sign-in success, never discovery success or the agent's credentials. */
29export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(
30 function* (input: {
31 readonly cacheDir: string;
32 readonly instanceId: ProviderInstanceId;
33 readonly settings: AcpRegistrySettings;
34 readonly environment: ProviderInstanceEnvironment;
35 readonly processEnvironment: NodeJS.ProcessEnv;
36 }) {
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`);
40 // Cosmetic settings and model discovery can rebuild the driver without
41 // changing the account. Credential overrides and profile paths cannot.
42 const binding = yield* hash({
43 ...(input.settings.source === "local"
44 ? { source: "local", commandArgs: input.settings.commandArgs }
45 : {}),
46 agentId: input.settings.agentId,
47 commandPath: input.settings.commandPath,
48 distribution: input.settings.distribution,
49 authMethodId: input.settings.authMethodId,
50 environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)),
51 profiles: [
52 "HOME",
53 "XDG_CONFIG_HOME",
54 "XDG_DATA_HOME",
55 "XDG_STATE_HOME",
56 "APPDATA",
57 "LOCALAPPDATA",
58 ].map((name) => input.processEnvironment[name] ?? null),
59 });
60 const saved = yield* fs.readFileString(filePath).pipe(
61 Effect.flatMap(decodeState),
62 Effect.orElseSucceed(() => undefined),
63 );
64 const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated);
65 const lock = yield* Semaphore.make(1);
66 const persist = (authenticated: boolean) =>
67 writeFileStringAtomically({
68 filePath,
69 contents: JSON.stringify({ binding, authenticated }),
70 }).pipe(
71 Effect.provideService(FileSystem.FileSystem, fs),
72 Effect.provideService(Path.Path, path),
73 Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")),
74 Effect.ignore,
75 );
76 if (saved && saved.binding !== binding) yield* persist(false);
77 return {
78 get: Ref.get(confirmed),
79 set: (authenticated: boolean) =>
80 lock.withPermit(
81 Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))),
82 ),
83 };
84 },
85);
apps/server/src/provider/acp/AcpRegistryProbe.test.ts deleted-605
......@@ -1,605 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";
13import * as EffectAcpErrors from "effect-acp/errors";
14
15import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
16import {
17 acpRegistryProbeFailure,
18 acpRegistryProbeResult,
19 listAcpRegistrySessions,
20 logoutAcpRegistry,
21 normalizeAcpRegistryAuthMethods,
22 normalizeAcpRegistryCommands,
23 probeAcpRegistryConfiguration,
24} from "./AcpRegistryProbe.ts";
25import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
26
27const instanceId = ProviderInstanceId.make("acpRegistry_codex");
28const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
29const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
30const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
31
32describe("ACP Registry probe", () => {
33 it("returns advertised auth methods and de-duplicated models", () => {
34 const result = acpRegistryProbeResult(instanceId, {
35 sessionId: "probe-session",
36 initializeResult: {
37 protocolVersion: 1,
38 agentCapabilities: {
39 loadSession: true,
40 auth: { logout: {} },
41 sessionCapabilities: { list: {}, resume: {} },
42 },
43 authMethods: [
44 { id: "chatgpt", name: "ChatGPT" },
45 {
46 id: "api-key",
47 name: "API key",
48 type: "env_var",
49 vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }],
50 },
51 { id: "login", name: "Terminal login", type: "terminal" },
52 ],
53 },
54 sessionSetupResult: {
55 sessionId: "probe-session",
56 configOptions: [
57 {
58 id: "model",
59 name: "Model",
60 category: "model",
61 type: "select",
62 currentValue: "gpt-5.4",
63 options: [
64 { value: "gpt-5.4", name: "GPT-5.4" },
65 { value: "gpt-5.4", name: "Duplicate" },
66 ],
67 },
68 ],
69 },
70 modelConfigId: "model",
71 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
72
73 expect(result).toEqual({
74 instanceId,
75 ready: true,
76 icon: null,
77 authMethods: [
78 { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" },
79 {
80 id: "api-key",
81 name: "API key",
82 description: null,
83 type: "env_var",
84 envVarNames: ["OPENAI_API_KEY"],
85 },
86 { id: "login", name: "Terminal login", description: null, type: "terminal" },
87 ],
88 models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }],
89 currentModelId: "gpt-5.4",
90 configOptions: [],
91 sessionManagement: {
92 canList: true,
93 canLoad: true,
94 canResume: true,
95 canLogout: true,
96 canDelete: false,
97 canConfigureProviders: false,
98 },
99 });
100 });
101
102 it("composes runnable terminal auth commands from the resolved spawn recipe", () => {
103 const methods = normalizeAcpRegistryAuthMethods(
104 [
105 {
106 id: "login",
107 name: "Terminal login",
108 type: "terminal",
109 args: ["auth", "log in"],
110 env: { FORCE_TTY: "1" },
111 },
112 ],
113 { command: "/opt/agents/devin", args: ["--acp"] },
114 );
115
116 expect(methods).toEqual([
117 {
118 id: "login",
119 name: "Terminal login",
120 description: null,
121 type: "terminal",
122 command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'",
123 },
124 ]);
125 });
126
127 it("omits unsafe truncated auth commands instead of publishing broken shell", () => {
128 const methods = normalizeAcpRegistryAuthMethods(
129 [
130 {
131 id: "login",
132 name: "Terminal login",
133 type: "terminal",
134 args: ["x".repeat(2_048)],
135 },
136 ],
137 { command: "/opt/agents/devin", args: ["--acp"] },
138 );
139
140 expect(methods[0]).not.toHaveProperty("command");
141 });
142
143 it("omits overlong opaque auth method and environment variable ids", () => {
144 const methods = normalizeAcpRegistryAuthMethods([
145 { id: "x".repeat(129), name: "Invalid" },
146 {
147 id: "api-key",
148 name: "API key",
149 type: "env_var",
150 vars: [
151 { name: "y".repeat(129), label: "Invalid" },
152 { name: "OPENAI_API_KEY", label: "OpenAI API key" },
153 ],
154 },
155 ]);
156
157 expect(methods).toEqual([
158 {
159 id: "api-key",
160 name: "API key",
161 description: null,
162 type: "env_var",
163 envVarNames: ["OPENAI_API_KEY"],
164 },
165 ]);
166 });
167
168 it("falls back to model-category configuration options", () => {
169 const result = acpRegistryProbeResult(instanceId, {
170 sessionId: "probe-session",
171 initializeResult: { protocolVersion: 1 },
172 sessionSetupResult: {
173 sessionId: "probe-session",
174 configOptions: [
175 {
176 id: "model",
177 name: "Model",
178 category: "model",
179 type: "select",
180 currentValue: "sonnet",
181 options: [
182 { value: "sonnet", name: "Sonnet", description: "Balanced" },
183 { value: "haiku", name: "Haiku" },
184 ],
185 },
186 ],
187 },
188 modelConfigId: "model",
189 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
190
191 expect(result.models).toEqual([
192 { id: "sonnet", name: "Sonnet", description: "Balanced" },
193 { id: "haiku", name: "Haiku", description: null },
194 ]);
195 expect(result.currentModelId).toBe("sonnet");
196 });
197
198 it("omits overlong opaque model ids instead of publishing mutated ids", () => {
199 const result = acpRegistryProbeResult(instanceId, {
200 sessionId: "probe-session",
201 initializeResult: { protocolVersion: 1 },
202 sessionSetupResult: {
203 sessionId: "probe-session",
204 configOptions: [
205 {
206 id: "model",
207 name: "Model",
208 category: "model",
209 type: "select",
210 currentValue: "x".repeat(129),
211 options: [
212 { value: "x".repeat(129), name: "Too long" },
213 { value: "valid", name: "Valid" },
214 ],
215 },
216 ],
217 },
218 modelConfigId: "model",
219 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
220
221 expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]);
222 expect(result.currentModelId).toBeNull();
223 });
224
225 it("preserves grouped model values and advertises reasoning choices", () => {
226 const codexModel = '["codex","gpt-6.1-sol"]';
227 const goModel = '["opencode-go","deepseek-v4.1-flash"]';
228 const result = acpRegistryProbeResult(instanceId, {
229 sessionId: "probe-session",
230 initializeResult: { protocolVersion: 1 },
231 sessionSetupResult: {
232 sessionId: "probe-session",
233 configOptions: [
234 {
235 id: "model",
236 name: "Model",
237 category: "model",
238 type: "select",
239 currentValue: goModel,
240 options: [
241 {
242 groupId: "codex",
243 name: "Codex",
244 options: [{ value: codexModel, name: "GPT-6.1 Sol" }],
245 },
246 {
247 groupId: "opencode-go",
248 name: "OpenCode Go",
249 options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }],
250 },
251 ],
252 },
253 {
254 id: "reasoning",
255 name: "Reasoning",
256 category: "thought_level",
257 type: "select",
258 currentValue: "high",
259 options: [
260 {
261 groupId: "effort",
262 name: "Effort",
263 options: [
264 { value: "medium", name: "Medium" },
265 { value: "high", name: "High" },
266 ],
267 },
268 ],
269 },
270 ],
271 },
272 modelConfigId: "model",
273 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
274
275 expect(result.models).toEqual([
276 { id: codexModel, name: "GPT-6.1 Sol", description: null },
277 { id: goModel, name: "DeepSeek V4.1 Flash", description: null },
278 ]);
279 expect(result.currentModelId).toBe(goModel);
280 expect(result.configOptions).toEqual([
281 {
282 id: "reasoning",
283 label: "Reasoning",
284 type: "select",
285 currentValue: "high",
286 options: [
287 { id: "medium", label: "Medium" },
288 { id: "high", label: "High" },
289 ],
290 },
291 ]);
292 });
293
294 it("omits a current model that falls outside the bounded model catalog", () => {
295 const result = acpRegistryProbeResult(instanceId, {
296 sessionId: "probe-session",
297 initializeResult: { protocolVersion: 1 },
298 sessionSetupResult: {
299 sessionId: "probe-session",
300 configOptions: [
301 {
302 id: "model",
303 name: "Model",
304 category: "model",
305 type: "select",
306 currentValue: "model-256",
307 options: Array.from({ length: 257 }, (_, index) => ({
308 value: `model-${index}`,
309 name: `Model ${index}`,
310 })),
311 },
312 ],
313 },
314 modelConfigId: "model",
315 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
316
317 expect(result.models).toHaveLength(256);
318 expect(result.currentModelId).toBeNull();
319 });
320
321 it("bounds and de-duplicates advertised commands", () => {
322 const commands = normalizeAcpRegistryCommands([
323 {
324 name: "create_plan",
325 description: " Create a plan ",
326 input: { type: "text", hint: " topic " },
327 },
328 { name: "CREATE_PLAN", description: "duplicate" },
329 ...Array.from({ length: 140 }, (_, index) => ({
330 name: `command_${index}`,
331 description: "",
332 })),
333 ]);
334
335 expect(commands.slashCommands).toHaveLength(128);
336 expect(commands.slashCommands[0]).toEqual({
337 name: "create_plan",
338 description: "Create a plan",
339 input: { hint: "topic" },
340 });
341 expect(
342 commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"),
343 ).toHaveLength(1);
344 expect(commands.skills).toEqual([]);
345 });
346
347 it("routes dollar-prefixed ACP commands to the provider skill menu", () => {
348 expect(
349 normalizeAcpRegistryCommands([
350 { name: "$workspace-skill", description: "Run the workspace skill" },
351 { name: "$WORKSPACE-SKILL", description: "duplicate" },
352 { name: "$", description: "empty" },
353 { name: "review", description: "Review changes" },
354 ]),
355 ).toEqual({
356 slashCommands: [{ name: "review", description: "Review changes" }],
357 skills: [
358 {
359 name: "workspace-skill",
360 description: "Run the workspace skill",
361 path: "acp://skill/workspace-skill",
362 scope: "agent",
363 enabled: true,
364 },
365 ],
366 });
367 });
368
369 it.effect("captures commands advertised just after session creation", () =>
370 Effect.gen(function* () {
371 const result = yield* probeAcpRegistryConfiguration({
372 instanceId,
373 settings: decodeSettings({ agentId: "mock-agent" }),
374 cwd: process.cwd(),
375 environment: process.env,
376 });
377
378 expect(result.slashCommands).toEqual([
379 {
380 name: "review",
381 description: "Review the current changes",
382 input: { hint: "focus" },
383 },
384 ]);
385 expect(result.skills).toEqual([
386 {
387 name: "workspace-skill",
388 description: "Run the workspace skill",
389 path: "acp://skill/workspace-skill",
390 scope: "agent",
391 enabled: true,
392 },
393 ]);
394 }).pipe(
395 Effect.provideService(
396 AcpRegistrySupport.AcpRegistryCatalog,
397 AcpRegistrySupport.AcpRegistryCatalog.of({
398 search: () => Effect.die("unused search"),
399 prepare: () => Effect.die("unused prepare"),
400 inspect: () => Effect.die("unused inspect"),
401 uninstallManagedBinary: () => Effect.die("unused uninstall"),
402 resolve: () =>
403 Effect.succeed({
404 agent: {
405 id: "mock-agent",
406 name: "Mock Agent",
407 version: "1.0.0",
408 description: "ACP probe test agent",
409 distribution: { npx: { package: "mock-agent@1.0.0" } },
410 },
411 distribution: "npx",
412 spawn: {
413 command: "node",
414 args: [mockAgentPath],
415 env: {
416 ...process.env,
417 T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25",
418 },
419 },
420 }),
421 }),
422 ),
423 Effect.provide(NodeServices.layer),
424 Effect.scoped,
425 ),
426 );
427
428 it.effect("includes package resolution in the probe timeout", () =>
429 Effect.gen(function* () {
430 const resolveStarted = yield* Deferred.make<void>();
431 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
432 search: () => Effect.die("unused search"),
433 prepare: () => Effect.die("unused prepare"),
434 inspect: () => Effect.die("unused inspect"),
435 uninstallManagedBinary: () => Effect.die("unused uninstall"),
436 resolve: () =>
437 Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)),
438 });
439 const probe = yield* probeAcpRegistryConfiguration({
440 instanceId,
441 settings: decodeSettings({ agentId: "slow-agent" }),
442 cwd: process.cwd(),
443 environment: process.env,
444 }).pipe(
445 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
446 Effect.flip,
447 Effect.forkChild({ startImmediately: true }),
448 );
449
450 yield* Deferred.await(resolveStarted);
451 yield* TestClock.adjust("60 seconds");
452
453 expect(yield* Fiber.join(probe)).toMatchObject({
454 reason: "probe_failed",
455 message: expect.stringContaining("within 60 seconds"),
456 });
457 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
458 );
459
460 it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => {
461 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
462 search: () => Effect.die("unused search"),
463 prepare: () => Effect.die("unused prepare"),
464 inspect: () => Effect.die("unused inspect"),
465 uninstallManagedBinary: () => Effect.die("unused uninstall"),
466 resolve: () =>
467 Effect.succeed({
468 agent: {
469 id: "mock-agent",
470 name: "Mock Agent",
471 version: "1.0.0",
472 description: "ACP session management test agent",
473 distribution: { npx: { package: "mock-agent@1.0.0" } },
474 },
475 distribution: "npx" as const,
476 spawn: {
477 command: "node",
478 args: [mockAgentPath],
479 env: {
480 ...process.env,
481 T3_ACP_SESSION_LIFECYCLE: "1",
482 T3_ACP_AUTH_METHOD_ID: "mock-login",
483 },
484 },
485 }),
486 });
487 const input = {
488 instanceId,
489 settings: decodeSettings({ agentId: "mock-agent" }),
490 cwd: process.cwd(),
491 environment: process.env,
492 };
493
494 return Effect.gen(function* () {
495 const listed = yield* listAcpRegistrySessions(input);
496 expect(listed).toMatchObject({
497 canLoad: true,
498 canResume: true,
499 nextCursor: null,
500 sessions: [
501 {
502 sessionId: "mock-session-1",
503 cwd: process.cwd(),
504 importedThreadId: null,
505 },
506 ],
507 });
508 yield* logoutAcpRegistry(input);
509 }).pipe(
510 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
511 Effect.provide(NodeServices.layer),
512 Effect.scoped,
513 );
514 });
515
516 it.effect("preserves the ACP method error for unsupported session management", () => {
517 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
518 search: () => Effect.die("unused search"),
519 prepare: () => Effect.die("unused prepare"),
520 inspect: () => Effect.die("unused inspect"),
521 uninstallManagedBinary: () => Effect.die("unused uninstall"),
522 resolve: () =>
523 Effect.succeed({
524 agent: {
525 id: "mock-agent",
526 name: "Mock Agent",
527 version: "1.0.0",
528 description: "ACP unsupported management test agent",
529 distribution: { npx: { package: "mock-agent@1.0.0" } },
530 },
531 distribution: "npx" as const,
532 spawn: {
533 command: "node",
534 args: [mockAgentPath],
535 env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" },
536 },
537 }),
538 });
539
540 return Effect.gen(function* () {
541 const failure = yield* listAcpRegistrySessions({
542 instanceId,
543 settings: decodeSettings({ agentId: "mock-agent" }),
544 cwd: process.cwd(),
545 environment: process.env,
546 }).pipe(Effect.flip);
547
548 expect(failure).toMatchObject({
549 reason: "session_list_unsupported",
550 message: "The ACP agent does not advertise session listing.",
551 cause: { _tag: "AcpRequestError", code: -32601 },
552 });
553 }).pipe(
554 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
555 Effect.provide(NodeServices.layer),
556 Effect.scoped,
557 );
558 });
559
560 it("distinguishes authentication failures from generic probe failures", () => {
561 const advertised = normalizeAcpRegistryAuthMethods([
562 { id: "grok-login", name: "Log in with Grok" },
563 ]);
564 const cause = new EffectAcpErrors.AcpRequestError({
565 code: -32000,
566 errorMessage: "login required",
567 });
568 const authFailure = acpRegistryProbeFailure(cause, advertised);
569 expect(authFailure.reason).toBe("authentication_failed");
570 expect(authFailure.message).toBe("The ACP agent could not complete authentication.");
571 expect(authFailure.cause).toBe(cause);
572 expect(authFailure.authMethods).toEqual([
573 {
574 id: "grok-login",
575 name: "Log in with Grok",
576 description: null,
577 type: "agent",
578 },
579 ]);
580 expect(
581 acpRegistryProbeFailure(
582 new EffectAcpErrors.AcpTransportError({
583 detail: "Authentication required",
584 cause: "credentials missing",
585 }),
586 ).reason,
587 ).toBe("authentication_failed");
588 const genericCause = new EffectAcpErrors.AcpTransportError({
589 detail: "connection closed",
590 cause: "closed",
591 });
592 const genericFailure = acpRegistryProbeFailure(genericCause);
593 expect(genericFailure.reason).toBe("probe_failed");
594 expect(genericFailure.message).toBe("The ACP agent could not create a test session.");
595 expect(genericFailure.cause).toBe(genericCause);
596 expect(
597 acpRegistryProbeFailure(
598 new EffectAcpErrors.AcpTransportError({
599 detail: "Failed while logging request",
600 cause: "logging failed",
601 }),
602 ).reason,
603 ).toBe("probe_failed");
604 });
605});
apps/server/src/provider/acp/AcpRegistryProbe.ts deleted-782
......@@ -1,782 +0,0 @@
1import {
2 AcpRegistryListProvidersResult,
3 AcpRegistryOperationError,
4 AcpRegistryListSessionsResult,
5 AcpRegistryProbeResult,
6 AcpRegistrySettings,
7 type AcpRegistryProbeAuthMethod,
8 type AcpRegistryProbeModel,
9 type AcpRegistryUrlAuthAction,
10 type ProviderInstanceId,
11 type ServerProviderSkill,
12 type ServerProviderSlashCommand,
13} from "@t3tools/contracts";
14import * as Crypto from "effect/Crypto";
15import * as Deferred from "effect/Deferred";
16import * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Ref from "effect/Ref";
21import * as Result from "effect/Result";
22import { ChildProcessSpawner } from "effect/process";
23import type * as EffectAcpErrors from "effect-acp/errors";
24import type * as EffectAcpSchema from "effect-acp/compat";
25
26import * as PtyAdapter from "../../terminal/PtyAdapter.ts";
27import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
28import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel";
29import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig";
30import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
31import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
32
33const MAX_AUTH_METHODS = 32;
34const MAX_MODELS = 256;
35const MAX_ID_LENGTH = 128;
36const MAX_NAME_LENGTH = 160;
37const MAX_DESCRIPTION_LENGTH = 1_024;
38const MAX_COMMANDS = 128;
39const MAX_COMMAND_LINE_LENGTH = 2_048;
40// Covers a cold package command launch and ACP initialization after registry
41// preparation globally installs the exact package.
42const PROBE_TIMEOUT_SECONDS = 60;
43const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`;
44const COMMAND_ADVERTISEMENT_GRACE = "500 millis";
45
46const boundedText = (value: string, maximumLength: number): string =>
47 value.trim().slice(0, maximumLength);
48
49const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
50 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
51
52export function normalizeAcpRegistryWebUrl(value: string): string | undefined {
53 if (value.length > 2_048 || !URL.canParse(value)) return undefined;
54 const url = new URL(value);
55 return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
56}
57
58function modelConfigOptions(
59 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
60): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
61 return (configOptions ?? []).flatMap((option) => {
62 if (option.category !== "model" || option.type !== "select") return [];
63 return option.options.flatMap((candidate) =>
64 "value" in candidate ? [candidate] : candidate.options,
65 );
66 });
67}
68
69function normalizeModels(
70 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
71): ReadonlyArray<AcpRegistryProbeModel> {
72 // Model discovery is the model config option's base models; ACP has no
73 // other portable model inventory.
74 const candidates = modelConfigOptions(configOptions).map((model) => ({
75 id: model.value,
76 name: model.name,
77 description: model.description ?? null,
78 }));
79 const seen = new Set<string>();
80 const models: Array<AcpRegistryProbeModel> = [];
81 for (const candidate of candidates) {
82 const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH);
83 if (id === undefined || seen.has(id)) continue;
84 seen.add(id);
85 models.push({
86 id,
87 name: boundedText(candidate.name, MAX_NAME_LENGTH) || id,
88 description:
89 candidate.description === null
90 ? null
91 : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null,
92 });
93 if (models.length === MAX_MODELS) break;
94 }
95 return models;
96}
97
98/** Agent spawn recipe used to compose runnable terminal-auth command lines. */
99export interface AcpRegistryAuthSpawnContext {
100 readonly command: string;
101 readonly args: ReadonlyArray<string>;
102}
103
104const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u;
105
106function shellDisplayToken(token: string): string {
107 return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`;
108}
109
110function terminalAuthCommand(
111 method: Extract<EffectAcpSchema.AuthMethod, { readonly type: "terminal" }>,
112 spawn: AcpRegistryAuthSpawnContext,
113): string | undefined {
114 const environmentPrefix = Object.entries(method.env ?? {}).map(
115 ([name, value]) => `${name}=${shellDisplayToken(value)}`,
116 );
117 const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken);
118 const displayCommand = [...environmentPrefix, ...command].join(" ");
119 return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined;
120}
121
122export function normalizeAcpRegistryAuthMethods(
123 methods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
124 spawn?: AcpRegistryAuthSpawnContext,
125): ReadonlyArray<AcpRegistryProbeAuthMethod> {
126 const normalized: Array<AcpRegistryProbeAuthMethod> = [];
127 for (const method of methods ?? []) {
128 const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH);
129 if (id === undefined) continue;
130 const type = method.type ?? "agent";
131 const envVarNames =
132 type === "env_var" && "vars" in method
133 ? method.vars
134 .flatMap((variable) => {
135 const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH);
136 return name === undefined ? [] : [name];
137 })
138 .slice(0, 16)
139 : [];
140 const command =
141 spawn !== undefined && "type" in method && method.type === "terminal"
142 ? terminalAuthCommand(method, spawn)
143 : undefined;
144 const link =
145 type === "env_var" && "link" in method && method.link
146 ? normalizeAcpRegistryWebUrl(method.link)
147 : undefined;
148 normalized.push({
149 id,
150 name: boundedText(method.name, MAX_NAME_LENGTH) || id,
151 description:
152 method.description == null
153 ? null
154 : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null,
155 type,
156 ...(command === undefined ? {} : { command }),
157 ...(envVarNames.length > 0 ? { envVarNames } : {}),
158 ...(link === undefined ? {} : { link }),
159 });
160 if (normalized.length === MAX_AUTH_METHODS) break;
161 }
162 return normalized;
163}
164
165export interface AcpRegistryAvailableCommands {
166 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
167 readonly skills: ReadonlyArray<ServerProviderSkill>;
168}
169
170export interface AcpRegistryLiveConfiguration {
171 readonly models: ReadonlyArray<AcpRegistryProbeModel>;
172 readonly currentModelId: string | null;
173 readonly configOptions: AcpRegistryProbeResult["configOptions"];
174}
175
176/** Normalizes volatile session configuration using the same bounds as discovery probes. */
177export function normalizeAcpRegistryLiveConfiguration(
178 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
179 modeState?: Parameters<typeof acpProviderOptionDescriptors>[0]["modeState"],
180): AcpRegistryLiveConfiguration {
181 const modelOption = configOptions.find(
182 (option) => option.category === "model" && option.type === "select",
183 );
184 const boundedCurrentModelId =
185 modelOption?.type === "select"
186 ? (boundedOpaqueValue(modelOption.currentValue, MAX_ID_LENGTH) ?? null)
187 : null;
188 const models = normalizeModels(configOptions);
189 return {
190 models,
191 currentModelId: models.some((model) => model.id === boundedCurrentModelId)
192 ? boundedCurrentModelId
193 : null,
194 configOptions: acpProviderOptionDescriptors({ configOptions, modeState }),
195 };
196}
197
198const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({
199 slashCommands: [],
200 skills: [],
201});
202
203/** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */
204export function normalizeAcpRegistryCommands(
205 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
206): AcpRegistryAvailableCommands {
207 const seen = new Set<string>();
208 const slashCommands: Array<ServerProviderSlashCommand> = [];
209 const skills: Array<ServerProviderSkill> = [];
210 let accepted = 0;
211 for (const command of commands) {
212 const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH);
213 if (name === undefined) continue;
214 const key = name.toLowerCase();
215 if (seen.has(key)) continue;
216 seen.add(key);
217 const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH);
218 const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : "";
219 if (name.startsWith("$")) {
220 const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH);
221 if (skillName === undefined) continue;
222 skills.push({
223 name: skillName,
224 ...(description ? { description } : {}),
225 path: `acp://skill/${encodeURIComponent(skillName)}`,
226 scope: "agent",
227 enabled: true,
228 });
229 } else {
230 slashCommands.push({
231 name,
232 ...(description ? { description } : {}),
233 ...(hint ? { input: { hint } } : {}),
234 });
235 }
236 accepted += 1;
237 if (accepted === MAX_COMMANDS) break;
238 }
239 return { slashCommands, skills };
240}
241
242/** Builds the bounded wire result from a successfully created disposable ACP session. */
243export function acpRegistryProbeResult(
244 instanceId: ProviderInstanceId,
245 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
246 icon: string | null = null,
247 spawn?: AcpRegistryAuthSpawnContext,
248): AcpRegistryProbeResult {
249 const liveConfiguration = normalizeAcpRegistryLiveConfiguration(
250 started.sessionSetupResult.configOptions ?? [],
251 parseSessionModeState(started.sessionSetupResult),
252 );
253 return AcpRegistryProbeResult.make({
254 instanceId,
255 ready: true,
256 icon,
257 authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn),
258 sessionManagement: {
259 canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null,
260 canLoad: started.initializeResult.agentCapabilities?.loadSession === true,
261 canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null,
262 canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null,
263 canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null,
264 canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null,
265 },
266 ...liveConfiguration,
267 });
268}
269
270export function acpRegistryProbeFailure(
271 error: EffectAcpErrors.AcpError,
272 authMethods: ReadonlyArray<AcpRegistryProbeAuthMethod> = [],
273 authAction?: AcpRegistryUrlAuthAction,
274): AcpRegistryOperationError {
275 const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message;
276 const authenticationFailed =
277 (error._tag === "AcpRequestError" && error.code === -32000) ||
278 /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail);
279 return new AcpRegistryOperationError({
280 reason: authenticationFailed ? "authentication_failed" : "probe_failed",
281 message: authenticationFailed
282 ? "The ACP agent could not complete authentication."
283 : "The ACP agent could not create a test session.",
284 cause: error,
285 ...(authMethods.length > 0 ? { authMethods } : {}),
286 ...(authAction === undefined ? {} : { authAction }),
287 });
288}
289
290export interface AcpRegistryConfigurationProbeInput {
291 readonly instanceId: ProviderInstanceId;
292 readonly settings: AcpRegistrySettings;
293 readonly cwd: string;
294 readonly environment: NodeJS.ProcessEnv;
295}
296
297type AcpRegistryManagementInput = AcpRegistryConfigurationProbeInput;
298
299export interface AcpRegistryConfigurationProbeResult {
300 readonly probe: AcpRegistryProbeResult;
301 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
302 readonly skills: ReadonlyArray<ServerProviderSkill>;
303}
304
305export type AcpRegistryConfigurationProbe<Requirements = never> = (
306 input: AcpRegistryConfigurationProbeInput,
307) => Effect.Effect<AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, Requirements>;
308
309/** Starts and immediately disposes an ACP session for one already-decoded configuration. */
310export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")(
311 function* (
312 input: AcpRegistryConfigurationProbeInput,
313 ): Effect.fn.Return<
314 AcpRegistryConfigurationProbeResult,
315 AcpRegistryOperationError,
316 AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
317 > {
318 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
319 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
320 const crypto = yield* Crypto.Crypto;
321 const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter);
322
323 const result = yield* Effect.gen(function* () {
324 // Resolution may install a missing registry package. Keep it inside the
325 // probe deadline so a provider refresh cannot inherit the installer's
326 // much longer timeout.
327 const resolved = yield* catalog
328 .resolve(input.settings, input.cwd, input.environment)
329 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
330 const authMethodsRef = yield* Ref.make<ReadonlyArray<AcpRegistryProbeAuthMethod>>([]);
331 const authActionRef = yield* Ref.make<AcpRegistryUrlAuthAction | undefined>(undefined);
332 const runtimeCoordinator = yield* Effect.serviceOption(
333 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
334 );
335 const runtimeContext = yield* Layer.build(
336 AcpSessionRuntime.layer({
337 spawn: resolved.spawn,
338 cwd: input.cwd,
339 clientCapabilities: {
340 auth: { terminal: Option.isSome(pty) },
341 elicitation: { url: {} },
342 fs: { readTextFile: false, writeTextFile: false },
343 terminal: false,
344 },
345 clientInfo: { name: "t3-code-provider-test", version: "0.0.0" },
346 authenticateOnAuthRequired: false,
347 onInitialized: (initializeResult) =>
348 Ref.set(
349 authMethodsRef,
350 normalizeAcpRegistryAuthMethods(initializeResult.authMethods, {
351 command: resolved.spawn.command,
352 args: resolved.spawn.args,
353 }),
354 ),
355 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
356 }).pipe(
357 Layer.provide(
358 Layer.mergeAll(
359 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
360 Layer.succeed(Crypto.Crypto, crypto),
361 ),
362 ),
363 ),
364 );
365 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
366 Effect.provide(runtimeContext),
367 );
368 const commandsRef = yield* Ref.make<AcpRegistryAvailableCommands>(
369 emptyAcpRegistryAvailableCommands(),
370 );
371 const commandsAdvertised = yield* Deferred.make<void>();
372 yield* runtime.handleElicitation((request) =>
373 Effect.gen(function* () {
374 if (
375 request.mode !== "url" ||
376 !("url" in request) ||
377 !("elicitationId" in request) ||
378 typeof request.url !== "string" ||
379 typeof request.elicitationId !== "string"
380 ) {
381 return { action: "decline" } as const;
382 }
383 const url = normalizeAcpRegistryWebUrl(request.url);
384 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
385 if (url === undefined || elicitationId === undefined) {
386 return { action: "decline" } as const;
387 }
388 const action: AcpRegistryUrlAuthAction = {
389 elicitationId,
390 url,
391 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
392 };
393 yield* Ref.set(authActionRef, action);
394 const accepted = yield* Option.match(runtimeCoordinator, {
395 onNone: () => Effect.succeed(false),
396 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
397 });
398 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
399 }),
400 );
401 yield* runtime.handleSessionUpdate((notification) => {
402 const update = notification.update;
403 return update.sessionUpdate === "available_commands_update"
404 ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe(
405 Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)),
406 Effect.asVoid,
407 )
408 : Effect.void;
409 });
410 const startResult = yield* Effect.result(runtime.start());
411 if (Result.isFailure(startResult)) {
412 return yield* acpRegistryProbeFailure(
413 startResult.failure,
414 yield* Ref.get(authMethodsRef),
415 yield* Ref.get(authActionRef),
416 );
417 }
418 const started = startResult.success;
419 yield* Deferred.await(commandsAdvertised).pipe(
420 Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE),
421 );
422 return { resolved, started, commands: yield* Ref.get(commandsRef) };
423 }).pipe(
424 Effect.scoped,
425 Effect.timeoutOrElse({
426 duration: PROBE_TIMEOUT,
427 orElse: () =>
428 Effect.fail(
429 new AcpRegistryOperationError({
430 reason: "probe_failed",
431 message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`,
432 }),
433 ),
434 }),
435 Effect.mapError((error) =>
436 error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error),
437 ),
438 );
439 return {
440 probe: acpRegistryProbeResult(
441 input.instanceId,
442 result.started,
443 result.resolved.agent?.icon ?? null,
444 {
445 command: result.resolved.spawn.command,
446 args: result.resolved.spawn.args,
447 },
448 ),
449 slashCommands: result.commands.slashCommands,
450 skills: result.commands.skills,
451 };
452 },
453);
454
455const MANAGEMENT_TIMEOUT = Duration.seconds(60);
456
457const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")(
458 function* (input: AcpRegistryManagementInput) {
459 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
460 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
461 const crypto = yield* Crypto.Crypto;
462 const runtimeCoordinator = yield* Effect.serviceOption(
463 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
464 );
465 const resolved = yield* catalog
466 .resolve(input.settings, input.cwd, input.environment)
467 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
468 const runtimeContext = yield* Layer.build(
469 AcpSessionRuntime.layer({
470 spawn: resolved.spawn,
471 cwd: input.cwd,
472 clientCapabilities: {
473 auth: { terminal: false },
474 elicitation: { url: {} },
475 fs: { readTextFile: false, writeTextFile: false },
476 terminal: false,
477 },
478 clientInfo: { name: "t3-code-session-manager", version: "0.0.0" },
479 authenticateOnAuthRequired: false,
480 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
481 }).pipe(
482 Layer.provide(
483 Layer.mergeAll(
484 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
485 Layer.succeed(Crypto.Crypto, crypto),
486 ),
487 ),
488 ),
489 );
490 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
491 Effect.provide(runtimeContext),
492 );
493 yield* runtime.handleElicitation((request) =>
494 Effect.gen(function* () {
495 if (
496 request.mode !== "url" ||
497 !("url" in request) ||
498 !("elicitationId" in request) ||
499 typeof request.url !== "string" ||
500 typeof request.elicitationId !== "string"
501 ) {
502 return { action: "decline" } as const;
503 }
504 const url = normalizeAcpRegistryWebUrl(request.url);
505 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
506 if (url === undefined || elicitationId === undefined) {
507 return { action: "decline" } as const;
508 }
509 const action: AcpRegistryUrlAuthAction = {
510 elicitationId,
511 url,
512 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
513 };
514 const accepted = yield* Option.match(runtimeCoordinator, {
515 onNone: () => Effect.succeed(false),
516 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
517 });
518 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
519 }),
520 );
521 return runtime;
522 },
523);
524
525function managementFailure(
526 operation:
527 | "list"
528 | "delete"
529 | "providers_list"
530 | "providers_set"
531 | "providers_disable"
532 | "logout",
533 error: EffectAcpErrors.AcpError | AcpRegistryOperationError,
534): AcpRegistryOperationError {
535 if (error._tag === "AcpRegistryOperationError") return error;
536 if (error._tag === "AcpRequestError" && error.code === -32601) {
537 if (operation === "list") {
538 return new AcpRegistryOperationError({
539 reason: "session_list_unsupported",
540 message: "The ACP agent does not advertise session listing.",
541 cause: error,
542 });
543 }
544 if (operation === "delete") {
545 return new AcpRegistryOperationError({
546 reason: "session_delete_unsupported",
547 message: "The ACP agent does not advertise session deletion.",
548 cause: error,
549 });
550 }
551 if (operation.startsWith("providers_")) {
552 return new AcpRegistryOperationError({
553 reason: "providers_unsupported",
554 message: "The ACP agent does not advertise provider configuration.",
555 cause: error,
556 });
557 }
558 return new AcpRegistryOperationError({
559 reason: "logout_unsupported",
560 message: "The ACP agent does not advertise logout.",
561 cause: error,
562 });
563 }
564 const classified = acpRegistryProbeFailure(error);
565 if (classified.reason === "authentication_failed") return classified;
566 const failure =
567 operation === "delete"
568 ? { reason: "session_delete_failed" as const, message: "Could not delete the ACP session." }
569 : operation === "providers_list"
570 ? { reason: "providers_list_failed" as const, message: "Could not list ACP providers." }
571 : operation === "providers_set" || operation === "providers_disable"
572 ? {
573 reason: "provider_configuration_failed" as const,
574 message: "Could not update the ACP provider configuration.",
575 }
576 : undefined;
577 return failure === undefined
578 ? classified
579 : new AcpRegistryOperationError({ ...failure, cause: error });
580}
581
582export const listAcpRegistrySessions = Effect.fn("AcpRegistryProbe.listSessions")(
583 function* (input: AcpRegistryManagementInput & { readonly cursor?: string }) {
584 const runtime = yield* makeAcpRegistryManagementRuntime(input);
585 const initialized = yield* runtime.initialize();
586 const listed = yield* runtime.listSessions(input.cursor);
587 const sessions = listed.sessions.slice(0, 256).flatMap((session) => {
588 const sessionId = boundedOpaqueValue(session.sessionId, 1_024);
589 const cwd = boundedOpaqueValue(session.cwd, 4_096);
590 if (sessionId === undefined || cwd === undefined) return [];
591 return [
592 {
593 sessionId,
594 cwd,
595 additionalDirectories: (session.additionalDirectories ?? [])
596 .flatMap((directory) => {
597 const normalized = boundedOpaqueValue(directory, 4_096);
598 return normalized === undefined ? [] : [normalized];
599 })
600 .slice(0, 32),
601 title:
602 session.title === undefined || session.title === null
603 ? null
604 : boundedText(session.title, 1_024) || null,
605 updatedAt:
606 session.updatedAt === undefined || session.updatedAt === null
607 ? null
608 : boundedText(session.updatedAt, 128) || null,
609 importedThreadId: null,
610 },
611 ];
612 });
613 return AcpRegistryListSessionsResult.make({
614 sessions,
615 nextCursor:
616 listed.nextCursor === undefined || listed.nextCursor === null
617 ? null
618 : boundedText(listed.nextCursor, 2_048) || null,
619 canLoad: initialized.agentCapabilities?.loadSession === true,
620 canResume: initialized.agentCapabilities?.sessionCapabilities?.resume != null,
621 canDelete: initialized.agentCapabilities?.sessionCapabilities?.delete != null,
622 });
623 },
624 (effect) =>
625 effect.pipe(
626 Effect.scoped,
627 Effect.timeoutOrElse({
628 duration: MANAGEMENT_TIMEOUT,
629 orElse: () =>
630 Effect.fail(
631 new AcpRegistryOperationError({
632 reason: "probe_failed",
633 message: "The ACP session list request timed out.",
634 }),
635 ),
636 }),
637 Effect.mapError((error) => managementFailure("list", error)),
638 ),
639);
640
641export const deleteAcpRegistrySession = Effect.fn("AcpRegistryProbe.deleteSession")(
642 function* (input: AcpRegistryManagementInput & { readonly sessionId: string }) {
643 const runtime = yield* makeAcpRegistryManagementRuntime(input);
644 yield* runtime.deleteSession(input.sessionId);
645 },
646 (effect) =>
647 effect.pipe(
648 Effect.scoped,
649 Effect.timeoutOrElse({
650 duration: MANAGEMENT_TIMEOUT,
651 orElse: () =>
652 Effect.fail(
653 new AcpRegistryOperationError({
654 reason: "session_delete_failed",
655 message: "The ACP session delete request timed out.",
656 }),
657 ),
658 }),
659 Effect.mapError((error) => managementFailure("delete", error)),
660 ),
661);
662
663export const listAcpRegistryProviders = Effect.fn("AcpRegistryProbe.listProviders")(
664 function* (input: AcpRegistryManagementInput) {
665 const runtime = yield* makeAcpRegistryManagementRuntime(input);
666 const listed = yield* runtime.listProviders;
667 return AcpRegistryListProvidersResult.make({
668 providers: listed.providers.slice(0, 64).flatMap((provider) => {
669 const providerId = boundedOpaqueValue(provider.providerId, 256);
670 if (providerId === undefined) return [];
671 const supported = provider.supported
672 .flatMap((protocol) => {
673 const value = boundedOpaqueValue(protocol, 64);
674 return value === undefined ? [] : [value];
675 })
676 .slice(0, 16);
677 const current = provider.current;
678 const currentApiType =
679 current == null ? undefined : boundedOpaqueValue(current.apiType, 64);
680 const currentBaseUrl =
681 current == null ? undefined : normalizeAcpRegistryWebUrl(current.baseUrl);
682 return [
683 {
684 providerId,
685 supported,
686 required: provider.required,
687 current:
688 currentApiType === undefined || currentBaseUrl === undefined
689 ? null
690 : { apiType: currentApiType, baseUrl: currentBaseUrl },
691 },
692 ];
693 }),
694 });
695 },
696 (effect) =>
697 effect.pipe(
698 Effect.scoped,
699 Effect.timeoutOrElse({
700 duration: MANAGEMENT_TIMEOUT,
701 orElse: () =>
702 Effect.fail(
703 new AcpRegistryOperationError({
704 reason: "providers_list_failed",
705 message: "The ACP provider list request timed out.",
706 }),
707 ),
708 }),
709 Effect.mapError((error) => managementFailure("providers_list", error)),
710 ),
711);
712
713export const setAcpRegistryProvider = Effect.fn("AcpRegistryProbe.setProvider")(
714 function* (input: AcpRegistryManagementInput & EffectAcpSchema.SetProviderRequest) {
715 const runtime = yield* makeAcpRegistryManagementRuntime(input);
716 yield* runtime.setProvider({
717 providerId: input.providerId,
718 apiType: input.apiType,
719 baseUrl: input.baseUrl,
720 ...(input.headers === undefined ? {} : { headers: input.headers }),
721 });
722 },
723 (effect) =>
724 effect.pipe(
725 Effect.scoped,
726 Effect.timeoutOrElse({
727 duration: MANAGEMENT_TIMEOUT,
728 orElse: () =>
729 Effect.fail(
730 new AcpRegistryOperationError({
731 reason: "provider_configuration_failed",
732 message: "The ACP provider configuration request timed out.",
733 }),
734 ),
735 }),
736 Effect.mapError((error) => managementFailure("providers_set", error)),
737 ),
738);
739
740export const disableAcpRegistryProvider = Effect.fn("AcpRegistryProbe.disableProvider")(
741 function* (input: AcpRegistryManagementInput & { readonly providerId: string }) {
742 const runtime = yield* makeAcpRegistryManagementRuntime(input);
743 yield* runtime.disableProvider(input.providerId);
744 },
745 (effect) =>
746 effect.pipe(
747 Effect.scoped,
748 Effect.timeoutOrElse({
749 duration: MANAGEMENT_TIMEOUT,
750 orElse: () =>
751 Effect.fail(
752 new AcpRegistryOperationError({
753 reason: "provider_configuration_failed",
754 message: "The ACP provider disable request timed out.",
755 }),
756 ),
757 }),
758 Effect.mapError((error) => managementFailure("providers_disable", error)),
759 ),
760);
761
762export const logoutAcpRegistry = Effect.fn("AcpRegistryProbe.logout")(
763 function* (input: AcpRegistryManagementInput) {
764 const runtime = yield* makeAcpRegistryManagementRuntime(input);
765 yield* runtime.logout;
766 },
767 (effect) =>
768 effect.pipe(
769 Effect.scoped,
770 Effect.timeoutOrElse({
771 duration: MANAGEMENT_TIMEOUT,
772 orElse: () =>
773 Effect.fail(
774 new AcpRegistryOperationError({
775 reason: "probe_failed",
776 message: "The ACP logout request timed out.",
777 }),
778 ),
779 }),
780 Effect.mapError((error) => managementFailure("logout", error)),
781 ),
782);
apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts deleted-366
......@@ -1,366 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Option from "effect/Option";
7import * as TestClock from "effect/testing/TestClock";
8
9import type {
10 AcpRegistryAvailableCommands,
11 AcpRegistryLiveConfiguration,
12} from "./AcpRegistryProbe.ts";
13import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
14
15describe("AcpRegistryRuntimeCoordinator", () => {
16 it.effect("suppresses a background probe while foreground startup is active", () =>
17 Effect.gen(function* () {
18 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
19 const foregroundStarted = yield* Deferred.make<void>();
20 const releaseForeground = yield* Deferred.make<void>();
21 const foreground = yield* coordinator
22 .withForegroundStartup(
23 "kilo",
24 Deferred.succeed(foregroundStarted, undefined).pipe(
25 Effect.andThen(Deferred.await(releaseForeground)),
26 ),
27 )
28 .pipe(Effect.forkChild);
29 yield* Deferred.await(foregroundStarted);
30
31 const probed = yield* coordinator.runBackgroundProbe("kilo", Effect.succeed("unexpected"));
32 expect(Option.isNone(probed)).toBe(true);
33
34 yield* Deferred.succeed(releaseForeground, undefined);
35 yield* Fiber.join(foreground);
36 }).pipe(
37 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
38 Effect.scoped,
39 ),
40 );
41
42 it.effect("interrupts an active background probe when foreground startup begins", () =>
43 Effect.gen(function* () {
44 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
45 const probeStarted = yield* Deferred.make<void>();
46 const probeFinalized = yield* Deferred.make<void>();
47 const releaseForeground = yield* Deferred.make<void>();
48 const probe = yield* coordinator
49 .runBackgroundProbe(
50 "kilo",
51 Deferred.succeed(probeStarted, undefined).pipe(
52 Effect.andThen(Effect.never),
53 Effect.ensuring(Deferred.succeed(probeFinalized, undefined).pipe(Effect.ignore)),
54 ),
55 )
56 .pipe(Effect.forkChild);
57 yield* Deferred.await(probeStarted);
58
59 const foreground = yield* coordinator
60 .withForegroundStartup("kilo", Deferred.await(releaseForeground))
61 .pipe(Effect.forkChild);
62 expect(Option.isNone(yield* Fiber.join(probe))).toBe(true);
63 yield* Deferred.await(probeFinalized);
64
65 yield* Deferred.succeed(releaseForeground, undefined);
66 yield* Fiber.join(foreground);
67 }).pipe(
68 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
69 Effect.scoped,
70 ),
71 );
72
73 it.effect("serializes native session mutations across callers", () =>
74 Effect.gen(function* () {
75 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
76 const firstEntered = yield* Deferred.make<void>();
77 const releaseFirst = yield* Deferred.make<void>();
78 const secondEntered = yield* Deferred.make<void>();
79 const first = yield* coordinator
80 .withSessionMutation(
81 Deferred.succeed(firstEntered, undefined).pipe(
82 Effect.andThen(Deferred.await(releaseFirst)),
83 ),
84 )
85 .pipe(Effect.forkChild);
86 yield* Deferred.await(firstEntered);
87
88 const second = yield* coordinator
89 .withSessionMutation(Deferred.succeed(secondEntered, undefined))
90 .pipe(Effect.forkChild);
91 expect(yield* Deferred.poll(secondEntered)).toEqual(Option.none());
92
93 yield* Deferred.succeed(releaseFirst, undefined);
94 yield* Fiber.join(first);
95 yield* Fiber.join(second);
96 expect(Option.isSome(yield* Deferred.poll(secondEntered))).toBe(true);
97 }).pipe(
98 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
99 Effect.scoped,
100 ),
101 );
102
103 it.effect("replays and replaces late command advertisements per provider instance", () =>
104 Effect.gen(function* () {
105 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
106 const codex = ProviderInstanceId.make("acpRegistry_codex");
107 const kilo = ProviderInstanceId.make("acpRegistry_kilo");
108 const firstSeen = yield* Deferred.make<void>();
109 const replacementSeen = yield* Deferred.make<void>();
110 const received: Array<AcpRegistryAvailableCommands> = [];
111
112 yield* coordinator.publishAvailableCommands(codex, {
113 slashCommands: [{ name: "status" }],
114 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
115 });
116 const consumer = yield* coordinator
117 .watchAvailableCommands(codex, (commands) =>
118 Effect.gen(function* () {
119 received.push(commands);
120 yield* received.length === 1
121 ? Deferred.succeed(firstSeen, undefined)
122 : Deferred.succeed(replacementSeen, undefined);
123 }),
124 )
125 .pipe(Effect.forkChild);
126 yield* Deferred.await(firstSeen);
127
128 yield* coordinator.publishAvailableCommands(kilo, {
129 slashCommands: [{ name: "review" }],
130 skills: [],
131 });
132 yield* coordinator.publishAvailableCommands(codex, { slashCommands: [], skills: [] });
133 yield* Deferred.await(replacementSeen);
134 yield* Fiber.interrupt(consumer);
135
136 expect(received).toEqual([
137 {
138 slashCommands: [{ name: "status" }],
139 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
140 },
141 { slashCommands: [], skills: [] },
142 ]);
143 expect(yield* coordinator.getAvailableCommands(kilo)).toEqual(
144 Option.some({ slashCommands: [{ name: "review" }], skills: [] }),
145 );
146 yield* coordinator.clearAvailableCommands(codex);
147 expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true);
148 }).pipe(
149 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
150 Effect.scoped,
151 ),
152 );
153
154 it.effect("replays and replaces live configuration per provider instance", () =>
155 Effect.gen(function* () {
156 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
157 const instanceId = ProviderInstanceId.make("acpRegistry_kilo");
158 const firstSeen = yield* Deferred.make<void>();
159 const replacementSeen = yield* Deferred.make<void>();
160 const received: Array<AcpRegistryLiveConfiguration> = [];
161 const first = {
162 models: [{ id: "sonnet", name: "Sonnet", description: null }],
163 currentModelId: "sonnet",
164 configOptions: [],
165 } satisfies AcpRegistryLiveConfiguration;
166 const replacement = {
167 models: [{ id: "opus", name: "Opus", description: null }],
168 currentModelId: "opus",
169 configOptions: [],
170 } satisfies AcpRegistryLiveConfiguration;
171
172 yield* coordinator.publishLiveConfiguration(instanceId, first);
173 const consumer = yield* coordinator
174 .watchLiveConfiguration(instanceId, (configuration) =>
175 Effect.gen(function* () {
176 received.push(configuration);
177 yield* received.length === 1
178 ? Deferred.succeed(firstSeen, undefined)
179 : Deferred.succeed(replacementSeen, undefined);
180 }),
181 )
182 .pipe(Effect.forkChild);
183 yield* Deferred.await(firstSeen);
184 yield* coordinator.publishLiveConfiguration(instanceId, replacement);
185 yield* Deferred.await(replacementSeen);
186 yield* Fiber.interrupt(consumer);
187
188 expect(received).toEqual([first, replacement]);
189 expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement));
190 yield* coordinator.clearLiveConfiguration(instanceId);
191 expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true);
192 }).pipe(
193 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
194 Effect.scoped,
195 ),
196 );
197
198 it.effect("requires matching user consent before accepting URL authentication", () =>
199 Effect.gen(function* () {
200 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
201 const instanceId = ProviderInstanceId.make("acpRegistry_antigravity");
202 const action = {
203 elicitationId: "login-1",
204 url: "https://accounts.example.com/login",
205 message: "Continue in your browser",
206 };
207 const actionSeen = yield* Deferred.make<void>();
208 const consumer = yield* coordinator
209 .watchUrlAuthAction(instanceId, (current) =>
210 current === null
211 ? Effect.void
212 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
213 )
214 .pipe(Effect.forkChild);
215 const request = yield* coordinator
216 .requestUrlAuthentication(instanceId, action)
217 .pipe(Effect.forkChild);
218 yield* Deferred.await(actionSeen);
219
220 expect(yield* coordinator.getUrlAuthAction(instanceId)).toEqual(
221 Option.some({
222 ...action,
223 createdAt: "1970-01-01T00:00:00.000Z",
224 expiresAt: "1970-01-01T00:10:00.000Z",
225 }),
226 );
227
228 expect(
229 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "stale" }),
230 ).toBe(false);
231 expect(
232 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-1" }),
233 ).toBe(true);
234 expect(yield* Fiber.join(request)).toBe(true);
235 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
236 yield* Fiber.interrupt(consumer);
237 }).pipe(
238 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
239 Effect.scoped,
240 ),
241 );
242
243 it.effect("expires stale URL authentication actions without accepting them", () =>
244 Effect.gen(function* () {
245 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
246 const instanceId = ProviderInstanceId.make("acpRegistry_expired");
247 const actionSeen = yield* Deferred.make<void>();
248 const consumer = yield* coordinator
249 .watchUrlAuthAction(instanceId, (current) =>
250 current === null
251 ? Effect.void
252 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
253 )
254 .pipe(Effect.forkChild);
255 const request = yield* coordinator
256 .requestUrlAuthentication(instanceId, {
257 elicitationId: "expired-login",
258 url: "https://accounts.example.com/login",
259 message: "Continue in your browser",
260 })
261 .pipe(Effect.forkChild);
262 yield* Deferred.await(actionSeen);
263 yield* TestClock.adjust("10 minutes");
264
265 expect(yield* Fiber.join(request)).toBe(false);
266 expect(
267 yield* coordinator.acceptUrlAuthentication({
268 instanceId,
269 elicitationId: "expired-login",
270 }),
271 ).toBe(false);
272 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
273 yield* Fiber.interrupt(consumer);
274 }).pipe(
275 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
276 Effect.scoped,
277 ),
278 );
279
280 it.effect("publishes a replacement URL action before retiring the previous request", () =>
281 Effect.gen(function* () {
282 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
283 const instanceId = ProviderInstanceId.make("acpRegistry_replacement");
284 const received: Array<string | null> = [];
285 const firstSeen = yield* Deferred.make<void>();
286 const replacementSeen = yield* Deferred.make<void>();
287 const consumer = yield* coordinator
288 .watchUrlAuthAction(instanceId, (current) =>
289 Effect.sync(() => {
290 received.push(current?.elicitationId ?? null);
291 }).pipe(
292 Effect.andThen(
293 current?.elicitationId === "login-1"
294 ? Deferred.succeed(firstSeen, undefined).pipe(Effect.asVoid)
295 : current?.elicitationId === "login-2"
296 ? Deferred.succeed(replacementSeen, undefined).pipe(Effect.asVoid)
297 : Effect.void,
298 ),
299 ),
300 )
301 .pipe(Effect.forkChild);
302 const first = yield* coordinator
303 .requestUrlAuthentication(instanceId, {
304 elicitationId: "login-1",
305 url: "https://accounts.example.com/first",
306 message: "Continue with the first login",
307 })
308 .pipe(Effect.forkChild);
309 yield* Deferred.await(firstSeen);
310 const replacement = yield* coordinator
311 .requestUrlAuthentication(instanceId, {
312 elicitationId: "login-2",
313 url: "https://accounts.example.com/replacement",
314 message: "Continue with the replacement login",
315 })
316 .pipe(Effect.forkChild);
317 yield* Deferred.await(replacementSeen);
318
319 expect(yield* Fiber.join(first)).toBe(false);
320 expect(
321 (yield* coordinator.getUrlAuthAction(instanceId)).pipe(Option.getOrThrow),
322 ).toMatchObject({ elicitationId: "login-2" });
323 expect(received.slice(0, 2)).toEqual(["login-1", "login-2"]);
324 expect(
325 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-2" }),
326 ).toBe(true);
327 expect(yield* Fiber.join(replacement)).toBe(true);
328 yield* Fiber.interrupt(consumer);
329 }).pipe(
330 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
331 Effect.scoped,
332 ),
333 );
334
335 it.effect("clears a published URL action when its request is interrupted", () =>
336 Effect.gen(function* () {
337 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
338 const instanceId = ProviderInstanceId.make("acpRegistry_interrupted");
339 const actionSeen = yield* Deferred.make<void>();
340 const cleared = yield* Deferred.make<void>();
341 const consumer = yield* coordinator
342 .watchUrlAuthAction(instanceId, (current) =>
343 current === null
344 ? Deferred.succeed(cleared, undefined).pipe(Effect.asVoid)
345 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
346 )
347 .pipe(Effect.forkChild);
348 const request = yield* coordinator
349 .requestUrlAuthentication(instanceId, {
350 elicitationId: "interrupted-login",
351 url: "https://accounts.example.com/interrupted",
352 message: "Continue with login",
353 })
354 .pipe(Effect.forkChild);
355 yield* Deferred.await(actionSeen);
356
357 yield* Fiber.interrupt(request);
358 yield* Deferred.await(cleared);
359 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
360 yield* Fiber.interrupt(consumer);
361 }).pipe(
362 Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer),
363 Effect.scoped,
364 ),
365 );
366});
apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts deleted-369
......@@ -1,369 +0,0 @@
1import {
2 type AcpRegistryAcceptUrlAuthInput,
3 type AcpRegistryUrlAuthAction,
4 type ProviderInstanceId,
5} from "@t3tools/contracts";
6import * as Context from "effect/Context";
7import * as Deferred from "effect/Deferred";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17
18import type {
19 AcpRegistryAvailableCommands,
20 AcpRegistryLiveConfiguration,
21} from "./AcpRegistryProbe.ts";
22
23interface AvailableCommandsUpdate {
24 readonly instanceId: ProviderInstanceId;
25 readonly commands: AcpRegistryAvailableCommands;
26}
27
28interface LiveConfigurationUpdate {
29 readonly instanceId: ProviderInstanceId;
30 readonly configuration: AcpRegistryLiveConfiguration;
31}
32
33interface UrlAuthActionUpdate {
34 readonly instanceId: ProviderInstanceId;
35 readonly action: AcpRegistryUrlAuthAction | null;
36}
37
38/** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */
39const URL_AUTH_ACTION_TTL = Duration.minutes(10);
40
41interface PendingUrlAuthAction {
42 readonly action: AcpRegistryUrlAuthAction;
43 readonly consent: Deferred.Deferred<boolean>;
44 readonly expiresAt: DateTime.Utc;
45}
46
47/** Gives a user-started ACP process priority over disposable discovery for the same agent. */
48export class AcpRegistryRuntimeCoordinator extends Context.Service<
49 AcpRegistryRuntimeCoordinator,
50 {
51 readonly withForegroundStartup: <A, E, R>(
52 agentId: string,
53 effect: Effect.Effect<A, E, R>,
54 ) => Effect.Effect<A, E, R>;
55 readonly runBackgroundProbe: <A, E, R>(
56 agentId: string,
57 effect: Effect.Effect<A, E, R>,
58 ) => Effect.Effect<Option.Option<A>, E, R>;
59 /** Serializes native-session imports and deletes across all client connections. */
60 readonly withSessionMutation: <A, E, R>(
61 effect: Effect.Effect<A, E, R>,
62 ) => Effect.Effect<A, E, R>;
63 readonly clearAvailableCommands: (instanceId: ProviderInstanceId) => Effect.Effect<void>;
64 readonly publishAvailableCommands: (
65 instanceId: ProviderInstanceId,
66 commands: AcpRegistryAvailableCommands,
67 ) => Effect.Effect<void>;
68 readonly getAvailableCommands: (
69 instanceId: ProviderInstanceId,
70 ) => Effect.Effect<Option.Option<AcpRegistryAvailableCommands>>;
71 readonly watchAvailableCommands: (
72 instanceId: ProviderInstanceId,
73 onUpdate: (commands: AcpRegistryAvailableCommands) => Effect.Effect<void>,
74 ) => Effect.Effect<void>;
75 readonly clearLiveConfiguration: (instanceId: ProviderInstanceId) => Effect.Effect<void>;
76 readonly publishLiveConfiguration: (
77 instanceId: ProviderInstanceId,
78 configuration: AcpRegistryLiveConfiguration,
79 ) => Effect.Effect<void>;
80 readonly getLiveConfiguration: (
81 instanceId: ProviderInstanceId,
82 ) => Effect.Effect<Option.Option<AcpRegistryLiveConfiguration>>;
83 readonly watchLiveConfiguration: (
84 instanceId: ProviderInstanceId,
85 onUpdate: (configuration: AcpRegistryLiveConfiguration) => Effect.Effect<void>,
86 ) => Effect.Effect<void>;
87 readonly requestUrlAuthentication: (
88 instanceId: ProviderInstanceId,
89 action: AcpRegistryUrlAuthAction,
90 ) => Effect.Effect<boolean>;
91 readonly acceptUrlAuthentication: (
92 input: AcpRegistryAcceptUrlAuthInput,
93 ) => Effect.Effect<boolean>;
94 readonly getUrlAuthAction: (
95 instanceId: ProviderInstanceId,
96 ) => Effect.Effect<Option.Option<AcpRegistryUrlAuthAction>>;
97 readonly watchUrlAuthAction: (
98 instanceId: ProviderInstanceId,
99 onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect<void>,
100 ) => Effect.Effect<void>;
101 }
102>()("t3/provider/acp/AcpRegistryRuntimeCoordinator") {
103 static get layer() {
104 return layer;
105 }
106}
107
108const make = Effect.gen(function* () {
109 const foregroundStarts = yield* PubSub.unbounded<string>();
110 const activeForegroundCounts = yield* Ref.make(new Map<string, number>());
111 const availableCommandsUpdates = yield* PubSub.unbounded<AvailableCommandsUpdate>();
112 const availableCommandsByInstance = yield* Ref.make(
113 new Map<ProviderInstanceId, AcpRegistryAvailableCommands>(),
114 );
115 const liveConfigurationUpdates = yield* PubSub.unbounded<LiveConfigurationUpdate>();
116 const liveConfigurationByInstance = yield* Ref.make(
117 new Map<ProviderInstanceId, AcpRegistryLiveConfiguration>(),
118 );
119 const urlAuthActionUpdates = yield* PubSub.unbounded<UrlAuthActionUpdate>();
120 const pendingUrlAuthActions = yield* Ref.make(
121 new Map<ProviderInstanceId, PendingUrlAuthAction>(),
122 );
123 const urlAuthActionPermit = yield* Semaphore.make(1);
124 const sessionMutationPermit = yield* Semaphore.make(1);
125
126 const withForegroundStartup: AcpRegistryRuntimeCoordinator["Service"]["withForegroundStartup"] = (
127 agentId,
128 effect,
129 ) =>
130 Effect.acquireUseRelease(
131 Ref.update(activeForegroundCounts, (current) => {
132 const next = new Map(current);
133 next.set(agentId, (next.get(agentId) ?? 0) + 1);
134 return next;
135 }).pipe(Effect.andThen(PubSub.publish(foregroundStarts, agentId))),
136 () => effect,
137 () =>
138 Ref.update(activeForegroundCounts, (current) => {
139 const next = new Map(current);
140 const remaining = (next.get(agentId) ?? 1) - 1;
141 if (remaining <= 0) next.delete(agentId);
142 else next.set(agentId, remaining);
143 return next;
144 }),
145 );
146
147 const runBackgroundProbe: AcpRegistryRuntimeCoordinator["Service"]["runBackgroundProbe"] = (
148 agentId,
149 effect,
150 ) =>
151 Effect.scoped(
152 Effect.gen(function* () {
153 // Subscribe before checking state so a foreground start cannot land
154 // in the gap between the check and the interruptible probe.
155 const subscription = yield* PubSub.subscribe(foregroundStarts);
156 if ((yield* Ref.get(activeForegroundCounts)).has(agentId)) {
157 return Option.none();
158 }
159 const foregroundStarted = Stream.fromSubscription(subscription).pipe(
160 Stream.filter((candidate) => candidate === agentId),
161 Stream.runHead,
162 Effect.as(Option.none()),
163 );
164 return yield* Effect.raceFirst(effect.pipe(Effect.map(Option.some)), foregroundStarted);
165 }),
166 );
167
168 const withSessionMutation: AcpRegistryRuntimeCoordinator["Service"]["withSessionMutation"] = (
169 effect,
170 ) => sessionMutationPermit.withPermit(effect);
171
172 const clearAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["clearAvailableCommands"] =
173 (instanceId) =>
174 Ref.update(availableCommandsByInstance, (current) => {
175 const next = new Map(current);
176 next.delete(instanceId);
177 return next;
178 });
179
180 const publishAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["publishAvailableCommands"] =
181 (instanceId, commands) =>
182 Ref.update(availableCommandsByInstance, (current) => {
183 const next = new Map(current);
184 next.set(instanceId, commands);
185 return next;
186 }).pipe(
187 Effect.andThen(
188 PubSub.publish(availableCommandsUpdates, {
189 instanceId,
190 commands,
191 }),
192 ),
193 Effect.asVoid,
194 );
195
196 const getAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["getAvailableCommands"] =
197 Effect.fn("AcpRegistryRuntimeCoordinator.getAvailableCommands")(function* (instanceId) {
198 return Option.fromNullishOr((yield* Ref.get(availableCommandsByInstance)).get(instanceId));
199 });
200
201 const watchAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["watchAvailableCommands"] =
202 (instanceId, onUpdate) =>
203 Effect.scoped(
204 Effect.gen(function* () {
205 // Subscribe before reading the current value so a publication in
206 // between is either observed from the snapshot, the queue, or both.
207 const subscription = yield* PubSub.subscribe(availableCommandsUpdates);
208 const current = yield* getAvailableCommands(instanceId);
209 if (Option.isSome(current)) {
210 yield* onUpdate(current.value);
211 }
212 yield* Stream.fromSubscription(subscription).pipe(
213 Stream.filter((update) => update.instanceId === instanceId),
214 Stream.runForEach((update) => onUpdate(update.commands)),
215 );
216 }),
217 );
218
219 const clearLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["clearLiveConfiguration"] =
220 (instanceId) =>
221 Ref.update(liveConfigurationByInstance, (current) => {
222 const next = new Map(current);
223 next.delete(instanceId);
224 return next;
225 });
226
227 const publishLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["publishLiveConfiguration"] =
228 (instanceId, configuration) =>
229 Ref.update(liveConfigurationByInstance, (current) =>
230 new Map(current).set(instanceId, configuration),
231 ).pipe(
232 Effect.andThen(
233 PubSub.publish(liveConfigurationUpdates, {
234 instanceId,
235 configuration,
236 }),
237 ),
238 Effect.asVoid,
239 );
240
241 const getLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["getLiveConfiguration"] =
242 Effect.fn("AcpRegistryRuntimeCoordinator.getLiveConfiguration")(function* (instanceId) {
243 return Option.fromNullishOr((yield* Ref.get(liveConfigurationByInstance)).get(instanceId));
244 });
245
246 const watchLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["watchLiveConfiguration"] =
247 (instanceId, onUpdate) =>
248 Effect.scoped(
249 Effect.gen(function* () {
250 const subscription = yield* PubSub.subscribe(liveConfigurationUpdates);
251 const current = yield* getLiveConfiguration(instanceId);
252 if (Option.isSome(current)) {
253 yield* onUpdate(current.value);
254 }
255 yield* Stream.fromSubscription(subscription).pipe(
256 Stream.filter((update) => update.instanceId === instanceId),
257 Stream.runForEach((update) => onUpdate(update.configuration)),
258 );
259 }),
260 );
261
262 const publishUrlAuthAction = (
263 instanceId: ProviderInstanceId,
264 action: AcpRegistryUrlAuthAction | null,
265 ) => PubSub.publish(urlAuthActionUpdates, { instanceId, action }).pipe(Effect.asVoid);
266
267 const requestUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["requestUrlAuthentication"] =
268 Effect.fn("AcpRegistryRuntimeCoordinator.requestUrlAuthentication")(
269 function* (instanceId, action) {
270 const consent = yield* Deferred.make<boolean>();
271 const createdAt = yield* DateTime.now;
272 const expiresAt = DateTime.addDuration(createdAt, URL_AUTH_ACTION_TTL);
273 const publishedAction = {
274 ...action,
275 createdAt: DateTime.formatIso(createdAt),
276 expiresAt: DateTime.formatIso(expiresAt),
277 } satisfies AcpRegistryUrlAuthAction;
278 const cleanup = Ref.modify(pendingUrlAuthActions, (current) => {
279 if (current.get(instanceId)?.consent !== consent) {
280 return [false, current] as const;
281 }
282 const next = new Map(current);
283 next.delete(instanceId);
284 return [true, next] as const;
285 }).pipe(
286 Effect.flatMap((removed) =>
287 removed ? publishUrlAuthAction(instanceId, null) : Effect.void,
288 ),
289 );
290 return yield* Effect.gen(function* () {
291 yield* urlAuthActionPermit.withPermits(1)(
292 Effect.gen(function* () {
293 const previous = yield* Ref.modify(pendingUrlAuthActions, (current) => {
294 const next = new Map(current);
295 const existing = next.get(instanceId);
296 next.set(instanceId, { action: publishedAction, consent, expiresAt });
297 return [existing, next] as const;
298 });
299 if (previous !== undefined) {
300 yield* Deferred.succeed(previous.consent, false).pipe(Effect.ignore);
301 }
302 yield* publishUrlAuthAction(instanceId, publishedAction);
303 }),
304 );
305 return yield* Deferred.await(consent).pipe(
306 Effect.timeoutOrElse({
307 duration: URL_AUTH_ACTION_TTL,
308 orElse: () => Effect.succeed(false),
309 }),
310 );
311 }).pipe(Effect.ensuring(cleanup));
312 },
313 );
314
315 const acceptUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["acceptUrlAuthentication"] =
316 Effect.fn("AcpRegistryRuntimeCoordinator.acceptUrlAuthentication")(function* (input) {
317 const pending = (yield* Ref.get(pendingUrlAuthActions)).get(input.instanceId);
318 if (pending?.action.elicitationId !== input.elicitationId) return false;
319 if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, pending.expiresAt)) {
320 yield* Deferred.succeed(pending.consent, false).pipe(Effect.ignore);
321 return false;
322 }
323 return yield* Deferred.succeed(pending.consent, true);
324 });
325
326 const getUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["getUrlAuthAction"] = Effect.fn(
327 "AcpRegistryRuntimeCoordinator.getUrlAuthAction",
328 )(function* (instanceId) {
329 return Option.fromNullishOr((yield* Ref.get(pendingUrlAuthActions)).get(instanceId)?.action);
330 });
331
332 const watchUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["watchUrlAuthAction"] = (
333 instanceId,
334 onUpdate,
335 ) =>
336 Effect.scoped(
337 Effect.gen(function* () {
338 const subscription = yield* PubSub.subscribe(urlAuthActionUpdates);
339 const current = yield* getUrlAuthAction(instanceId);
340 if (Option.isSome(current)) {
341 yield* onUpdate(current.value);
342 }
343 yield* Stream.fromSubscription(subscription).pipe(
344 Stream.filter((update) => update.instanceId === instanceId),
345 Stream.runForEach((update) => onUpdate(update.action)),
346 );
347 }),
348 );
349
350 return AcpRegistryRuntimeCoordinator.of({
351 withForegroundStartup,
352 runBackgroundProbe,
353 withSessionMutation,
354 clearAvailableCommands,
355 publishAvailableCommands,
356 getAvailableCommands,
357 watchAvailableCommands,
358 clearLiveConfiguration,
359 publishLiveConfiguration,
360 getLiveConfiguration,
361 watchLiveConfiguration,
362 requestUrlAuthentication,
363 acceptUrlAuthentication,
364 getUrlAuthAction,
365 watchUrlAuthAction,
366 });
367});
368
369const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make);
apps/server/src/provider/acp/AcpRegistrySupport.test.ts deleted-1728
......@@ -1,1728 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
4import {
5 HostProcessArchitecture,
6 HostProcessEnvironment,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import { SpawnExecutableResolution } from "@t3tools/shared/shell";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
17import { HttpClient, HttpClientResponse } from "effect/http";
18import * as TestClock from "effect/testing/TestClock";
19import { ChildProcess, ChildProcessSpawner } from "effect/process";
20
21import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
22import * as ServerSettings from "../../serverSettings.ts";
23
24const registryUrl = "https://registry.test/registry.json";
25const archiveUrl = "https://registry.test/example-agent.bin";
26const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings);
27const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
28
29function makeAgent(
30 distribution: AcpRegistrySupport.AcpRegistryAgent["distribution"],
31): AcpRegistrySupport.AcpRegistryAgent {
32 return {
33 id: "example-agent",
34 name: "Example Agent",
35 version: "1.2.3",
36 description: "ACP Registry test agent",
37 distribution,
38 };
39}
40
41function makeRegistry(agent: AcpRegistrySupport.AcpRegistryAgent): string {
42 return JSON.stringify({ version: "1.0.0", agents: [agent] });
43}
44
45function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings {
46 return decodeAcpRegistrySettings({
47 agentId: "example-agent",
48 ...input,
49 });
50}
51
52function layerResolver(
53 execute: Parameters<typeof HttpClient.make>[0],
54 environment: NodeJS.ProcessEnv = process.env,
55) {
56 return Layer.mergeAll(
57 NodeServices.layer,
58 ServerSettings.layerTest(),
59 Layer.succeed(HostProcessPlatform, "linux"),
60 Layer.succeed(HostProcessArchitecture, "x64"),
61 Layer.succeed(HostProcessEnvironment, environment),
62 Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)),
63 );
64}
65
66const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* (
67 rootDirectory: string,
68) {
69 const fileSystem = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;
71 const toolchainBin = path.join(rootDirectory, "fake-node", "bin");
72 const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm");
73 const globalBin = path.join(globalPrefix, "bin");
74 const executablePath = path.join(globalBin, "example-agent");
75 const npmPath = path.join(toolchainBin, "npm");
76 const logPath = path.join(rootDirectory, "npm.log");
77 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
78 yield* fileSystem.makeDirectory(globalPrefix, { recursive: true });
79 yield* fileSystem.writeFileString(logPath, "");
80 yield* fileSystem.copyFile(
81 yield* path.fromFileUrl(
82 new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url),
83 ),
84 npmPath,
85 );
86 yield* fileSystem.chmod(npmPath, 0o755);
87 return {
88 environment: {
89 ...process.env,
90 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
91 FAKE_NPM_LOG: logPath,
92 FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"),
93 FAKE_NPM_MANIFEST: encodeUnknownJson({
94 name: "@example/acp",
95 version: "1.2.3",
96 bin: { "example-agent": "dist/cli.js" },
97 }),
98 } satisfies NodeJS.ProcessEnv,
99 executablePath,
100 globalBin,
101 logPath,
102 npmPath,
103 };
104});
105
106const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* (
107 rootDirectory: string,
108) {
109 const fileSystem = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
111 const toolchainBin = path.join(rootDirectory, "fake-uv", "bin");
112 const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin");
113 const executablePath = path.join(globalBin, "fast-agent");
114 const uvPath = path.join(toolchainBin, "uv");
115 const logPath = path.join(rootDirectory, "uv.log");
116 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
117 yield* fileSystem.writeFileString(logPath, "");
118 yield* fileSystem.copyFile(
119 yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)),
120 uvPath,
121 );
122 yield* fileSystem.chmod(uvPath, 0o755);
123 return {
124 environment: {
125 ...process.env,
126 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
127 FAKE_UV_LOG: logPath,
128 FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"),
129 FAKE_UV_EXECUTABLE: executablePath,
130 } satisfies NodeJS.ProcessEnv,
131 executablePath,
132 globalBin,
133 logPath,
134 uvPath,
135 };
136});
137
138describe("AcpRegistrySupport", () => {
139 it("preserves the registry failure when translating it for clients", () => {
140 const cause = new Error("registry unavailable");
141 const failure = new AcpRegistrySupport.AcpRegistryError({
142 reason: "registry_unavailable",
143 detail: "Could not load the ACP Registry.",
144 cause,
145 });
146
147 expect(AcpRegistrySupport.toAcpRegistryOperationError(failure)).toMatchObject({
148 reason: "registry_unavailable",
149 message: "Could not load the ACP Registry.",
150 cause: failure,
151 });
152 });
153
154 it("maps supported Node platforms to ACP Registry target keys", () => {
155 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe(
156 "darwin-aarch64",
157 );
158 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "x64")).toBe(
159 "linux-x86_64",
160 );
161 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe(
162 "windows-aarch64",
163 );
164 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined();
165 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined();
166 });
167
168 it("selects the preferred compatible distribution", () => {
169 const agent = makeAgent({
170 binary: {
171 "linux-x86_64": {
172 archive: archiveUrl,
173 cmd: "./bin/example-agent",
174 args: ["acp"],
175 },
176 },
177 npx: {
178 package: "@example/acp@1.2.3",
179 args: ["--stdio"],
180 },
181 });
182
183 expect(
184 AcpRegistrySupport.resolveAcpRegistryDistribution({
185 agent,
186 preference: "auto",
187 platformTarget: "linux-x86_64",
188 }),
189 ).toMatchObject({ kind: "binary", args: ["acp"] });
190 expect(
191 AcpRegistrySupport.resolveAcpRegistryDistribution({
192 agent,
193 preference: "npx",
194 platformTarget: "linux-x86_64",
195 }),
196 ).toEqual({
197 kind: "npx",
198 packageName: "@example/acp@1.2.3",
199 args: ["--stdio"],
200 env: {},
201 });
202 expect(
203 AcpRegistrySupport.resolveAcpRegistryDistribution({
204 agent,
205 preference: "binary",
206 platformTarget: "darwin-aarch64",
207 }),
208 ).toBeUndefined();
209 });
210
211 it.effect("resolves a local command on the selected environment without the registry", () => {
212 const requests: Array<string> = [];
213 return Effect.gen(function* () {
214 const fileSystem = yield* FileSystem.FileSystem;
215 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" });
216 const commandPath = `${cacheDir}/dsh`;
217 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
218 yield* fileSystem.chmod(commandPath, 0o755);
219 const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" };
220 const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" };
221 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
222 cacheDir,
223 toolsDir: `${cacheDir}/tools`,
224 registryUrl,
225 }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment));
226 const localSettings = decodeAcpRegistrySettings({
227 source: "local",
228 commandPath: "dsh",
229 commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
230 });
231
232 expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" });
233 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
234 status: "ready",
235 agentId: "dsh",
236 version: null,
237 distribution: "local",
238 });
239 const resolved = yield* resolver.resolve(localSettings, "/workspace", environment);
240 expect(resolved).toEqual({
241 distribution: "local",
242 spawn: {
243 command: commandPath,
244 args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
245 cwd: "/workspace",
246 env: environment,
247 shell: false,
248 },
249 });
250 expect(requests).toEqual([]);
251 expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false);
252 }).pipe(
253 Effect.scoped,
254 Effect.provide(
255 layerResolver((request) => {
256 requests.push(request.url);
257 return Effect.die("unexpected registry request for a local provider");
258 }),
259 ),
260 );
261 });
262
263 it.effect("resolves a local executable path and inherits the host environment", () =>
264 Effect.gen(function* () {
265 const fileSystem = yield* FileSystem.FileSystem;
266 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" });
267 const commandPath = `${cacheDir}/dsh wrapper`;
268 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
269 yield* fileSystem.chmod(commandPath, 0o755);
270 const environment = { PATH: "/unused", INHERITED: "host" };
271 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
272 cacheDir,
273 toolsDir: `${cacheDir}/tools`,
274 registryUrl,
275 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
276 const resolved = yield* resolver.resolve(
277 decodeAcpRegistrySettings({ source: "local", commandPath }),
278 "/workspace",
279 );
280
281 expect(resolved.spawn).toEqual({
282 command: commandPath,
283 args: [],
284 cwd: "/workspace",
285 env: environment,
286 shell: false,
287 });
288 expect(resolved.agent).toBeUndefined();
289
290 for (const extension of [".cmd", ".BAT"]) {
291 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
292 cacheDir,
293 toolsDir: `${cacheDir}/tools`,
294 registryUrl,
295 }).pipe(
296 Effect.provideService(HostProcessPlatform, "win32"),
297 Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`),
298 );
299 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" });
300 for (const failure of [
301 yield* windowsResolver.inspect(localSettings).pipe(Effect.flip),
302 yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip),
303 ]) {
304 expect(failure).toMatchObject({
305 reason: "runner_unavailable",
306 detail: expect.stringContaining("node.exe"),
307 });
308 }
309 }
310 }).pipe(
311 Effect.scoped,
312 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
313 ),
314 );
315
316 it.effect("reports missing or non-executable local commands without registry fallback", () =>
317 Effect.gen(function* () {
318 const fileSystem = yield* FileSystem.FileSystem;
319 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
320 prefix: "t3-acp-local-missing-",
321 });
322 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
323 cacheDir,
324 toolsDir: `${cacheDir}/tools`,
325 registryUrl,
326 });
327 const environment = { PATH: cacheDir };
328 const unconfigured = decodeAcpRegistrySettings({ source: "local" });
329 expect(yield* resolver.inspect(unconfigured, environment)).toEqual({
330 status: "unconfigured",
331 });
332 expect(
333 yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip),
334 ).toMatchObject({ reason: "agent_not_configured" });
335
336 const nonExecutable = `${cacheDir}/non-executable`;
337 yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n");
338 yield* fileSystem.chmod(nonExecutable, 0o644);
339 for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) {
340 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath });
341 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
342 status: "missing_runner",
343 version: null,
344 distribution: "local",
345 });
346 expect(
347 yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip),
348 ).toMatchObject({
349 reason: "runner_unavailable",
350 detail: "Local ACP executable is not available on this environment's PATH.",
351 });
352 }
353 }).pipe(
354 Effect.scoped,
355 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
356 ),
357 );
358
359 it.effect("resolves command overrides while preserving registry args and environment", () => {
360 const agent = makeAgent({
361 binary: {
362 "linux-x86_64": {
363 archive: archiveUrl,
364 cmd: "./bin/example-agent",
365 args: ["acp", "--stdio"],
366 env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" },
367 },
368 },
369 });
370 const requests: Array<string> = [];
371 return Effect.gen(function* () {
372 const fileSystem = yield* FileSystem.FileSystem;
373 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
374 prefix: "t3-acp-registry-override-",
375 });
376 const commandPath = `${cacheDir}/example-agent`;
377 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
378 yield* fileSystem.chmod(commandPath, 0o755);
379 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
380 cacheDir,
381 toolsDir: `${cacheDir}/tools`,
382 registryUrl,
383 });
384 const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", {
385 HOST_VALUE: "host",
386 OVERRIDE_ME: "host",
387 });
388
389 expect(resolved.distribution).toBe("binary");
390 expect(resolved.spawn).toEqual({
391 command: commandPath,
392 args: ["acp", "--stdio"],
393 cwd: "/workspace",
394 env: {
395 HOST_VALUE: "host",
396 OVERRIDE_ME: "registry",
397 REGISTRY_VALUE: "registry",
398 },
399 });
400 expect(requests).toEqual([registryUrl]);
401 }).pipe(
402 Effect.scoped,
403 Effect.provide(
404 layerResolver((request) => {
405 requests.push(request.url);
406 return Effect.succeed(
407 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
408 );
409 }),
410 ),
411 );
412 });
413
414 it.effect("installs into T3 home a package and launches its exposed command", () => {
415 const agent = makeAgent({
416 npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] },
417 });
418 return Effect.gen(function* () {
419 const fileSystem = yield* FileSystem.FileSystem;
420 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
421 prefix: "t3-acp-registry-global-package-",
422 });
423 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
424 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
425 cacheDir,
426 toolsDir: `${cacheDir}/tools`,
427 registryUrl,
428 }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment));
429
430 const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
431 const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
432
433 expect(first.spawn).toMatchObject({
434 command: toolchain.executablePath,
435 args: ["--stdio"],
436 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
437 });
438 expect(second.spawn.command).toBe(toolchain.executablePath);
439 const npmCommands = yield* fileSystem.readFileString(toolchain.logPath);
440 expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1);
441 expect(npmCommands).toContain("install --global @example/acp@V1.2.3");
442 }).pipe(
443 Effect.scoped,
444 Effect.provide(
445 layerResolver((request) =>
446 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
447 ),
448 ),
449 );
450 });
451
452 it.effect("uses its managed prefix despite a system-owned npm prefix", () => {
453 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
454 return Effect.gen(function* () {
455 const fileSystem = yield* FileSystem.FileSystem;
456 const path = yield* Path.Path;
457 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
458 prefix: "t3-acp-registry-user-global-package-",
459 });
460 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
461 const systemPrefix = path.join(cacheDir, "system-global");
462 yield* fileSystem.makeDirectory(systemPrefix);
463 yield* fileSystem.chmod(systemPrefix, 0o555);
464 const userHome = path.join(cacheDir, "home");
465 const environment = {
466 ...toolchain.environment,
467 HOME: userHome,
468 FAKE_NPM_PREFIX: systemPrefix,
469 };
470 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
471 cacheDir,
472 toolsDir: `${cacheDir}/tools`,
473 registryUrl,
474 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
475
476 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
477 const userGlobalBin = toolchain.globalBin;
478 expect(resolved.spawn).toMatchObject({
479 command: path.join(userGlobalBin, "example-agent"),
480 env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) },
481 });
482 }).pipe(
483 Effect.scoped,
484 Effect.provide(
485 layerResolver((request) =>
486 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
487 ),
488 ),
489 );
490 });
491
492 it.effect("installs into T3 home a uv tool and launches its exposed command", () => {
493 const agent = makeAgent({
494 uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] },
495 });
496 return Effect.gen(function* () {
497 const fileSystem = yield* FileSystem.FileSystem;
498 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
499 prefix: "t3-acp-registry-global-uv-tool-",
500 });
501 const toolchain = yield* makeFakeUvToolchain(cacheDir);
502 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
503 cacheDir,
504 toolsDir: `${cacheDir}/tools`,
505 registryUrl,
506 });
507
508 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
509
510 expect(resolved.spawn).toMatchObject({
511 command: toolchain.executablePath,
512 args: ["--acp"],
513 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
514 });
515 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
516 "tool install --force fast-agent-acp==V0.10.1",
517 );
518 }).pipe(
519 Effect.scoped,
520 Effect.provide(
521 layerResolver((request) =>
522 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
523 ),
524 ),
525 );
526 });
527
528 it.effect("honors an exact Windows PATH override when launching a global package", () => {
529 const agent = makeAgent({
530 uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] },
531 });
532 return Effect.gen(function* () {
533 const fileSystem = yield* FileSystem.FileSystem;
534 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
535 prefix: "t3-acp-registry-windows-package-path-",
536 });
537 const toolchain = yield* makeFakeUvToolchain(cacheDir);
538 const linuxResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
539 cacheDir,
540 toolsDir: `${cacheDir}/tools`,
541 registryUrl,
542 });
543 yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment);
544
545 const windowsEnvironment = {
546 ...toolchain.environment,
547 Path: "C:\\host\\bin",
548 PATH: "C:\\provider\\bin",
549 };
550 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
551 cacheDir,
552 toolsDir: `${cacheDir}/tools`,
553 registryUrl,
554 }).pipe(
555 Effect.provideService(HostProcessPlatform, "win32"),
556 Effect.provideService(SpawnExecutableResolution, (command) => {
557 if (command === "uv") return toolchain.uvPath;
558 if (command === "fast-agent") return toolchain.executablePath;
559 return undefined;
560 }),
561 );
562 const resolved = yield* windowsResolver.resolve(
563 settings(),
564 "C:\\workspace",
565 windowsEnvironment,
566 );
567
568 expect(resolved.spawn).toMatchObject({
569 env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` },
570 });
571 }).pipe(
572 Effect.scoped,
573 Effect.provide(
574 layerResolver((request) =>
575 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
576 ),
577 ),
578 );
579 });
580
581 it.effect("installs and reuses a registry binary in the managed cache", () => {
582 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
583 const agent = makeAgent({
584 binary: {
585 "linux-x86_64": {
586 archive: archiveUrl,
587 cmd: "./bin/example-agent",
588 args: ["acp"],
589 // SHA-256 of binaryBytes.
590 sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7",
591 },
592 },
593 });
594 const requests: Array<string> = [];
595 return Effect.gen(function* () {
596 const fileSystem = yield* FileSystem.FileSystem;
597 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
598 prefix: "t3-acp-registry-install-",
599 });
600 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
601 cacheDir,
602 toolsDir: `${cacheDir}/tools`,
603 registryUrl,
604 });
605 const first = yield* resolver.resolve(settings(), "/workspace");
606 const second = yield* resolver.resolve(settings(), "/workspace");
607
608 expect(first.spawn.command).toBe(second.spawn.command);
609 expect(first.spawn.command).toContain(
610 "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent",
611 );
612 expect(yield* fileSystem.readFileString(first.spawn.command)).toBe(
613 "#!/bin/sh\necho example\n",
614 );
615 expect(requests).toEqual([registryUrl, archiveUrl]);
616 }).pipe(
617 Effect.scoped,
618 Effect.provide(
619 layerResolver((request) => {
620 requests.push(request.url);
621 const response =
622 request.url === registryUrl
623 ? new Response(makeRegistry(agent))
624 : new Response(
625 new ReadableStream<Uint8Array>({
626 start(controller) {
627 controller.enqueue(binaryBytes.slice(0, 8));
628 controller.enqueue(binaryBytes.slice(8));
629 controller.close();
630 },
631 }),
632 );
633 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
634 }),
635 ),
636 );
637 });
638
639 it.effect(
640 "prepares the registry version despite an older PATH binary and permits explicit overrides",
641 () => {
642 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n");
643 const agent = makeAgent({
644 binary: {
645 "linux-x86_64": {
646 archive: archiveUrl,
647 cmd: "./bin/example-agent",
648 args: ["acp"],
649 // SHA-256 of binaryBytes.
650 sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31",
651 },
652 },
653 });
654 const requests: Array<string> = [];
655 return Effect.gen(function* () {
656 const fileSystem = yield* FileSystem.FileSystem;
657 const path = yield* Path.Path;
658 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
659 prefix: "t3-acp-registry-system-",
660 });
661 const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({
662 prefix: "t3-acp-registry-system-bin-",
663 });
664 const systemBinary = path.join(systemBinDir, "example-agent");
665 yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n");
666 yield* fileSystem.chmod(systemBinary, 0o755);
667 const environment = { PATH: systemBinDir };
668
669 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
670 cacheDir,
671 toolsDir: `${cacheDir}/tools`,
672 registryUrl,
673 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
674 yield* resolver.search({ query: "example" });
675 expect(yield* resolver.inspect(settings(), environment)).toMatchObject({
676 status: "unprepared",
677 });
678
679 const overridden = settings({ commandPath: systemBinary });
680 expect(yield* resolver.inspect(overridden, environment)).toMatchObject({
681 status: "ready",
682 version: null,
683 });
684 const custom = yield* resolver.resolve(overridden, "/workspace", environment);
685 expect(custom.spawn.command).toBe(systemBinary);
686 expect(custom.spawn.args).toEqual(["acp"]);
687 expect(requests).toEqual([registryUrl]);
688
689 yield* resolver.prepare({ agentId: "example-agent" });
690 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
691 expect(resolved.spawn.command).not.toBe(systemBinary);
692 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
693 "#!/bin/sh\necho managed\n",
694 );
695 expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n");
696
697 const inspection = yield* resolver.inspect(settings(), environment);
698 expect(inspection).toMatchObject({
699 status: "ready",
700 distribution: "binary",
701 version: "1.2.3",
702 });
703
704 expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]);
705 }).pipe(
706 Effect.scoped,
707 Effect.provide(
708 layerResolver((request) => {
709 requests.push(request.url);
710 return Effect.succeed(
711 HttpClientResponse.fromWeb(
712 request,
713 request.url === registryUrl
714 ? new Response(makeRegistry(agent))
715 : new Response(binaryBytes.buffer as ArrayBuffer),
716 ),
717 );
718 }),
719 ),
720 );
721 },
722 );
723
724 it.effect("installs a tar archive containing a root directory entry", () => {
725 const downloadUrl = "https://registry.test/agent.tar.gz";
726 const agent = makeAgent({
727 binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } },
728 });
729 return Effect.gen(function* () {
730 const fileSystem = yield* FileSystem.FileSystem;
731 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" });
732 const source = `${cacheDir}/source`;
733 yield* fileSystem.makeDirectory(source);
734 yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n");
735 const archivePath = `${cacheDir}/agent.tar.gz`;
736 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
737 const tar = yield* spawner.spawn(
738 ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]),
739 );
740 expect(yield* tar.exitCode).toBe(0);
741 const archive = yield* fileSystem.readFile(archivePath);
742 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
743 cacheDir,
744 toolsDir: `${cacheDir}/tools`,
745 registryUrl,
746 }).pipe(
747 Effect.provideService(
748 HttpClient.HttpClient,
749 HttpClient.make((request) =>
750 Effect.succeed(
751 HttpClientResponse.fromWeb(
752 request,
753 request.url === registryUrl
754 ? new Response(makeRegistry(agent))
755 : new Response(archive),
756 ),
757 ),
758 ),
759 ),
760 );
761 yield* resolver.prepare({ agentId: agent.id });
762 const resolved = yield* resolver.resolve(settings(), "/workspace", {});
763 expect(resolved.spawn.command).toBe(
764 yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`),
765 );
766 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
767 "#!/bin/sh\necho managed\n",
768 );
769 }).pipe(
770 Effect.scoped,
771 Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())),
772 Effect.provideService(HostProcessPlatform, "linux"),
773 Effect.provideService(HostProcessArchitecture, "x64"),
774 );
775 });
776
777 it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => {
778 const exact = {
779 ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }),
780 id: "codex-acp",
781 name: "Codex",
782 authors: ["OpenAI", "Zed Industries"],
783 license: "Apache-2.0",
784 website: "https://example.test/codex",
785 repository: "https://example.test/codex/source",
786 icon: "https://example.test/codex.svg",
787 } satisfies AcpRegistrySupport.AcpRegistryAgent;
788 const descriptionMatch = {
789 ...makeAgent({ npx: { package: "other-agent@1.2.3" } }),
790 id: "other-agent",
791 name: "Other Agent",
792 description: "An adapter for Codex workflows",
793 } satisfies AcpRegistrySupport.AcpRegistryAgent;
794 const incompatible = {
795 ...makeAgent({
796 binary: {
797 "darwin-aarch64": { archive: archiveUrl, cmd: "agent" },
798 },
799 }),
800 id: "darwin-only",
801 name: "Codex Darwin",
802 } satisfies AcpRegistrySupport.AcpRegistryAgent;
803
804 return Effect.gen(function* () {
805 const fileSystem = yield* FileSystem.FileSystem;
806 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
807 prefix: "t3-acp-registry-search-",
808 });
809 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
810 cacheDir,
811 toolsDir: `${cacheDir}/tools`,
812 registryUrl,
813 });
814 const result = yield* resolver.search({ query: "codex" });
815
816 expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]);
817 expect(result.agents[0]).toMatchObject({
818 authors: ["OpenAI", "Zed Industries"],
819 distribution: "npx",
820 integrity: "registry",
821 license: "Apache-2.0",
822 });
823 }).pipe(
824 Effect.scoped,
825 Effect.provide(
826 layerResolver((request) =>
827 Effect.succeed(
828 HttpClientResponse.fromWeb(
829 request,
830 new Response(
831 JSON.stringify({
832 version: "1.0.0",
833 agents: [descriptionMatch, incompatible, exact],
834 }),
835 ),
836 ),
837 ),
838 ),
839 ),
840 );
841 });
842
843 it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => {
844 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
845 let requests = 0;
846 return Effect.gen(function* () {
847 const fileSystem = yield* FileSystem.FileSystem;
848 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
849 prefix: "t3-acp-registry-refresh-",
850 });
851 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
852 cacheDir,
853 toolsDir: `${cacheDir}/tools`,
854 registryUrl,
855 });
856 yield* Effect.all(
857 [resolver.search({ query: "example" }), resolver.search({ query: "example" })],
858 { concurrency: "unbounded" },
859 );
860 expect(requests).toBe(1);
861
862 yield* resolver.search({ query: "example" });
863 expect(requests).toBe(2);
864 }).pipe(
865 Effect.scoped,
866 Effect.provide(
867 layerResolver((request) => {
868 requests += 1;
869 return Effect.yieldNow.pipe(
870 Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
871 );
872 }),
873 ),
874 );
875 });
876
877 it.effect("filters runner recipes that the environment cannot prepare", () => {
878 const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
879 const binaryAgent = {
880 ...makeAgent({
881 binary: {
882 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
883 },
884 }),
885 id: "binary-agent",
886 name: "Binary Agent",
887 } satisfies AcpRegistrySupport.AcpRegistryAgent;
888 return Effect.gen(function* () {
889 const fileSystem = yield* FileSystem.FileSystem;
890 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
891 prefix: "t3-acp-registry-runner-filter-",
892 });
893 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
894 cacheDir,
895 toolsDir: `${cacheDir}/tools`,
896 registryUrl,
897 });
898 const result = yield* resolver.search({ query: "" });
899
900 expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]);
901 }).pipe(
902 Effect.scoped,
903 Effect.provide(
904 layerResolver(
905 (request) =>
906 Effect.succeed(
907 HttpClientResponse.fromWeb(
908 request,
909 new Response(
910 JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }),
911 ),
912 ),
913 ),
914 { PATH: "" },
915 ),
916 ),
917 );
918 });
919
920 it.effect("discards registry agents with blank names or unsafe versions", () => {
921 const distribution = {
922 binary: {
923 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
924 },
925 } satisfies AcpRegistrySupport.AcpRegistryAgent["distribution"];
926 const valid = makeAgent(distribution);
927 const blankName = { ...valid, id: "blank-name", name: " " };
928 const blankVersion = { ...valid, id: "blank-version", version: "\t" };
929 const parentVersion = { ...valid, id: "parent-version", version: ".." };
930 return Effect.gen(function* () {
931 const fileSystem = yield* FileSystem.FileSystem;
932 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
933 prefix: "t3-acp-registry-blank-fields-",
934 });
935 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
936 cacheDir,
937 toolsDir: `${cacheDir}/tools`,
938 registryUrl,
939 });
940 const result = yield* resolver.search({ query: "" });
941
942 expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]);
943 }).pipe(
944 Effect.scoped,
945 Effect.provide(
946 layerResolver((request) =>
947 Effect.succeed(
948 HttpClientResponse.fromWeb(
949 request,
950 new Response(
951 JSON.stringify({
952 version: "1.0.0",
953 agents: [blankName, blankVersion, parentVersion, valid],
954 }),
955 ),
956 ),
957 ),
958 ),
959 ),
960 );
961 });
962
963 it.effect("ignores unpinned runner recipes from the registry", () => {
964 const agent = makeAgent({ npx: { package: "@example/acp@latest" } });
965 return Effect.gen(function* () {
966 const fileSystem = yield* FileSystem.FileSystem;
967 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
968 prefix: "t3-acp-registry-unpinned-",
969 });
970 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
971 cacheDir,
972 toolsDir: `${cacheDir}/tools`,
973 registryUrl,
974 });
975 const result = yield* resolver.search({ query: "" });
976
977 expect(result.agents).toEqual([]);
978 }).pipe(
979 Effect.scoped,
980 Effect.provide(
981 layerResolver((request) =>
982 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
983 ),
984 ),
985 );
986 });
987
988 it.effect("rejects package syntax for the wrong runner", () => {
989 const invalidAgents = [
990 { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" },
991 ];
992 const validAgents = [
993 { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" },
994 { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" },
995 ];
996 return Effect.gen(function* () {
997 const fileSystem = yield* FileSystem.FileSystem;
998 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
999 prefix: "t3-acp-registry-runner-syntax-",
1000 });
1001 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1002 cacheDir,
1003 toolsDir: `${cacheDir}/tools`,
1004 registryUrl,
1005 });
1006 const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip);
1007 const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip);
1008 const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip);
1009
1010 expect(invalid.reason).toBe("agent_not_found");
1011 expect(validAt.reason).toBe("runner_unavailable");
1012 expect(validEquals.reason).toBe("runner_unavailable");
1013 }).pipe(
1014 Effect.scoped,
1015 Effect.provide(
1016 layerResolver(
1017 (request) =>
1018 Effect.succeed(
1019 HttpClientResponse.fromWeb(
1020 request,
1021 new Response(
1022 JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }),
1023 ),
1024 ),
1025 ),
1026 { PATH: "" },
1027 ),
1028 ),
1029 );
1030 });
1031
1032 it.effect("verifies declared SHA-256 before installing a binary", () => {
1033 const agent = makeAgent({
1034 binary: {
1035 "linux-x86_64": {
1036 archive: archiveUrl,
1037 cmd: "example-agent",
1038 sha256: "0".repeat(64),
1039 },
1040 },
1041 });
1042 const binaryBytes = new TextEncoder().encode("not the declared binary");
1043
1044 return Effect.gen(function* () {
1045 const fileSystem = yield* FileSystem.FileSystem;
1046 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1047 prefix: "t3-acp-registry-checksum-",
1048 });
1049 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1050 cacheDir,
1051 toolsDir: `${cacheDir}/tools`,
1052 registryUrl,
1053 });
1054 const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip);
1055
1056 expect(error.reason).toBe("checksum_mismatch");
1057 }).pipe(
1058 Effect.scoped,
1059 Effect.provide(
1060 layerResolver((request) =>
1061 Effect.succeed(
1062 HttpClientResponse.fromWeb(
1063 request,
1064 request.url === registryUrl
1065 ? new Response(makeRegistry(agent))
1066 : new Response(binaryBytes.buffer as ArrayBuffer),
1067 ),
1068 ),
1069 ),
1070 ),
1071 );
1072 });
1073
1074 it.effect("installs into T3 home package recipes during preparation", () => {
1075 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } });
1076 const requests: string[] = [];
1077
1078 return Effect.gen(function* () {
1079 const fileSystem = yield* FileSystem.FileSystem;
1080 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1081 prefix: "t3-acp-registry-runner-",
1082 });
1083 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1084 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1085 cacheDir,
1086 toolsDir: `${cacheDir}/tools`,
1087 registryUrl,
1088 }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment));
1089 const prepared = yield* resolver.prepare({ agentId: agent.id });
1090 expect(prepared).toEqual({
1091 agentId: "example-agent",
1092 version: "1.2.3",
1093 distribution: "npx",
1094 prepared: true,
1095 });
1096 expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true);
1097 expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false);
1098 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
1099 "install --global @example/acp@1.2.3",
1100 );
1101 expect(requests).toEqual([registryUrl]);
1102 }).pipe(
1103 Effect.scoped,
1104 Effect.provide(
1105 layerResolver((request) => {
1106 requests.push(request.url);
1107 return Effect.succeed(
1108 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1109 );
1110 }, process.env),
1111 ),
1112 );
1113 });
1114
1115 it.effect("falls back to a valid cached registry index when refresh fails", () => {
1116 const agent = makeAgent({
1117 npx: {
1118 package: "@example/acp@1.2.3",
1119 args: ["--stdio"],
1120 },
1121 });
1122 return Effect.gen(function* () {
1123 const fileSystem = yield* FileSystem.FileSystem;
1124 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1125 prefix: "t3-acp-registry-cache-",
1126 });
1127 const registryDirectory = `${cacheDir}/acp-registry`;
1128 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1129 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1130 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1131 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1132 cacheDir,
1133 toolsDir: `${cacheDir}/tools`,
1134 registryUrl,
1135 });
1136 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
1137
1138 expect(resolved.spawn).toMatchObject({
1139 command: toolchain.executablePath,
1140 args: ["--stdio"],
1141 });
1142 }).pipe(
1143 Effect.scoped,
1144 Effect.provide(
1145 layerResolver((request) =>
1146 Effect.succeed(
1147 HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })),
1148 ),
1149 ),
1150 ),
1151 );
1152 });
1153
1154 it.effect("rejects unsafe command paths before downloading an archive", () => {
1155 const agent = makeAgent({
1156 binary: {
1157 "linux-x86_64": {
1158 archive: archiveUrl,
1159 cmd: "../outside",
1160 },
1161 },
1162 });
1163 const requests: Array<string> = [];
1164 return Effect.gen(function* () {
1165 const fileSystem = yield* FileSystem.FileSystem;
1166 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1167 prefix: "t3-acp-registry-invalid-",
1168 });
1169 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1170 cacheDir,
1171 toolsDir: `${cacheDir}/tools`,
1172 registryUrl,
1173 });
1174 const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip);
1175
1176 expect(error.reason).toBe("archive_invalid");
1177 expect(requests).toEqual([registryUrl]);
1178 }).pipe(
1179 Effect.scoped,
1180 Effect.provide(
1181 layerResolver((request) => {
1182 requests.push(request.url);
1183 return Effect.succeed(
1184 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1185 );
1186 }),
1187 ),
1188 );
1189 });
1190
1191 it.effect("inspects from disk without waiting for a registry refresh", () => {
1192 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1193 let requests = 0;
1194 return Effect.gen(function* () {
1195 const fileSystem = yield* FileSystem.FileSystem;
1196 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1197 prefix: "t3-acp-registry-cold-inspect-",
1198 });
1199 const registryDirectory = `${cacheDir}/acp-registry`;
1200 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1201 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1202
1203 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1204 cacheDir,
1205 toolsDir: `${cacheDir}/tools`,
1206 registryUrl,
1207 });
1208 const inspection = yield* resolver.inspect(settings());
1209
1210 expect(inspection).toMatchObject({ status: "ready", agentId: agent.id });
1211 expect(requests).toBe(0);
1212 }).pipe(
1213 Effect.scoped,
1214 Effect.provide(
1215 layerResolver((request) => {
1216 requests += 1;
1217 return Effect.succeed(
1218 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1219 );
1220 }),
1221 ),
1222 );
1223 });
1224
1225 it.effect("fails a cold inspection immediately when no local registry is available", () => {
1226 let requests = 0;
1227 return Effect.gen(function* () {
1228 const fileSystem = yield* FileSystem.FileSystem;
1229 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1230 prefix: "t3-acp-registry-empty-inspect-",
1231 });
1232 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1233 cacheDir,
1234 toolsDir: `${cacheDir}/tools`,
1235 registryUrl,
1236 });
1237 const error = yield* resolver.inspect(settings()).pipe(Effect.flip);
1238
1239 expect(error.reason).toBe("registry_unavailable");
1240 expect(requests).toBe(0);
1241 }).pipe(
1242 Effect.scoped,
1243 Effect.provide(
1244 layerResolver((request) => {
1245 requests += 1;
1246 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused")));
1247 }),
1248 ),
1249 );
1250 });
1251
1252 it.effect("uses the effective provider environment for inspection and resolution", () => {
1253 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1254 return Effect.gen(function* () {
1255 const fileSystem = yield* FileSystem.FileSystem;
1256 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1257 prefix: "t3-acp-registry-effective-env-",
1258 });
1259 const registryDirectory = `${cacheDir}/acp-registry`;
1260 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1261 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1262 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1263
1264 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1265 cacheDir,
1266 toolsDir: `${cacheDir}/tools`,
1267 registryUrl,
1268 });
1269 const hostInspection = yield* resolver.inspect(settings());
1270 const providerEnvironment = toolchain.environment;
1271 const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment);
1272 const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment);
1273
1274 expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" });
1275 expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" });
1276 expect(resolved.spawn).toMatchObject({
1277 command: toolchain.executablePath,
1278 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
1279 });
1280 }).pipe(
1281 Effect.scoped,
1282 Effect.provide(
1283 layerResolver(
1284 (request) =>
1285 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1286 { PATH: "" },
1287 ),
1288 ),
1289 );
1290 });
1291
1292 it.effect("requires a regular executable file in the managed binary cache", () => {
1293 const agent = makeAgent({
1294 binary: {
1295 "linux-x86_64": {
1296 archive: archiveUrl,
1297 cmd: "bin/example-agent",
1298 },
1299 },
1300 });
1301 return Effect.gen(function* () {
1302 const fileSystem = yield* FileSystem.FileSystem;
1303 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1304 prefix: "t3-acp-registry-non-file-",
1305 });
1306 const registryDirectory = `${cacheDir}/acp-registry`;
1307 const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`;
1308 yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true });
1309 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1310 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1311
1312 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1313 cacheDir,
1314 toolsDir: `${cacheDir}/tools`,
1315 registryUrl,
1316 });
1317 const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1318
1319 expect(directoryError.reason).toBe("archive_invalid");
1320
1321 yield* fileSystem.remove(fakeExecutable, { recursive: true });
1322 yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n");
1323 yield* fileSystem.chmod(fakeExecutable, 0o644);
1324 const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1325
1326 expect(modeError.reason).toBe("archive_invalid");
1327
1328 yield* fileSystem.chmod(fakeExecutable, 0o755);
1329 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1330 }).pipe(
1331 Effect.scoped,
1332 Effect.provide(
1333 layerResolver((request) =>
1334 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1335 ),
1336 ),
1337 );
1338 });
1339
1340 it.effect.each([
1341 { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true },
1342 { name: "registry", source: "registry", agentId: "example-agent", referenced: true },
1343 { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true },
1344 { name: "local command", source: "local", agentId: "example-agent", referenced: false },
1345 ] as const)(
1346 "checks $name references before uninstalling only managed binaries",
1347 ({ source, agentId, referenced }) => {
1348 return Effect.gen(function* () {
1349 const fileSystem = yield* FileSystem.FileSystem;
1350 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1351 prefix: "t3-acp-registry-uninstall-",
1352 });
1353 const agentRoot = `${cacheDir}/tools/example-agent`;
1354 const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`;
1355 yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true });
1356 yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary");
1357 yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, {
1358 recursive: true,
1359 });
1360 yield* fileSystem.writeFileString(runnerCache, "{}");
1361
1362 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1363 cacheDir,
1364 toolsDir: `${cacheDir}/tools`,
1365 registryUrl,
1366 });
1367 const serverSettings = yield* ServerSettings.ServerSettingsService;
1368 const instanceId = ProviderInstanceId.make("uninstall-reference");
1369 yield* serverSettings.updateProviderInstance({
1370 operation: "upsert",
1371 instanceId,
1372 instance: {
1373 driver: ProviderDriverKind.make("acpRegistry"),
1374 displayName: "Uninstall reference",
1375 enabled: true,
1376 config: {
1377 agentId,
1378 commandPath: "/local/agent",
1379 ...(source ? { source } : {}),
1380 },
1381 },
1382 });
1383 const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1384 expect(first).toEqual({ agentId: "example-agent", removed: !referenced });
1385 expect(yield* fileSystem.exists(agentRoot)).toBe(referenced);
1386 yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId });
1387 const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1388
1389 expect(second).toEqual({ agentId: "example-agent", removed: referenced });
1390 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1391 agentId: "example-agent",
1392 removed: false,
1393 });
1394 expect(yield* fileSystem.exists(agentRoot)).toBe(false);
1395 expect(yield* fileSystem.exists(runnerCache)).toBe(true);
1396 }).pipe(
1397 Effect.scoped,
1398 Effect.provide(
1399 layerResolver((request) =>
1400 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))),
1401 ),
1402 ),
1403 );
1404 },
1405 );
1406
1407 it.effect("keeps managed package installs when removing the same agent's binaries", () =>
1408 Effect.gen(function* () {
1409 const fileSystem = yield* FileSystem.FileSystem;
1410 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1411 prefix: "t3-acp-uninstall-packages-",
1412 });
1413 const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`;
1414 yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true });
1415 yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary");
1416 yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true });
1417 yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command");
1418 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1419 cacheDir,
1420 toolsDir: `${cacheDir}/tools`,
1421 registryUrl,
1422 });
1423 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1424 agentId: "example-agent",
1425 removed: true,
1426 });
1427 expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false);
1428 expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe(
1429 "package command",
1430 );
1431 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1432 agentId: "example-agent",
1433 removed: false,
1434 });
1435 }).pipe(
1436 Effect.scoped,
1437 Effect.provide(layerResolver(() => Effect.die("unexpected HTTP request"))),
1438 ),
1439 );
1440
1441 it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => {
1442 const agent = makeAgent({
1443 binary: {
1444 "linux-x86_64": {
1445 archive: archiveUrl,
1446 cmd: "./bin/example-agent",
1447 },
1448 },
1449 });
1450 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1451 return Effect.gen(function* () {
1452 const downloadStarted = yield* Deferred.make<void>();
1453 const releaseDownload = yield* Deferred.make<void>();
1454 return yield* Effect.gen(function* () {
1455 const fileSystem = yield* FileSystem.FileSystem;
1456 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1457 prefix: "t3-acp-registry-uninstall-race-",
1458 });
1459 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1460 cacheDir,
1461 toolsDir: `${cacheDir}/tools`,
1462 registryUrl,
1463 });
1464
1465 const prepareFiber = yield* resolver
1466 .prepare({ agentId: agent.id })
1467 .pipe(Effect.forkChild({ startImmediately: true }));
1468 yield* Deferred.await(downloadStarted);
1469 const uninstallFiber = yield* resolver
1470 .uninstallManagedBinary({ agentId: agent.id })
1471 .pipe(Effect.forkChild({ startImmediately: true }));
1472
1473 expect(uninstallFiber.pollUnsafe()).toBeUndefined();
1474 yield* Deferred.succeed(releaseDownload, undefined);
1475
1476 expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true });
1477 expect(yield* Fiber.join(uninstallFiber)).toEqual({
1478 agentId: agent.id,
1479 removed: false,
1480 });
1481 const agentRoot = `${cacheDir}/tools/${agent.id}`;
1482 expect(yield* fileSystem.exists(agentRoot)).toBe(true);
1483
1484 const serverSettings = yield* ServerSettings.ServerSettingsService;
1485 const instanceId = ProviderInstanceId.make("registry-reference");
1486 yield* serverSettings.updateProviderInstance({
1487 operation: "upsert",
1488 instanceId,
1489 instance: {
1490 driver: ProviderDriverKind.make("acpRegistry"),
1491 displayName: "Registry reference",
1492 enabled: true,
1493 config: { agentId: agent.id },
1494 },
1495 });
1496 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1497 agentId: agent.id,
1498 removed: false,
1499 });
1500 yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId });
1501 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1502 agentId: agent.id,
1503 removed: true,
1504 });
1505 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1506 agentId: agent.id,
1507 removed: false,
1508 });
1509 }).pipe(
1510 Effect.scoped,
1511 Effect.provide(
1512 layerResolver((request) => {
1513 if (request.url === registryUrl) {
1514 return Effect.succeed(
1515 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1516 );
1517 }
1518 return Deferred.succeed(downloadStarted, undefined).pipe(
1519 Effect.andThen(Deferred.await(releaseDownload)),
1520 Effect.as(
1521 HttpClientResponse.fromWeb(
1522 request,
1523 new Response(binaryBytes.buffer as ArrayBuffer),
1524 ),
1525 ),
1526 );
1527 }),
1528 ),
1529 );
1530 });
1531 });
1532
1533 it.effect("reserves a prepared binary until a configured instance inspects it", () => {
1534 const agent = makeAgent({
1535 binary: {
1536 "linux-x86_64": {
1537 archive: archiveUrl,
1538 cmd: "./bin/example-agent",
1539 },
1540 },
1541 });
1542 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1543 return Effect.gen(function* () {
1544 const fileSystem = yield* FileSystem.FileSystem;
1545 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1546 prefix: "t3-acp-registry-uninstall-reservation-",
1547 });
1548 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1549 cacheDir,
1550 toolsDir: `${cacheDir}/tools`,
1551 registryUrl,
1552 });
1553
1554 yield* resolver.prepare({ agentId: agent.id });
1555 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1556 agentId: agent.id,
1557 removed: false,
1558 });
1559 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1560 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1561 agentId: agent.id,
1562 removed: true,
1563 });
1564 }).pipe(
1565 Effect.scoped,
1566 Effect.provide(
1567 layerResolver((request) =>
1568 Effect.succeed(
1569 HttpClientResponse.fromWeb(
1570 request,
1571 request.url === registryUrl
1572 ? new Response(makeRegistry(agent))
1573 : new Response(binaryBytes.buffer as ArrayBuffer),
1574 ),
1575 ),
1576 ),
1577 ),
1578 );
1579 });
1580
1581 it.effect("expires an abandoned prepared-binary reservation", () => {
1582 const agent = makeAgent({
1583 binary: {
1584 "linux-x86_64": {
1585 archive: archiveUrl,
1586 cmd: "./bin/example-agent",
1587 },
1588 },
1589 });
1590 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1591 return Effect.gen(function* () {
1592 const fileSystem = yield* FileSystem.FileSystem;
1593 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1594 prefix: "t3-acp-registry-uninstall-expiry-",
1595 });
1596 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1597 cacheDir,
1598 toolsDir: `${cacheDir}/tools`,
1599 registryUrl,
1600 });
1601
1602 yield* resolver.prepare({ agentId: agent.id });
1603 yield* TestClock.adjust("31 seconds");
1604 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1605 agentId: agent.id,
1606 removed: true,
1607 });
1608 }).pipe(
1609 Effect.scoped,
1610 Effect.provide(
1611 layerResolver((request) =>
1612 Effect.succeed(
1613 HttpClientResponse.fromWeb(
1614 request,
1615 request.url === registryUrl
1616 ? new Response(makeRegistry(agent))
1617 : new Response(binaryBytes.buffer as ArrayBuffer),
1618 ),
1619 ),
1620 ),
1621 ),
1622 );
1623 });
1624});
1625
1626describe("acpRegistryManagedBinaryDirectories", () => {
1627 it.effect("lists managed package and binary command directories", () =>
1628 Effect.gen(function* () {
1629 const fileSystem = yield* FileSystem.FileSystem;
1630 const path = yield* Path.Path;
1631 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1632 prefix: "t3-acp-registry-bins-",
1633 });
1634 const install = (agent: string, version: string, target: string, commandDirectory = "") =>
1635 fileSystem.makeDirectory(
1636 path.join(cacheDir, "tools", agent, version, target, commandDirectory),
1637 { recursive: true },
1638 );
1639 yield* install("kimi", "1.49.0", "linux-x86_64", "bin");
1640 yield* install("kimi", "1.50.0", "linux-x86_64", "cmd");
1641 yield* install("kimi", "1.50.0", "darwin-aarch64");
1642 yield* install("other-agent", "0.2.0", "darwin-aarch64");
1643 const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin");
1644 const geminiCommand = path.join(globalBin, "gemini");
1645 const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs");
1646 yield* fileSystem.makeDirectory(globalBin, { recursive: true });
1647 yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true });
1648 yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n");
1649 yield* fileSystem.chmod(geminiCommand, 0o755);
1650 yield* fileSystem.writeFileString(
1651 path.join(receiptsDirectory, "gemini.json"),
1652 encodeUnknownJson({
1653 agentId: "gemini",
1654 agentVersion: "0.56.0",
1655 distribution: "npx",
1656 packageSpec: "@google/gemini-cli@0.56.0",
1657 managerPath: "/usr/bin/npm",
1658 binDirectory: globalBin,
1659 executablePath: geminiCommand,
1660 packageRoot: path.join(
1661 cacheDir,
1662 "tools",
1663 "gemini",
1664 "0.56.0",
1665 "npm",
1666 "lib",
1667 "node_modules",
1668 "@google",
1669 "gemini-cli",
1670 ),
1671 packageVersion: "0.56.0",
1672 }),
1673 );
1674 yield* fileSystem.writeFileString(
1675 path.join(cacheDir, "acp-registry", "registry.json"),
1676 encodeUnknownJson({
1677 version: "1.0.0",
1678 agents: [
1679 {
1680 ...makeAgent({
1681 binary: {
1682 "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" },
1683 },
1684 }),
1685 id: "kimi",
1686 name: "Kimi",
1687 version: "1.49.0",
1688 },
1689 {
1690 ...makeAgent({
1691 binary: {
1692 "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" },
1693 },
1694 }),
1695 id: "kimi",
1696 name: "Kimi",
1697 version: "1.50.0",
1698 },
1699 ],
1700 }),
1701 );
1702
1703 const directories = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1704 fileSystem,
1705 path,
1706 cacheDir,
1707 toolsDir: path.join(cacheDir, "tools"),
1708 platform: "linux",
1709 architecture: "x64",
1710 });
1711 expect(directories).toEqual([
1712 globalBin,
1713 path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"),
1714 path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"),
1715 ]);
1716
1717 const missing = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1718 fileSystem,
1719 path,
1720 cacheDir: path.join(cacheDir, "does-not-exist"),
1721 toolsDir: path.join(cacheDir, "does-not-exist", "tools"),
1722 platform: "linux",
1723 architecture: "x64",
1724 });
1725 expect(missing).toEqual([]);
1726 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
1727 );
1728});
apps/server/src/provider/acp/AcpRegistrySupport.ts deleted-1943
......@@ -1,1943 +0,0 @@
1import {
2 AcpRegistryOperationError,
3 AcpRegistryOperationErrorReason,
4 TrimmedNonEmptyString,
5 type AcpRegistryManagedBinaryUninstallInput,
6 type AcpRegistryManagedBinaryUninstallResult,
7 type AcpRegistryPrepareInput,
8 type AcpRegistryPrepareResult,
9 type AcpRegistrySearchInput,
10 type AcpRegistrySearchResult,
11 type AcpRegistryDistribution as AcpRegistryDistributionKind,
12 type AcpRegistryDistributionPreference,
13 type AcpRegistrySettings,
14} from "@t3tools/contracts";
15import {
16 HostProcessArchitecture,
17 HostProcessEnvironment,
18 HostProcessPlatform,
19} from "@t3tools/shared/hostProcess";
20import {
21 mergePathEntries,
22 resolveSpawnCommand,
23 SpawnExecutableResolution,
24} from "@t3tools/shared/shell";
25import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";
27import * as Duration from "effect/Duration";
28import * as Crypto from "effect/Crypto";
29import * as Effect from "effect/Effect";
30import * as Hex from "effect/encoding/Hex";
31import * as FileSystem from "effect/FileSystem";
32import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";
34import * as Path from "effect/Path";
35import * as PlatformError from "effect/PlatformError";
36import * as Ref from "effect/Ref";
37import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";
39import * as Stream from "effect/Stream";
40import { sha256 } from "@noble/hashes/sha2";
41import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
42import { ChildProcess, ChildProcessSpawner } from "effect/process";
43
44import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
45import * as ServerSettings from "../../serverSettings.ts";
46import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
47
48const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
49
50const MAX_REGISTRY_BYTES = 1024 * 1024;
51const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
52const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024;
53const MAX_SEARCH_RESULTS = 20;
54const REGISTRY_REQUEST_TIMEOUT = "30 seconds";
55const ARCHIVE_REQUEST_TIMEOUT = "20 minutes";
56const PACKAGE_INSTALL_TIMEOUT = "20 minutes";
57const PACKAGE_QUERY_TIMEOUT = "30 seconds";
58
59const BoundedAgentId = Schema.String.check(
60 Schema.isMaxLength(128),
61 Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u),
62);
63const BoundedName = TrimmedNonEmptyString.check(Schema.isMaxLength(160));
64// Versions become install directory names, so "." and ".." must not decode.
65const BoundedVersion = TrimmedNonEmptyString.check(
66 Schema.isMaxLength(128),
67 Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u),
68);
69const BoundedDescription = Schema.String.check(Schema.isMaxLength(1_024));
70const BoundedMetadata = Schema.String.check(Schema.isMaxLength(256));
71const BoundedArgument = Schema.String.check(Schema.isMaxLength(1_024));
72const PackageCommandName = Schema.String.check(
73 Schema.isMaxLength(128),
74 Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/iu),
75);
76const isPackageCommandName = Schema.is(PackageCommandName);
77const EXACT_RUNNER_VERSION = "v?[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?";
78const EXACT_NPX_PACKAGE = new RegExp(
79 `^(?:@[^/@\\s]+/[^@\\s]+|[a-z0-9][a-z0-9._-]*)@${EXACT_RUNNER_VERSION}$`,
80 "iu",
81);
82const EXACT_UVX_PACKAGE = new RegExp(`^[a-z0-9][a-z0-9._-]*(?:@|==)${EXACT_RUNNER_VERSION}$`, "iu");
83const NpxPackage = Schema.String.check(
84 Schema.isMaxLength(256),
85 Schema.makeFilter(
86 (value) =>
87 EXACT_NPX_PACKAGE.test(value) || "ACP Registry npx packages must include an exact version.",
88 ),
89);
90const UvxPackage = Schema.String.check(
91 Schema.isMaxLength(256),
92 Schema.makeFilter(
93 (value) =>
94 EXACT_UVX_PACKAGE.test(value) || "ACP Registry uvx packages must include an exact version.",
95 ),
96);
97const HttpsUrl = Schema.String.check(
98 Schema.isMaxLength(2_048),
99 Schema.makeFilter((value) => {
100 try {
101 const url = new URL(value);
102 return (
103 (url.protocol === "https:" && url.username.length === 0 && url.password.length === 0) ||
104 "ACP Registry URLs must use HTTPS without embedded credentials."
105 );
106 } catch {
107 return "ACP Registry URLs must be valid absolute URLs.";
108 }
109 }),
110);
111
112const AcpRegistryPackageDistributionFields = {
113 args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))),
114 env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)),
115} as const;
116const AcpRegistryNpxDistribution = Schema.Struct({
117 package: NpxPackage,
118 ...AcpRegistryPackageDistributionFields,
119});
120const AcpRegistryUvxDistribution = Schema.Struct({
121 package: UvxPackage,
122 ...AcpRegistryPackageDistributionFields,
123});
124
125const AcpRegistryBinaryTarget = Schema.Struct({
126 archive: HttpsUrl,
127 cmd: BoundedArgument,
128 sha256: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/iu))),
129 args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))),
130 env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)),
131});
132
133const AcpRegistryAgent = Schema.Struct({
134 id: BoundedAgentId,
135 name: BoundedName,
136 version: BoundedVersion,
137 description: BoundedDescription,
138 authors: Schema.optionalKey(Schema.Array(BoundedMetadata).check(Schema.isMaxLength(16))),
139 license: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))),
140 website: Schema.optionalKey(HttpsUrl),
141 repository: Schema.optionalKey(HttpsUrl),
142 icon: Schema.optionalKey(HttpsUrl),
143 distribution: Schema.Struct({
144 binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)),
145 npx: Schema.optionalKey(AcpRegistryNpxDistribution),
146 uvx: Schema.optionalKey(AcpRegistryUvxDistribution),
147 }),
148});
149export type AcpRegistryAgent = typeof AcpRegistryAgent.Type;
150
151const NpmPackageManifest = Schema.Struct({
152 name: Schema.String,
153 version: Schema.String,
154 bin: Schema.Union([BoundedArgument, Schema.Record(PackageCommandName, BoundedArgument)]),
155});
156
157const AcpRegistryPackageInstallReceipt = Schema.Struct({
158 agentId: BoundedAgentId,
159 agentVersion: BoundedVersion,
160 distribution: Schema.Literals(["npx", "uvx"]),
161 packageSpec: Schema.String,
162 managerPath: Schema.String,
163 binDirectory: Schema.String,
164 executablePath: Schema.String,
165 packageRoot: Schema.optional(Schema.String),
166 packageVersion: Schema.optional(Schema.String),
167});
168type AcpRegistryPackageInstallReceipt = typeof AcpRegistryPackageInstallReceipt.Type;
169
170const AcpRegistryIndexEnvelope = Schema.Struct({
171 version: BoundedVersion,
172 agents: Schema.Array(Schema.Unknown).check(Schema.isMaxLength(512)),
173});
174export interface AcpRegistryIndex {
175 readonly version: string;
176 readonly agents: ReadonlyArray<AcpRegistryAgent>;
177}
178
179const decodeRegistryIndexEnvelope = Schema.decodeUnknownEffect(AcpRegistryIndexEnvelope);
180const decodeRegistryAgent = Schema.decodeUnknownOption(AcpRegistryAgent);
181const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
182const decodeNpmPackageManifest = Schema.decodeUnknownEffect(
183 Schema.fromJsonString(NpmPackageManifest),
184);
185const decodePackageInstallReceipt = Schema.decodeUnknownOption(
186 Schema.fromJsonString(AcpRegistryPackageInstallReceipt),
187);
188const encodePackageInstallReceipt = Schema.encodeSync(
189 Schema.fromJsonString(AcpRegistryPackageInstallReceipt),
190);
191const decodeHttpsUrl = Schema.decodeUnknownEffect(HttpsUrl);
192const decodeBoundedAgentId = Schema.decodeUnknownEffect(BoundedAgentId);
193
194export const AcpRegistryErrorReason = AcpRegistryOperationErrorReason;
195export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type;
196
197export class AcpRegistryError extends Schema.TaggedError<AcpRegistryError>()("AcpRegistryError", {
198 reason: AcpRegistryErrorReason,
199 detail: Schema.String,
200 cause: Schema.optional(Schema.Defect()),
201}) {
202 override get message(): string {
203 return this.detail;
204 }
205}
206
207export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistryOperationError {
208 return new AcpRegistryOperationError({
209 reason: error.reason,
210 message: error.detail,
211 cause: error,
212 });
213}
214
215const isAcpRegistryError = Schema.is(AcpRegistryError);
216
217export type AcpRegistryPlatformTarget =
218 | "darwin-aarch64"
219 | "darwin-x86_64"
220 | "linux-aarch64"
221 | "linux-x86_64"
222 | "windows-aarch64"
223 | "windows-x86_64";
224
225export function resolveAcpRegistryPlatformTarget(
226 platform: NodeJS.Platform,
227 architecture: NodeJS.Architecture,
228): AcpRegistryPlatformTarget | undefined {
229 const os =
230 platform === "darwin"
231 ? "darwin"
232 : platform === "linux"
233 ? "linux"
234 : platform === "win32"
235 ? "windows"
236 : undefined;
237 const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined;
238 return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined;
239}
240
241interface ExactPackageSpec {
242 readonly name: string;
243 readonly version: string;
244}
245
246function parseNpxPackageSpec(packageSpec: string): ExactPackageSpec {
247 const separator = packageSpec.lastIndexOf("@");
248 return {
249 name: packageSpec.slice(0, separator),
250 version: packageSpec.slice(separator + 1).replace(/^v/iu, ""),
251 };
252}
253
254function parseUvxPackageSpec(packageSpec: string): ExactPackageSpec {
255 const equalsSeparator = packageSpec.lastIndexOf("==");
256 const separator = equalsSeparator >= 0 ? equalsSeparator : packageSpec.lastIndexOf("@");
257 const separatorLength = equalsSeparator >= 0 ? 2 : 1;
258 return {
259 name: packageSpec.slice(0, separator),
260 version: packageSpec.slice(separator + separatorLength).replace(/^v/iu, ""),
261 };
262}
263
264function packageManagerFor(distribution: AcpRegistryDistributionKind): "npm" | "uv" | undefined {
265 return distribution === "npx" ? "npm" : distribution === "uvx" ? "uv" : undefined;
266}
267
268function packageCommandCandidates(
269 agent: AcpRegistryAgent,
270 packageName: string,
271): ReadonlyArray<string> {
272 const unscopedPackageName = packageName.split("/").pop() ?? packageName;
273 return Array.from(
274 new Set(
275 [agent.id, unscopedPackageName, unscopedPackageName.replace(/-acp$/u, "")].filter(
276 isPackageCommandName,
277 ),
278 ),
279 );
280}
281
282function readEnvironmentPath(
283 environment: NodeJS.ProcessEnv,
284 platform: NodeJS.Platform,
285): string | undefined {
286 if (platform !== "win32") return environment.PATH;
287 return (
288 environment.PATH ??
289 Object.entries(environment).find(([key]) => key.toLowerCase() === "path")?.[1]
290 );
291}
292
293function withPreferredPath(
294 environment: NodeJS.ProcessEnv,
295 directory: string,
296 platform: NodeJS.Platform,
297): NodeJS.ProcessEnv {
298 return {
299 ...environment,
300 PATH: mergePathEntries(directory, readEnvironmentPath(environment, platform), platform),
301 };
302}
303
304/**
305 * Directories exposing installed ACP Registry commands. The integrated
306 * terminal appends them to PATH so managed package commands and
307 * managed binary agents are both available by name. Best effort: unreadable
308 * directories resolve to no entries.
309 */
310export const acpRegistryManagedBinaryDirectories = (input: {
311 readonly fileSystem: FileSystem.FileSystem;
312 readonly path: Path.Path;
313 readonly cacheDir: string;
314 readonly toolsDir: string;
315 readonly platform: NodeJS.Platform;
316 readonly architecture: NodeJS.Architecture;
317}): Effect.Effect<ReadonlyArray<string>> =>
318 Effect.gen(function* () {
319 const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture);
320 const registryDirectory = input.path.join(input.cacheDir, "acp-registry");
321 const installsDirectory = input.toolsDir;
322 const packageReceiptsDirectory = input.path.join(registryDirectory, "package-installs");
323 const listDirectories = (directory: string) =>
324 input.fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): Array<string> => []));
325 const directories: Array<string> = [];
326
327 for (const receiptFile of (yield* listDirectories(packageReceiptsDirectory)).toSorted()) {
328 const receipt = yield* input.fileSystem
329 .readFileString(input.path.join(packageReceiptsDirectory, receiptFile))
330 .pipe(
331 Effect.map(decodePackageInstallReceipt),
332 Effect.orElseSucceed(() => Option.none()),
333 );
334 if (
335 Option.isSome(receipt) &&
336 receipt.value.binDirectory ===
337 input.path.join(
338 installsDirectory,
339 receipt.value.agentId,
340 encodeURIComponent(receipt.value.agentVersion),
341 receipt.value.distribution === "npx" ? "npm" : "python",
342 ...(receipt.value.distribution === "npx" && input.platform === "win32" ? [] : ["bin"]),
343 ) &&
344 input.path.dirname(receipt.value.executablePath) === receipt.value.binDirectory &&
345 (yield* input.fileSystem
346 .exists(receipt.value.executablePath)
347 .pipe(Effect.orElseSucceed(() => false)))
348 ) {
349 directories.push(receipt.value.binDirectory);
350 }
351 }
352
353 if (target === undefined) return Array.from(new Set(directories));
354 const cachedAgents = yield* input.fileSystem
355 .readFileString(input.path.join(registryDirectory, "registry.json"))
356 .pipe(
357 Effect.flatMap(decodeJson),
358 Effect.flatMap(decodeRegistryIndexEnvelope),
359 Effect.map((envelope) =>
360 envelope.agents.flatMap((candidate) => {
361 const decoded = decodeRegistryAgent(candidate);
362 return Option.isSome(decoded) ? [decoded.value] : [];
363 }),
364 ),
365 Effect.orElseSucceed((): ReadonlyArray<AcpRegistryAgent> => []),
366 );
367 const cachedAgentByInstall = new Map(
368 // Install directories are named by the URI-encoded version.
369 cachedAgents.map(
370 (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const,
371 ),
372 );
373 for (const agent of (yield* listDirectories(installsDirectory)).toSorted()) {
374 const versions = (yield* listDirectories(input.path.join(installsDirectory, agent))).toSorted(
375 (left, right) => right.localeCompare(left, undefined, { numeric: true }),
376 );
377 for (const version of versions) {
378 const installRoot = input.path.join(installsDirectory, agent, version, target);
379 if (yield* input.fileSystem.exists(installRoot).pipe(Effect.orElseSucceed(() => false))) {
380 const binaryTarget = cachedAgentByInstall.get(`${agent}\0${version}`)?.distribution
381 .binary?.[target];
382 const commandSegments =
383 binaryTarget === undefined ? undefined : normalizeRegistryCommandPath(binaryTarget.cmd);
384 const directory =
385 commandSegments === undefined
386 ? installRoot
387 : input.path.join(installRoot, ...commandSegments.slice(0, -1));
388 if (yield* input.fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) {
389 directories.push(directory);
390 }
391 }
392 }
393 }
394 return Array.from(new Set(directories));
395 });
396
397export interface ResolvedAcpRegistryDistribution {
398 readonly kind: AcpRegistryDistributionKind;
399 readonly args: ReadonlyArray<string>;
400 readonly env: Readonly<Record<string, string>>;
401 readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type;
402 readonly packageName?: string;
403}
404
405export function resolveAcpRegistryDistribution(input: {
406 readonly agent: AcpRegistryAgent;
407 readonly preference: AcpRegistryDistributionPreference;
408 readonly platformTarget: AcpRegistryPlatformTarget | undefined;
409}): ResolvedAcpRegistryDistribution | undefined {
410 const { agent, platformTarget } = input;
411 const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined;
412 const candidates: ReadonlyArray<AcpRegistryDistributionKind> =
413 input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference];
414
415 for (const kind of candidates) {
416 if (kind === "binary" && binary) {
417 return {
418 kind,
419 args: binary.args ?? [],
420 env: binary.env ?? {},
421 binaryTarget: binary,
422 };
423 }
424 if (kind === "npx" && agent.distribution.npx) {
425 return {
426 kind,
427 args: agent.distribution.npx.args ?? [],
428 env: agent.distribution.npx.env ?? {},
429 packageName: agent.distribution.npx.package,
430 };
431 }
432 if (kind === "uvx" && agent.distribution.uvx) {
433 return {
434 kind,
435 args: agent.distribution.uvx.args ?? [],
436 env: agent.distribution.uvx.env ?? {},
437 packageName: agent.distribution.uvx.package,
438 };
439 }
440 }
441 return undefined;
442}
443
444export interface ResolvedAcpRegistryAgent {
445 readonly agent?: AcpRegistryAgent;
446 readonly distribution: AcpRegistryDistributionKind | "local";
447 readonly spawn: AcpSessionRuntime.AcpSpawnInput;
448}
449
450export type AcpRegistryInspection =
451 | { readonly status: "unconfigured" }
452 | { readonly status: "not_found"; readonly agentId: string }
453 | {
454 readonly status: "unsupported";
455 readonly agentId: string;
456 readonly version: string;
457 }
458 | {
459 readonly status: "missing_runner";
460 readonly agentId: string;
461 readonly version: string;
462 readonly distribution: AcpRegistryDistributionKind;
463 readonly runner: string;
464 }
465 | {
466 readonly status: "missing_runner";
467 readonly version: null;
468 readonly distribution: "local";
469 }
470 | {
471 readonly status: "unprepared";
472 readonly agentId: string;
473 readonly version: string;
474 readonly distribution: "binary";
475 }
476 | {
477 readonly status: "ready";
478 readonly agentId: string;
479 readonly version: string | null;
480 readonly distribution: AcpRegistryDistributionKind | "local";
481 readonly documentationUrl?: string;
482 };
483
484export class AcpRegistryCatalog extends Context.Service<
485 AcpRegistryCatalog,
486 {
487 readonly search: (
488 input: AcpRegistrySearchInput,
489 ) => Effect.Effect<AcpRegistrySearchResult, AcpRegistryError>;
490 readonly prepare: (
491 input: AcpRegistryPrepareInput,
492 ) => Effect.Effect<AcpRegistryPrepareResult, AcpRegistryError>;
493 readonly inspect: (
494 settings: AcpRegistrySettings,
495 environment?: NodeJS.ProcessEnv,
496 ) => Effect.Effect<AcpRegistryInspection, AcpRegistryError>;
497 readonly resolve: (
498 settings: AcpRegistrySettings,
499 cwd: string,
500 environment?: NodeJS.ProcessEnv,
501 ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>;
502 readonly uninstallManagedBinary: (
503 input: AcpRegistryManagedBinaryUninstallInput,
504 ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>;
505 }
506>()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") {
507 static layer(options: AcpRegistryCatalogOptions) {
508 return Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options));
509 }
510}
511
512export interface AcpRegistryCatalogOptions {
513 readonly cacheDir: string;
514 readonly toolsDir: string;
515 readonly registryUrl?: string;
516}
517
518const INSTALL_LOCK_RETRY_COUNT = 300;
519const INSTALL_LOCK_RETRY_DELAY = "100 millis";
520const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
521const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000;
522
523function isAlreadyExists(error: PlatformError.PlatformError): boolean {
524 return error.reason._tag === "AlreadyExists";
525}
526
527function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined {
528 const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
529 const segments = normalized.split("/").filter((segment) => segment.length > 0);
530 if (
531 segments.length === 0 ||
532 normalized.startsWith("/") ||
533 /^[a-zA-Z]:/u.test(normalized) ||
534 segments.some((segment) => segment === "." || segment === "..")
535 ) {
536 return undefined;
537 }
538 return segments;
539}
540
541function validateArchiveEntries(output: string): boolean {
542 return output
543 .split(/\r?\n/u)
544 .map((entry) => entry.trim())
545 .filter((entry) => entry.length > 0)
546 .every((entry) => entry === "./" || normalizeRegistryCommandPath(entry) !== undefined);
547}
548
549type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip";
550
551function archiveKind(url: string): ArchiveKind {
552 const pathname = new URL(url).pathname.toLowerCase();
553 if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz";
554 if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2";
555 if (pathname.endsWith(".zip")) return "zip";
556 return "raw";
557}
558
559function archiveFileName(kind: ArchiveKind): string {
560 switch (kind) {
561 case "tar_gz":
562 return "agent.tar.gz";
563 case "tar_bz2":
564 return "agent.tar.bz2";
565 case "zip":
566 return "agent.zip";
567 case "raw":
568 return "agent.bin";
569 }
570}
571
572function compareText(left: string, right: string): number {
573 return left < right ? -1 : left > right ? 1 : 0;
574}
575
576function searchRank(agent: AcpRegistryAgent, query: string): number | undefined {
577 const normalized = query.trim().toLowerCase();
578 if (normalized.length === 0) return 100;
579
580 const id = agent.id.toLowerCase();
581 const name = agent.name.toLowerCase();
582 const authors = (agent.authors ?? []).join(" ").toLowerCase();
583 const description = agent.description.toLowerCase();
584 const terms = normalized.split(/\s+/u);
585 if (id === normalized || name === normalized) return 0;
586 if (id.startsWith(normalized) || name.startsWith(normalized)) return 10;
587
588 const identityTokens = `${id} ${name}`.split(/[^a-z0-9]+/u).filter(Boolean);
589 if (terms.every((term) => identityTokens.some((token) => token.startsWith(term)))) return 20;
590 if (terms.every((term) => id.includes(term) || name.includes(term))) return 30;
591 if (terms.every((term) => authors.includes(term))) return 40;
592 if (terms.every((term) => `${id} ${name} ${authors} ${description}`.includes(term))) return 50;
593 return undefined;
594}
595
596export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(function* (
597 input: AcpRegistryCatalogOptions,
598): Effect.fn.Return<
599 AcpRegistryCatalog["Service"],
600 never,
601 | ChildProcessSpawner.ChildProcessSpawner
602 | Crypto.Crypto
603 | FileSystem.FileSystem
604 | HttpClient.HttpClient
605 | Path.Path
606 | ServerSettings.ServerSettingsService
607> {
608 const crypto = yield* Crypto.Crypto;
609 const sha256Hex = (data: Uint8Array) =>
610 crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie);
611 const fileSystem = yield* FileSystem.FileSystem;
612 const path = yield* Path.Path;
613 const httpClient = yield* HttpClient.HttpClient;
614 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
615 const serverSettings = yield* ServerSettings.ServerSettingsService;
616 const platform = yield* HostProcessPlatform;
617 const architecture = yield* HostProcessArchitecture;
618 const hostEnvironment = yield* HostProcessEnvironment;
619 const resolveExecutable = yield* SpawnExecutableResolution;
620 const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture);
621 const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL;
622 const registryDirectory = path.join(input.cacheDir, "acp-registry");
623 const registryCachePath = path.join(registryDirectory, "registry.json");
624 const installsDirectory = input.toolsDir;
625 const agentInstallRoot = (agent: AcpRegistryAgent) =>
626 path.join(installsDirectory, agent.id, encodeURIComponent(agent.version));
627 const packageInstallRoot = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") =>
628 path.join(agentInstallRoot(agent), distribution === "npx" ? "npm" : "python");
629 const packageBinDirectory = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") =>
630 distribution === "npx" && platform === "win32"
631 ? packageInstallRoot(agent, distribution)
632 : path.join(packageInstallRoot(agent, distribution), "bin");
633 const packageReceiptsDirectory = path.join(registryDirectory, "package-installs");
634 const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined);
635 const registryRevision = yield* Ref.make(0);
636 const registrySemaphore = yield* Semaphore.make(1);
637 const installSemaphore = yield* Semaphore.make(1);
638 const preparedBinaryReservations = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
639
640 const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* (
641 runner: string,
642 ) {
643 const linkedTarget = yield* fileSystem.readLink(runner).pipe(Effect.option);
644 if (Option.isNone(linkedTarget)) return path.dirname(runner);
645 const target = path.isAbsolute(linkedTarget.value)
646 ? linkedTarget.value
647 : path.resolve(path.dirname(runner), linkedTarget.value);
648 // Some service installs expose npm/uv through a wrapper symlink into the
649 // actual toolchain bin directory. Do not mistake npm's internal
650 // `npm-cli.js` directory for the global command directory.
651 return path.basename(target) === path.basename(runner)
652 ? path.dirname(target)
653 : path.dirname(runner);
654 });
655
656 const reservePreparedBinary = Effect.fn("AcpRegistryCatalog.reservePreparedBinary")(function* (
657 agentId: string,
658 ) {
659 const now = yield* Clock.currentTimeMillis;
660 yield* Ref.update(preparedBinaryReservations, (current) => {
661 const next = new Map(Array.from(current).filter(([, expiresAt]) => expiresAt > now));
662 next.set(agentId, now + PREPARED_BINARY_RESERVATION_MS);
663 return next;
664 });
665 });
666
667 const consumePreparedBinaryReservation = (agentId: string) =>
668 Ref.update(preparedBinaryReservations, (current) => {
669 if (!current.has(agentId)) return current;
670 const next = new Map(current);
671 next.delete(agentId);
672 return next;
673 });
674
675 const hasPreparedBinaryReservation = Effect.fn("AcpRegistryCatalog.hasPreparedBinaryReservation")(
676 function* (agentId: string) {
677 const now = yield* Clock.currentTimeMillis;
678 return yield* Ref.modify(preparedBinaryReservations, (current) => {
679 const expiresAt = current.get(agentId);
680 if (expiresAt === undefined || expiresAt <= now) {
681 if (expiresAt === undefined) return [false, current] as const;
682 const next = new Map(current);
683 next.delete(agentId);
684 return [false, next] as const;
685 }
686 return [true, current] as const;
687 });
688 },
689 );
690
691 const assertHttpsUrl = Effect.fn("AcpRegistryCatalog.assertHttpsUrl")(function* (
692 value: string,
693 detail: string,
694 ) {
695 const valid = yield* decodeHttpsUrl(value).pipe(Effect.option);
696 if (Option.isNone(valid)) {
697 return yield* new AcpRegistryError({ reason: "registry_unavailable", detail });
698 }
699 return value;
700 });
701
702 const decodeRegistryText = Effect.fn("AcpRegistryCatalog.decodeRegistryText")(function* (
703 text: string,
704 ) {
705 const decoded = yield* decodeJson(text).pipe(
706 Effect.mapError(
707 (cause) =>
708 new AcpRegistryError({
709 reason: "registry_unavailable",
710 detail: "ACP Registry returned invalid JSON.",
711 cause,
712 }),
713 ),
714 );
715 const envelope = yield* decodeRegistryIndexEnvelope(decoded).pipe(
716 Effect.mapError(
717 (cause) =>
718 new AcpRegistryError({
719 reason: "registry_unavailable",
720 detail: "ACP Registry returned an invalid index.",
721 cause,
722 }),
723 ),
724 );
725 const agents = envelope.agents.flatMap((candidate) => {
726 const decodedAgent = decodeRegistryAgent(candidate);
727 return Option.isSome(decodedAgent) ? [decodedAgent.value] : [];
728 });
729 const discarded = envelope.agents.length - agents.length;
730 if (discarded > 0) {
731 yield* Effect.logWarning("ignored invalid ACP Registry entries", { discarded });
732 }
733 return { version: envelope.version, agents } satisfies AcpRegistryIndex;
734 });
735
736 const readCachedRegistry = fileSystem
737 .readFileString(registryCachePath)
738 .pipe(Effect.flatMap(decodeRegistryText), Effect.option);
739
740 const writeRegistryCache = (text: string) =>
741 crypto.randomUUIDv4.pipe(
742 Effect.flatMap((id) => {
743 const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`;
744 return fileSystem
745 .makeDirectory(registryDirectory, { recursive: true })
746 .pipe(
747 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),
748 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),
749 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),
750 );
751 }),
752 Effect.ignore,
753 );
754
755 const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () {
756 yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS.");
757 const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe(
758 Effect.flatMap(HttpClientResponse.filterStatusOk),
759 Effect.mapError(
760 (cause) =>
761 new AcpRegistryError({
762 reason: "registry_unavailable",
763 detail: `Could not fetch ACP Registry index from ${registryUrl}.`,
764 cause,
765 }),
766 ),
767 Effect.timeoutOrElse({
768 duration: REGISTRY_REQUEST_TIMEOUT,
769 orElse: () =>
770 Effect.fail(
771 new AcpRegistryError({
772 reason: "registry_unavailable",
773 detail: "Timed out fetching ACP Registry index.",
774 }),
775 ),
776 }),
777 );
778 const collected = yield* collectUint8StreamText({
779 stream: response.stream,
780 maxBytes: MAX_REGISTRY_BYTES + 1,
781 }).pipe(
782 Effect.mapError(
783 (cause) =>
784 new AcpRegistryError({
785 reason: "registry_unavailable",
786 detail: "Could not read ACP Registry response body.",
787 cause,
788 }),
789 ),
790 Effect.timeoutOrElse({
791 duration: REGISTRY_REQUEST_TIMEOUT,
792 orElse: () =>
793 Effect.fail(
794 new AcpRegistryError({
795 reason: "registry_unavailable",
796 detail: "Timed out reading ACP Registry response body.",
797 }),
798 ),
799 }),
800 );
801 if (collected.truncated || collected.bytes > MAX_REGISTRY_BYTES) {
802 return yield* new AcpRegistryError({
803 reason: "registry_unavailable",
804 detail: `ACP Registry index exceeds ${MAX_REGISTRY_BYTES} bytes.`,
805 });
806 }
807 if (collected.invalidUtf8) {
808 return yield* new AcpRegistryError({
809 reason: "registry_unavailable",
810 detail: "ACP Registry index is not valid UTF-8.",
811 });
812 }
813 const registry = yield* decodeRegistryText(collected.text);
814 yield* writeRegistryCache(collected.text);
815 return registry;
816 });
817
818 const refreshRegistry = Effect.fn("AcpRegistryCatalog.refreshRegistry")(function* () {
819 const observedRevision = yield* Ref.get(registryRevision);
820 return yield* registrySemaphore.withPermits(1)(
821 Effect.gen(function* () {
822 const currentRevision = yield* Ref.get(registryRevision);
823 const current = yield* Ref.get(registryRef);
824 if (current !== undefined && currentRevision !== observedRevision) return current;
825
826 const registry = yield* fetchRegistry().pipe(
827 Effect.catch((networkError) =>
828 readCachedRegistry.pipe(
829 Effect.flatMap(
830 Option.match({
831 onNone: () => Effect.fail(networkError),
832 onSome: Effect.succeed,
833 }),
834 ),
835 ),
836 ),
837 );
838 yield* Ref.set(registryRef, registry);
839 yield* Ref.update(registryRevision, (revision) => revision + 1);
840 return registry;
841 }),
842 );
843 });
844
845 const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () {
846 const current = yield* Ref.get(registryRef);
847 if (current !== undefined) return current;
848
849 const cached = yield* readCachedRegistry;
850 if (Option.isNone(cached)) {
851 return yield* new AcpRegistryError({
852 reason: "registry_unavailable",
853 detail: "No valid cached ACP Registry index is available.",
854 });
855 }
856 yield* Ref.set(registryRef, cached.value);
857 return cached.value;
858 });
859
860 const loadRegistry = Effect.fn("AcpRegistryCatalog.loadRegistry")(function* () {
861 return yield* loadCachedRegistry().pipe(Effect.catch(() => refreshRegistry()));
862 });
863
864 const runCommand = Effect.fn("AcpRegistryCatalog.runCommand")(function* (
865 command: string,
866 args: ReadonlyArray<string>,
867 options: {
868 readonly cwd?: string;
869 readonly env?: NodeJS.ProcessEnv;
870 readonly timeout?: Duration.Input;
871 readonly truncatedOutputReason?: AcpRegistryErrorReason;
872 } = {},
873 ) {
874 const collect = Effect.gen(function* () {
875 const resolved = yield* resolveSpawnCommand(
876 command,
877 args,
878 options.env === undefined ? {} : { env: options.env },
879 );
880 const child = yield* spawner.spawn(
881 ChildProcess.make(resolved.command, resolved.args, {
882 ...(options.cwd ? { cwd: options.cwd } : {}),
883 ...(options.env === undefined ? {} : { env: options.env }),
884 shell: resolved.shell,
885 }),
886 );
887 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
888 const [stdout, stderr, exitCode] = yield* Effect.all(
889 [
890 collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }),
891 collectUint8StreamText({ stream: child.stderr, maxBytes: 1024 * 1024 }),
892 child.exitCode,
893 ],
894 { concurrency: "unbounded" },
895 );
896 if (Number(exitCode) !== 0) {
897 return yield* new AcpRegistryError({
898 reason: "install_failed",
899 detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`,
900 });
901 }
902 // Archive listings feed validateArchiveEntries; a truncated listing would let
903 // unvalidated entries past the traversal check, so oversized output is fatal.
904 if (stdout.truncated) {
905 return yield* new AcpRegistryError({
906 reason: options.truncatedOutputReason ?? "archive_invalid",
907 detail: `ACP Registry install command '${command}' produced more output than expected.`,
908 });
909 }
910 return stdout.text;
911 });
912 const scoped = collect.pipe(
913 Effect.scoped,
914 Effect.mapError((cause) =>
915 isAcpRegistryError(cause)
916 ? cause
917 : new AcpRegistryError({
918 reason: "install_failed",
919 detail: `Could not run ACP Registry install command '${command}'.`,
920 cause,
921 }),
922 ),
923 );
924 return yield* options.timeout === undefined
925 ? scoped
926 : scoped.pipe(
927 Effect.timeoutOrElse({
928 duration: options.timeout,
929 orElse: () =>
930 Effect.fail(
931 new AcpRegistryError({
932 reason: "install_failed",
933 detail: `Timed out running ACP Registry install command '${command}'.`,
934 }),
935 ),
936 }),
937 );
938 });
939
940 const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) =>
941 sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe(
942 Effect.map((digest) =>
943 path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`),
944 ),
945 );
946
947 const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* (
948 agent: AcpRegistryAgent,
949 distribution: "npx" | "uvx",
950 packageSpec: string,
951 managerPath: string,
952 ) {
953 const receipt = yield* fileSystem
954 .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath))
955 .pipe(
956 Effect.map(decodePackageInstallReceipt),
957 Effect.orElseSucceed(() => Option.none()),
958 );
959 if (
960 Option.isNone(receipt) ||
961 receipt.value.agentId !== agent.id ||
962 receipt.value.agentVersion !== agent.version ||
963 receipt.value.distribution !== distribution ||
964 receipt.value.packageSpec !== packageSpec ||
965 receipt.value.managerPath !== managerPath ||
966 receipt.value.binDirectory !== packageBinDirectory(agent, distribution) ||
967 path.dirname(receipt.value.executablePath) !== receipt.value.binDirectory
968 ) {
969 return Option.none<AcpRegistryPackageInstallReceipt>();
970 }
971 const executableExists = yield* fileSystem
972 .exists(receipt.value.executablePath)
973 .pipe(Effect.orElseSucceed(() => false));
974 if (!executableExists) return Option.none<AcpRegistryPackageInstallReceipt>();
975
976 if (distribution === "npx") {
977 const packageIdentity = parseNpxPackageSpec(packageSpec);
978 if (receipt.value.packageRoot === undefined || receipt.value.packageVersion === undefined) {
979 return Option.none<AcpRegistryPackageInstallReceipt>();
980 }
981 const manifest = yield* fileSystem
982 .readFileString(path.join(receipt.value.packageRoot, "package.json"))
983 .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option);
984 if (
985 Option.isNone(manifest) ||
986 manifest.value.name !== packageIdentity.name ||
987 manifest.value.version !== packageIdentity.version ||
988 receipt.value.packageVersion !== packageIdentity.version
989 ) {
990 return Option.none<AcpRegistryPackageInstallReceipt>();
991 }
992 }
993 return receipt;
994 });
995
996 const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")(
997 function* (receipt: AcpRegistryPackageInstallReceipt) {
998 const receiptPath = yield* packageReceiptPath(
999 receipt.agentId,
1000 receipt.distribution,
1001 receipt.managerPath,
1002 );
1003 const temporaryPath = `${receiptPath}.${process.pid}.tmp`;
1004 yield* fileSystem.makeDirectory(packageReceiptsDirectory, { recursive: true });
1005 yield* fileSystem.remove(temporaryPath, { force: true });
1006 yield* fileSystem.writeFileString(temporaryPath, `${encodePackageInstallReceipt(receipt)}\n`);
1007 yield* fileSystem.remove(receiptPath, { force: true });
1008 yield* fileSystem.rename(temporaryPath, receiptPath);
1009 },
1010 Effect.mapError((cause) =>
1011 isAcpRegistryError(cause)
1012 ? cause
1013 : new AcpRegistryError({
1014 reason: "install_failed",
1015 detail: "Could not record the globally installed ACP Registry command.",
1016 cause,
1017 }),
1018 ),
1019 );
1020
1021 const parsePackageManagerPath = Effect.fn("AcpRegistryCatalog.parsePackageManagerPath")(
1022 function* (managerPath: string, output: string) {
1023 const lines = output
1024 .split(/\r?\n/u)
1025 .map((line) => line.trim())
1026 .filter((line) => line.length > 0);
1027 if (lines.length !== 1 || !path.isAbsolute(lines[0]!)) {
1028 return yield* new AcpRegistryError({
1029 reason: "install_failed",
1030 detail: `ACP Registry package manager '${managerPath}' returned an invalid global path.`,
1031 });
1032 }
1033 return lines[0]!;
1034 },
1035 );
1036
1037 const npmCommandName = (
1038 agent: AcpRegistryAgent,
1039 packageName: string,
1040 manifest: typeof NpmPackageManifest.Type,
1041 ): string | undefined => {
1042 const packageBaseName = packageName.split("/").pop() ?? packageName;
1043 if (typeof manifest.bin === "string") return packageBaseName;
1044 const entries = Object.entries(manifest.bin).toSorted(([left], [right]) =>
1045 compareText(left, right),
1046 );
1047 if (entries.length === 1) return entries[0]![0];
1048 if (manifest.bin[packageBaseName] !== undefined) return packageBaseName;
1049 if (manifest.bin[agent.id] !== undefined) return agent.id;
1050 return new Set(entries.map(([, commandPath]) => commandPath)).size === 1
1051 ? entries[0]?.[0]
1052 : undefined;
1053 };
1054
1055 const discoverNpmGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverNpmGlobalPackage")(
1056 function* (
1057 agent: AcpRegistryAgent,
1058 packageSpec: string,
1059 managerPath: string,
1060 environment: NodeJS.ProcessEnv,
1061 ) {
1062 const packageIdentity = parseNpxPackageSpec(packageSpec);
1063 const commandOptions = {
1064 env: environment,
1065 timeout: PACKAGE_QUERY_TIMEOUT,
1066 truncatedOutputReason: "install_failed" as const,
1067 } as const;
1068 const globalRoot = yield* runCommand(managerPath, ["root", "--global"], commandOptions).pipe(
1069 Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)),
1070 );
1071 const globalPrefix = yield* runCommand(
1072 managerPath,
1073 ["prefix", "--global"],
1074 commandOptions,
1075 ).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)));
1076 const packageRoot = path.join(globalRoot, ...packageIdentity.name.split("/"));
1077 const manifestPath = path.join(packageRoot, "package.json");
1078 const manifestInfo = yield* fileSystem.stat(manifestPath).pipe(Effect.option);
1079 if (Option.isNone(manifestInfo) || manifestInfo.value.size > MAX_PACKAGE_MANIFEST_BYTES) {
1080 return Option.none<AcpRegistryPackageInstallReceipt>();
1081 }
1082 const manifest = yield* fileSystem
1083 .readFileString(manifestPath)
1084 .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option);
1085 if (
1086 Option.isNone(manifest) ||
1087 manifest.value.name !== packageIdentity.name ||
1088 manifest.value.version !== packageIdentity.version
1089 ) {
1090 return Option.none<AcpRegistryPackageInstallReceipt>();
1091 }
1092 const commandName = npmCommandName(agent, packageIdentity.name, manifest.value);
1093 if (commandName === undefined) return Option.none<AcpRegistryPackageInstallReceipt>();
1094 const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin");
1095 const executablePath = resolveExecutable(
1096 commandName,
1097 platform,
1098 withPreferredPath(environment, binDirectory, platform),
1099 );
1100 return executablePath === undefined
1101 ? Option.none<AcpRegistryPackageInstallReceipt>()
1102 : Option.some<AcpRegistryPackageInstallReceipt>({
1103 agentId: agent.id,
1104 agentVersion: agent.version,
1105 distribution: "npx" as const,
1106 packageSpec,
1107 managerPath,
1108 binDirectory,
1109 executablePath,
1110 packageRoot,
1111 packageVersion: manifest.value.version,
1112 });
1113 },
1114 );
1115
1116 const discoverUvGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverUvGlobalPackage")(
1117 function* (
1118 agent: AcpRegistryAgent,
1119 packageSpec: string,
1120 managerPath: string,
1121 environment: NodeJS.ProcessEnv,
1122 ) {
1123 const packageIdentity = parseUvxPackageSpec(packageSpec);
1124 const installedTools = yield* runCommand(managerPath, ["tool", "list"], {
1125 env: environment,
1126 timeout: PACKAGE_QUERY_TIMEOUT,
1127 truncatedOutputReason: "install_failed",
1128 });
1129 const normalizedPackageName = packageIdentity.name.toLowerCase().replace(/[._-]+/gu, "-");
1130 const exactVersionInstalled = installedTools.split(/\r?\n/u).some((line) => {
1131 const match = /^(\S+) v(\S+)(?:\s|$)/u.exec(line);
1132 return (
1133 match?.[1]?.toLowerCase().replace(/[._-]+/gu, "-") === normalizedPackageName &&
1134 match[2] === packageIdentity.version
1135 );
1136 });
1137 if (!exactVersionInstalled) return Option.none<AcpRegistryPackageInstallReceipt>();
1138 const binDirectory = yield* runCommand(managerPath, ["tool", "dir", "--bin"], {
1139 env: environment,
1140 timeout: PACKAGE_QUERY_TIMEOUT,
1141 truncatedOutputReason: "install_failed",
1142 }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)));
1143 const commandEnvironment = withPreferredPath(environment, binDirectory, platform);
1144 const executablePath = packageCommandCandidates(agent, packageIdentity.name)
1145 .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment))
1146 .find((candidate): candidate is string => candidate !== undefined);
1147 return executablePath === undefined
1148 ? Option.none<AcpRegistryPackageInstallReceipt>()
1149 : Option.some<AcpRegistryPackageInstallReceipt>({
1150 agentId: agent.id,
1151 agentVersion: agent.version,
1152 distribution: "uvx" as const,
1153 packageSpec,
1154 managerPath,
1155 binDirectory,
1156 executablePath,
1157 packageVersion: packageIdentity.version,
1158 });
1159 },
1160 );
1161
1162 const ensureGlobalPackage = Effect.fn("AcpRegistryCatalog.ensureGlobalPackage")(function* (
1163 agent: AcpRegistryAgent,
1164 distribution: "npx" | "uvx",
1165 packageSpec: string,
1166 environment: NodeJS.ProcessEnv,
1167 ) {
1168 const managerName = packageManagerFor(distribution)!;
1169 const managerPath = resolveExecutable(managerName, platform, environment);
1170 if (managerPath === undefined) {
1171 return yield* new AcpRegistryError({
1172 reason: "runner_unavailable",
1173 detail: `ACP Registry agent ${agent.id} requires '${managerName}', but it is not available on this environment's PATH.`,
1174 });
1175 }
1176 const receipt = yield* readPackageReceipt(agent, distribution, packageSpec, managerPath);
1177 if (Option.isSome(receipt) && distribution === "npx") return receipt.value;
1178
1179 const packageRoot = packageInstallRoot(agent, distribution);
1180 yield* fileSystem.makeDirectory(packageRoot, { recursive: true }).pipe(
1181 Effect.mapError(
1182 (cause) =>
1183 new AcpRegistryError({
1184 reason: "install_failed",
1185 detail: `Could not create the managed install directory for ${agent.id}.`,
1186 cause,
1187 }),
1188 ),
1189 );
1190 const packageEnvironment =
1191 distribution === "npx"
1192 ? { ...environment, npm_config_prefix: packageRoot }
1193 : {
1194 ...environment,
1195 UV_TOOL_DIR: packageRoot,
1196 UV_TOOL_BIN_DIR: packageBinDirectory(agent, distribution),
1197 };
1198 if (Option.isSome(receipt)) {
1199 const installed = yield* discoverUvGlobalPackage(
1200 agent,
1201 packageSpec,
1202 managerPath,
1203 packageEnvironment,
1204 );
1205 if (Option.isSome(installed)) return installed.value;
1206 }
1207
1208 const discover =
1209 distribution === "npx"
1210 ? () => discoverNpmGlobalPackage(agent, packageSpec, managerPath, packageEnvironment)
1211 : () => discoverUvGlobalPackage(agent, packageSpec, managerPath, packageEnvironment);
1212 const existing = yield* discover();
1213 if (Option.isSome(existing)) {
1214 yield* writePackageReceipt(existing.value);
1215 return existing.value;
1216 }
1217
1218 const managerDirectory = yield* managerBinDirectory(managerPath);
1219 const installEnvironment = withPreferredPath(packageEnvironment, managerDirectory, platform);
1220 if (distribution === "npx") {
1221 yield* runCommand(managerPath, ["install", "--global", packageSpec], {
1222 env: installEnvironment,
1223 timeout: PACKAGE_INSTALL_TIMEOUT,
1224 truncatedOutputReason: "install_failed",
1225 });
1226 } else {
1227 yield* runCommand(managerPath, ["tool", "install", "--force", packageSpec], {
1228 env: installEnvironment,
1229 timeout: PACKAGE_INSTALL_TIMEOUT,
1230 truncatedOutputReason: "install_failed",
1231 });
1232 }
1233
1234 if (distribution === "npx") {
1235 const installed = yield* discoverNpmGlobalPackage(
1236 agent,
1237 packageSpec,
1238 managerPath,
1239 packageEnvironment,
1240 );
1241 if (Option.isNone(installed)) {
1242 return yield* new AcpRegistryError({
1243 reason: "install_failed",
1244 detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`,
1245 });
1246 }
1247 yield* writePackageReceipt(installed.value);
1248 return installed.value;
1249 }
1250
1251 const installed = yield* discoverUvGlobalPackage(
1252 agent,
1253 packageSpec,
1254 managerPath,
1255 packageEnvironment,
1256 );
1257 if (Option.isNone(installed)) {
1258 return yield* new AcpRegistryError({
1259 reason: "install_failed",
1260 detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`,
1261 });
1262 }
1263 yield* writePackageReceipt(installed.value);
1264 return installed.value;
1265 });
1266
1267 const acquireInstallLock = Effect.fn("AcpRegistryCatalog.acquireInstallLock")(function* (
1268 lockPath: string,
1269 ) {
1270 for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
1271 const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe(
1272 Effect.as(true),
1273 Effect.catch((error) =>
1274 isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error),
1275 ),
1276 );
1277 if (acquired) return;
1278 const now = yield* Clock.currentTimeMillis;
1279 const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
1280 const mtime = Option.flatMap(lockInfo, (info) => info.mtime);
1281 if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) {
1282 yield* fileSystem.remove(lockPath, { force: true });
1283 continue;
1284 }
1285 yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
1286 }
1287 return yield* new AcpRegistryError({
1288 reason: "install_failed",
1289 detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`,
1290 });
1291 });
1292
1293 const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* (
1294 root: string,
1295 executablePath: string,
1296 ) {
1297 const rootRealPath = yield* fileSystem.realPath(root);
1298 const executableRealPath = yield* fileSystem.realPath(executablePath);
1299 const relative = path.relative(rootRealPath, executableRealPath);
1300 if (relative.startsWith("..") || path.isAbsolute(relative)) {
1301 return yield* new AcpRegistryError({
1302 reason: "archive_invalid",
1303 detail: "ACP Registry archive command resolves outside its installation directory.",
1304 });
1305 }
1306 const info = yield* fileSystem.stat(executableRealPath);
1307 const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0;
1308 if (info.type !== "File" || !hasExecutableMode) {
1309 return yield* new AcpRegistryError({
1310 reason: "archive_invalid",
1311 detail: "ACP Registry archive command is not a regular executable file.",
1312 });
1313 }
1314 return executableRealPath;
1315 });
1316
1317 const binaryPaths = (agent: AcpRegistryAgent, target: typeof AcpRegistryBinaryTarget.Type) => {
1318 const commandSegments = normalizeRegistryCommandPath(target.cmd);
1319 if (platformTarget === undefined || commandSegments === undefined) return undefined;
1320 const installRoot = path.join(agentInstallRoot(agent), platformTarget);
1321 return {
1322 commandSegments,
1323 installRoot,
1324 executablePath: path.join(installRoot, ...commandSegments),
1325 };
1326 };
1327
1328 const installBinary = Effect.fn("AcpRegistryCatalog.installBinary")(function* (
1329 agent: AcpRegistryAgent,
1330 target: typeof AcpRegistryBinaryTarget.Type,
1331 ) {
1332 if (platformTarget === undefined) {
1333 return yield* new AcpRegistryError({
1334 reason: "unsupported_platform",
1335 detail: `ACP Registry does not support platform ${platform}-${architecture}.`,
1336 });
1337 }
1338 const paths = binaryPaths(agent, target);
1339 if (paths === undefined) {
1340 return yield* new AcpRegistryError({
1341 reason: "archive_invalid",
1342 detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`,
1343 });
1344 }
1345 const { commandSegments, installRoot, executablePath } = paths;
1346 if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
1347 return yield* assertExecutableInRoot(installRoot, executablePath).pipe(
1348 Effect.mapError((cause) =>
1349 isAcpRegistryError(cause)
1350 ? cause
1351 : new AcpRegistryError({
1352 reason: "install_failed",
1353 detail: `Could not validate cached ACP Registry agent ${agent.id}.`,
1354 cause,
1355 }),
1356 ),
1357 );
1358 }
1359
1360 yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe(
1361 Effect.mapError(
1362 (cause) =>
1363 new AcpRegistryError({
1364 reason: "install_failed",
1365 detail: `Could not create ACP Registry cache for ${agent.id}.`,
1366 cause,
1367 }),
1368 ),
1369 );
1370 const lockPath = `${installRoot}.lock`;
1371 yield* acquireInstallLock(lockPath).pipe(
1372 Effect.mapError((cause) =>
1373 isAcpRegistryError(cause)
1374 ? cause
1375 : new AcpRegistryError({
1376 reason: "install_failed",
1377 detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`,
1378 cause,
1379 }),
1380 ),
1381 );
1382
1383 return yield* Effect.gen(function* () {
1384 if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
1385 return yield* assertExecutableInRoot(installRoot, executablePath);
1386 }
1387 const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({
1388 directory: path.dirname(installRoot),
1389 prefix: `.${agent.id}-install-`,
1390 });
1391 const extractionRoot = path.join(temporaryRoot, "extracted");
1392 yield* fileSystem.makeDirectory(extractionRoot, { recursive: true });
1393 const kind = archiveKind(target.archive);
1394 const archivePath = path.join(temporaryRoot, archiveFileName(kind));
1395 const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe(
1396 Effect.flatMap(HttpClientResponse.filterStatusOk),
1397 Effect.mapError(
1398 (cause) =>
1399 new AcpRegistryError({
1400 reason: "download_failed",
1401 detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`,
1402 cause,
1403 }),
1404 ),
1405 Effect.timeoutOrElse({
1406 duration: ARCHIVE_REQUEST_TIMEOUT,
1407 orElse: () =>
1408 Effect.fail(
1409 new AcpRegistryError({
1410 reason: "download_failed",
1411 detail: `Timed out downloading ACP Registry agent ${agent.id} ${agent.version}.`,
1412 }),
1413 ),
1414 }),
1415 );
1416 // Hash while streaming: archives can be up to MAX_ARCHIVE_BYTES, and
1417 // Effect's Crypto only digests a whole buffer.
1418 const hash = sha256.create();
1419 let downloadedBytes = 0;
1420 yield* response.stream.pipe(
1421 Stream.tap((chunk) => {
1422 if (downloadedBytes + chunk.byteLength > MAX_ARCHIVE_BYTES) {
1423 return Effect.fail(
1424 new AcpRegistryError({
1425 reason: "archive_invalid",
1426 detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`,
1427 }),
1428 );
1429 }
1430 downloadedBytes += chunk.byteLength;
1431 hash.update(chunk);
1432 return Effect.void;
1433 }),
1434 Stream.run(fileSystem.sink(archivePath)),
1435 Effect.mapError((cause) =>
1436 isAcpRegistryError(cause)
1437 ? cause
1438 : new AcpRegistryError({
1439 reason: "download_failed",
1440 detail: `Could not save ACP Registry agent ${agent.id} ${agent.version}.`,
1441 cause,
1442 }),
1443 ),
1444 Effect.timeoutOrElse({
1445 duration: ARCHIVE_REQUEST_TIMEOUT,
1446 orElse: () =>
1447 Effect.fail(
1448 new AcpRegistryError({
1449 reason: "download_failed",
1450 detail: `Timed out reading ACP Registry agent ${agent.id} download.`,
1451 }),
1452 ),
1453 }),
1454 );
1455 if (target.sha256 !== undefined) {
1456 const actual = Hex.encode(hash.digest());
1457 if (actual !== target.sha256.toLowerCase()) {
1458 return yield* new AcpRegistryError({
1459 reason: "checksum_mismatch",
1460 detail: `ACP Registry agent ${agent.id} download did not match its declared SHA-256.`,
1461 });
1462 }
1463 }
1464
1465 if (kind === "raw") {
1466 const targetPath = path.join(extractionRoot, ...commandSegments);
1467 yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true });
1468 yield* fileSystem.rename(archivePath, targetPath);
1469 } else {
1470 const listArgs =
1471 kind === "tar_gz"
1472 ? ["-tzf", archivePath]
1473 : kind === "tar_bz2"
1474 ? ["-tjf", archivePath]
1475 : platform === "win32"
1476 ? ["-tf", archivePath]
1477 : undefined;
1478 const entries =
1479 listArgs === undefined
1480 ? yield* runCommand("unzip", ["-Z1", archivePath])
1481 : yield* runCommand("tar", listArgs);
1482 if (!validateArchiveEntries(entries)) {
1483 return yield* new AcpRegistryError({
1484 reason: "archive_invalid",
1485 detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`,
1486 });
1487 }
1488 if (kind === "zip" && platform !== "win32") {
1489 yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]);
1490 } else {
1491 const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf";
1492 yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]);
1493 }
1494 }
1495
1496 const stagedExecutable = path.join(extractionRoot, ...commandSegments);
1497 if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) {
1498 return yield* new AcpRegistryError({
1499 reason: "archive_invalid",
1500 detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`,
1501 });
1502 }
1503 if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755);
1504 yield* assertExecutableInRoot(extractionRoot, stagedExecutable);
1505 yield* fileSystem.remove(installRoot, { recursive: true, force: true });
1506 yield* fileSystem.rename(extractionRoot, installRoot);
1507 return yield* assertExecutableInRoot(installRoot, executablePath);
1508 }).pipe(
1509 Effect.scoped,
1510 Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)),
1511 Effect.mapError((cause) =>
1512 isAcpRegistryError(cause)
1513 ? cause
1514 : new AcpRegistryError({
1515 reason: "install_failed",
1516 detail: `Could not install ACP Registry agent ${agent.id}.`,
1517 cause,
1518 }),
1519 ),
1520 );
1521 });
1522
1523 const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* (
1524 registry: AcpRegistryIndex,
1525 agentId: string,
1526 ) {
1527 const agent = registry.agents.find((candidate) => candidate.id === agentId.trim());
1528 if (agent === undefined) {
1529 return yield* new AcpRegistryError({
1530 reason: "agent_not_found",
1531 detail: `ACP Registry does not contain agent '${agentId.trim()}'.`,
1532 });
1533 }
1534 return agent;
1535 });
1536
1537 const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* (
1538 agent: AcpRegistryAgent,
1539 preference: AcpRegistryDistributionPreference,
1540 ) {
1541 const distribution = resolveAcpRegistryDistribution({
1542 agent,
1543 preference,
1544 platformTarget,
1545 });
1546 if (distribution === undefined) {
1547 return yield* new AcpRegistryError({
1548 reason: platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution",
1549 detail: `ACP Registry agent ${agent.id} has no ${preference === "auto" ? "compatible" : preference} distribution for ${platform}-${architecture}.`,
1550 });
1551 }
1552 return distribution;
1553 });
1554
1555 const search: AcpRegistryCatalog["Service"]["search"] = (input) =>
1556 Effect.gen(function* () {
1557 const registry = yield* refreshRegistry();
1558 const ranked = registry.agents.flatMap((agent) => {
1559 const distribution = resolveAcpRegistryDistribution({
1560 agent,
1561 preference: "auto",
1562 platformTarget,
1563 });
1564 const rank = searchRank(agent, input.query);
1565 const packageManager =
1566 distribution === undefined ? undefined : packageManagerFor(distribution.kind);
1567 const packageManagerAvailable =
1568 packageManager === undefined ||
1569 resolveExecutable(packageManager, platform, hostEnvironment) !== undefined;
1570 return distribution === undefined || rank === undefined || !packageManagerAvailable
1571 ? []
1572 : [{ agent, distribution, rank }];
1573 });
1574 ranked.sort(
1575 (left, right) =>
1576 left.rank - right.rank ||
1577 compareText(left.agent.name.toLowerCase(), right.agent.name.toLowerCase()) ||
1578 compareText(left.agent.id, right.agent.id),
1579 );
1580 return {
1581 agents: ranked.slice(0, MAX_SEARCH_RESULTS).map(({ agent, distribution }) => ({
1582 id: agent.id,
1583 name: agent.name,
1584 version: agent.version,
1585 description: agent.description,
1586 authors: agent.authors ?? [],
1587 license: agent.license ?? null,
1588 website: agent.website ?? null,
1589 repository: agent.repository ?? null,
1590 icon: agent.icon ?? null,
1591 distribution: distribution.kind,
1592 integrity:
1593 distribution.kind === "binary" && distribution.binaryTarget?.sha256 !== undefined
1594 ? "sha256"
1595 : "registry",
1596 })),
1597 } satisfies AcpRegistrySearchResult;
1598 });
1599
1600 const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) =>
1601 Effect.gen(function* () {
1602 const registry = yield* refreshRegistry();
1603 const agent = yield* findAgent(registry, input.agentId);
1604 const distribution = yield* compatibleDistribution(agent, "auto");
1605 if (distribution.kind === "binary") {
1606 // A PATH executable can be a different version with different ACP
1607 // capabilities. Only an explicit command override opts into that copy.
1608 yield* installSemaphore.withPermits(1)(
1609 installBinary(agent, distribution.binaryTarget!).pipe(
1610 Effect.tap(() => reservePreparedBinary(agent.id)),
1611 ),
1612 );
1613 } else {
1614 yield* installSemaphore.withPermits(1)(
1615 ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment),
1616 );
1617 }
1618 return {
1619 agentId: agent.id,
1620 version: agent.version,
1621 distribution: distribution.kind,
1622 prepared: true,
1623 } satisfies AcpRegistryPrepareResult;
1624 });
1625
1626 const validateLocalExecutable = (command: string) =>
1627 platform === "win32" && /\.(?:cmd|bat)$/iu.test(command)
1628 ? Effect.fail(
1629 new AcpRegistryError({
1630 reason: "runner_unavailable",
1631 detail:
1632 "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.",
1633 }),
1634 )
1635 : Effect.void;
1636
1637 const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) =>
1638 Effect.gen(function* () {
1639 if (settings.source === "local") {
1640 const command = settings.commandPath.trim();
1641 if (!command) return { status: "unconfigured" } as const;
1642 const executable = resolveExecutable(command, platform, environment ?? hostEnvironment);
1643 if (executable !== undefined) yield* validateLocalExecutable(executable);
1644 return executable === undefined
1645 ? ({
1646 status: "missing_runner",
1647 version: null,
1648 distribution: "local",
1649 } as const)
1650 : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const);
1651 }
1652 const agentId = settings.agentId.trim();
1653 if (agentId.length === 0) return { status: "unconfigured" } as const;
1654 const registry = yield* loadCachedRegistry();
1655 const agent = registry.agents.find((candidate) => candidate.id === agentId);
1656 if (agent === undefined) return { status: "not_found", agentId } as const;
1657 const documentationUrl = agent.website ?? agent.repository;
1658 const distribution = resolveAcpRegistryDistribution({
1659 agent,
1660 preference: settings.distribution,
1661 platformTarget,
1662 });
1663 if (distribution === undefined) {
1664 return { status: "unsupported", agentId, version: agent.version } as const;
1665 }
1666
1667 const commandOverride = settings.commandPath.trim();
1668 if (commandOverride.length > 0) {
1669 const available =
1670 resolveExecutable(commandOverride, platform, environment ?? hostEnvironment) !==
1671 undefined;
1672 return available
1673 ? ({
1674 status: "ready",
1675 agentId,
1676 // The catalog version does not describe an overridden executable.
1677 version: null,
1678 distribution: distribution.kind,
1679 ...(documentationUrl ? { documentationUrl } : {}),
1680 } as const)
1681 : ({
1682 status: "missing_runner",
1683 agentId,
1684 version: agent.version,
1685 distribution: distribution.kind,
1686 runner: commandOverride,
1687 } as const);
1688 }
1689
1690 if (distribution.kind === "binary") {
1691 return yield* installSemaphore.withPermits(1)(
1692 Effect.gen(function* () {
1693 const paths = binaryPaths(agent, distribution.binaryTarget!);
1694 if (paths === undefined) {
1695 return { status: "unsupported", agentId, version: agent.version } as const;
1696 }
1697 yield* consumePreparedBinaryReservation(agent.id);
1698 const exists = yield* fileSystem
1699 .exists(paths.executablePath)
1700 .pipe(Effect.orElseSucceed(() => false));
1701 if (!exists) {
1702 return {
1703 status: "unprepared",
1704 agentId,
1705 version: agent.version,
1706 distribution: "binary",
1707 } as const;
1708 }
1709 yield* assertExecutableInRoot(paths.installRoot, paths.executablePath).pipe(
1710 Effect.mapError((cause) =>
1711 isAcpRegistryError(cause)
1712 ? cause
1713 : new AcpRegistryError({
1714 reason: "install_failed",
1715 detail: `Could not validate cached ACP Registry agent ${agent.id}.`,
1716 cause,
1717 }),
1718 ),
1719 );
1720 return {
1721 status: "ready",
1722 agentId,
1723 version: agent.version,
1724 distribution: "binary",
1725 ...(documentationUrl ? { documentationUrl } : {}),
1726 } as const;
1727 }),
1728 );
1729 }
1730
1731 const runner = packageManagerFor(distribution.kind)!;
1732 const available =
1733 resolveExecutable(runner, platform, environment ?? hostEnvironment) !== undefined;
1734 return available
1735 ? ({
1736 status: "ready",
1737 agentId,
1738 version: agent.version,
1739 distribution: distribution.kind,
1740 ...(documentationUrl ? { documentationUrl } : {}),
1741 } as const)
1742 : ({
1743 status: "missing_runner",
1744 agentId,
1745 version: agent.version,
1746 distribution: distribution.kind,
1747 runner,
1748 } as const);
1749 });
1750
1751 const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) =>
1752 Effect.gen(function* () {
1753 if (settings.source === "local") {
1754 const executable = settings.commandPath.trim();
1755 if (!executable) {
1756 return yield* new AcpRegistryError({
1757 reason: "agent_not_configured",
1758 detail: "Local ACP provider requires an executable.",
1759 });
1760 }
1761 const env = environment ?? hostEnvironment;
1762 const command = resolveExecutable(executable, platform, env);
1763 if (command === undefined) {
1764 return yield* new AcpRegistryError({
1765 reason: "runner_unavailable",
1766 detail: "Local ACP executable is not available on this environment's PATH.",
1767 });
1768 }
1769 yield* validateLocalExecutable(command);
1770 return {
1771 distribution: "local",
1772 spawn: { command, args: settings.commandArgs, cwd, env, shell: false },
1773 } satisfies ResolvedAcpRegistryAgent;
1774 }
1775 const agentId = settings.agentId.trim();
1776 if (agentId.length === 0) {
1777 return yield* new AcpRegistryError({
1778 reason: "agent_not_configured",
1779 detail: "ACP Registry provider requires a registry agent ID.",
1780 });
1781 }
1782 const registry = yield* loadRegistry();
1783 const agent = yield* findAgent(registry, agentId);
1784 const distribution = yield* compatibleDistribution(agent, settings.distribution);
1785
1786 let command: string;
1787 let args: ReadonlyArray<string>;
1788 let commandBinDirectory: string | undefined;
1789 const effectiveEnvironment = environment ?? hostEnvironment;
1790 const commandOverride = settings.commandPath.trim();
1791 if (commandOverride.length > 0) {
1792 const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment);
1793 if (resolvedOverride === undefined) {
1794 return yield* new AcpRegistryError({
1795 reason: "runner_unavailable",
1796 detail: `ACP Registry agent ${agent.id} requires '${commandOverride}', but it is not available on this provider instance's PATH.`,
1797 });
1798 }
1799 command = resolvedOverride;
1800 args = distribution.args;
1801 } else if (distribution.kind === "npx" || distribution.kind === "uvx") {
1802 const installed = yield* installSemaphore.withPermits(1)(
1803 ensureGlobalPackage(
1804 agent,
1805 distribution.kind,
1806 distribution.packageName!,
1807 effectiveEnvironment,
1808 ),
1809 );
1810 command = installed.executablePath;
1811 args = distribution.args;
1812 commandBinDirectory = installed.binDirectory;
1813 } else {
1814 const target = distribution.binaryTarget!;
1815 command = yield* installSemaphore.withPermits(1)(
1816 installBinary(agent, target).pipe(
1817 Effect.tap(() => consumePreparedBinaryReservation(agent.id)),
1818 ),
1819 );
1820 args = distribution.args;
1821 }
1822
1823 const baseSpawnEnvironment = {
1824 ...effectiveEnvironment,
1825 ...distribution.env,
1826 };
1827 const spawnEnvironment =
1828 commandBinDirectory === undefined
1829 ? baseSpawnEnvironment
1830 : withPreferredPath(baseSpawnEnvironment, commandBinDirectory, platform);
1831
1832 return {
1833 agent,
1834 distribution: distribution.kind,
1835 spawn: {
1836 command,
1837 args,
1838 cwd,
1839 env: spawnEnvironment,
1840 },
1841 } satisfies ResolvedAcpRegistryAgent;
1842 });
1843
1844 const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) =>
1845 serverSettings
1846 .withSettingsSnapshot((settings) =>
1847 installSemaphore.withPermits(1)(
1848 Effect.gen(function* () {
1849 const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe(
1850 Effect.mapError(
1851 (cause) =>
1852 new AcpRegistryError({
1853 reason: "install_failed",
1854 detail: "ACP Registry managed binary uninstall received an invalid agent ID.",
1855 cause,
1856 }),
1857 ),
1858 );
1859 const isReferenced = Object.values(settings.providerInstances).some((instance) => {
1860 if (
1861 instance.driver !== "acpRegistry" ||
1862 instance.config === null ||
1863 typeof instance.config !== "object"
1864 )
1865 return false;
1866 const config = instance.config as Record<string, unknown>;
1867 return (
1868 config.source !== "local" &&
1869 typeof config.agentId === "string" &&
1870 config.agentId.trim() === safeAgentId
1871 );
1872 });
1873 if (isReferenced) {
1874 yield* consumePreparedBinaryReservation(safeAgentId);
1875 return { agentId: safeAgentId, removed: false };
1876 }
1877 if (yield* hasPreparedBinaryReservation(safeAgentId)) {
1878 return { agentId: safeAgentId, removed: false };
1879 }
1880 const agentRoot = path.join(installsDirectory, safeAgentId);
1881 const existed = yield* fileSystem.exists(agentRoot).pipe(
1882 Effect.mapError(
1883 (cause) =>
1884 new AcpRegistryError({
1885 reason: "install_failed",
1886 detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`,
1887 cause,
1888 }),
1889 ),
1890 );
1891 if (!existed) return { agentId: safeAgentId, removed: false };
1892
1893 let removed = false;
1894 yield* Effect.gen(function* () {
1895 for (const version of yield* fileSystem.readDirectory(agentRoot)) {
1896 const versionRoot = path.join(agentRoot, version);
1897 for (const entry of yield* fileSystem.readDirectory(versionRoot)) {
1898 if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue;
1899 yield* fileSystem.remove(path.join(versionRoot, entry), {
1900 recursive: true,
1901 force: true,
1902 });
1903 removed = true;
1904 }
1905 if ((yield* fileSystem.readDirectory(versionRoot)).length === 0)
1906 yield* fileSystem.remove(versionRoot, { recursive: true });
1907 }
1908 if ((yield* fileSystem.readDirectory(agentRoot)).length === 0)
1909 yield* fileSystem.remove(agentRoot, { recursive: true });
1910 }).pipe(
1911 Effect.mapError(
1912 (cause) =>
1913 new AcpRegistryError({
1914 reason: "install_failed",
1915 detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`,
1916 cause,
1917 }),
1918 ),
1919 );
1920 return { agentId: safeAgentId, removed };
1921 }),
1922 ),
1923 )
1924 .pipe(
1925 Effect.catchTags({
1926 ServerSettingsError: (cause) =>
1927 new AcpRegistryError({
1928 reason: "install_failed",
1929 detail:
1930 "Could not read provider settings while checking managed ACP binary references.",
1931 cause,
1932 }),
1933 }),
1934 );
1935
1936 return AcpRegistryCatalog.of({
1937 search,
1938 prepare,
1939 inspect,
1940 resolve,
1941 uninstallManagedBinary,
1942 });
1943});
apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh deleted-21
......@@ -1,21 +0,0 @@
1#!/bin/sh
2printf '%s\n' "$*" >> "$FAKE_NPM_LOG"
3prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}"
4if [ "$1" = "root" ] && [ "$2" = "--global" ]; then
5 printf '%s\n' "$prefix/lib/node_modules"
6 exit 0
7fi
8if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then
9 printf '%s\n' "$prefix"
10 exit 0
11fi
12if [ "$1" = "install" ] && [ "$2" = "--global" ]; then
13 package_root="$prefix/lib/node_modules/@example/acp"
14 executable="$prefix/bin/example-agent"
15 mkdir -p "$package_root" "$prefix/bin"
16 printf '%s' "$FAKE_NPM_MANIFEST" > "$package_root/package.json"
17 printf '#!/bin/sh\n' > "$executable"
18 chmod 755 "$executable"
19 exit 0
20fi
21exit 64
apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh deleted-22
......@@ -1,22 +0,0 @@
1#!/bin/sh
2printf '%s\n' "$*" >> "$FAKE_UV_LOG"
3tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}"
4executable="$tool_bin/fast-agent"
5printf "tool-dir=%s bin-dir=%s\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG"
6if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then
7 printf '%s\n' "$tool_bin"
8 exit 0
9fi
10if [ "$1" = "tool" ] && [ "$2" = "list" ]; then
11 if [ -x "$executable" ]; then
12 printf 'fast-agent-acp v0.10.1\n- fast-agent\n'
13 fi
14 exit 0
15fi
16if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then
17 mkdir -p "$tool_bin"
18 printf '#!/bin/sh\n' > "$executable"
19 chmod 755 "$executable"
20 exit 0
21fi
22exit 64
apps/server/src/provider/builtInDrivers.ts+4-1
......@@ -20,7 +20,10 @@
2020 *
2121 * @module provider/builtInDrivers
2222 */
23import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts";
23import {
24 AcpRegistryDriver,
25 type AcpRegistryDriverEnv,
26} from "@t3tools/provider-acp-registry/server";
2427import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";
2528import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
2629import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
apps/server/src/server.ts+3-2
......@@ -45,7 +45,8 @@ import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
4545import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts";
4646import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime";
4747import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger";
48import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts";
48import * as ProviderHostLive from "./provider/ProviderHostLive.ts";
49import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
4950import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
5051import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
5152import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
......@@ -597,7 +598,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
597598 Layer.provideMerge(layerPtyAdapter),
598599 // Search, prepare, status inspection, and turn launch share one registry
599600 // cache so every client and provider instance sees the same prepared agents.
600 Layer.provideMerge(AcpRegistryCatalog.layer.pipe(Layer.provide(layerServerSettings))),
601 Layer.provideMerge(AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer))),
601602 // Shared native/canonical NDJSON writers used by both the per-instance
602603 // V2 drivers and the orchestration runtime. Provide resource attribution so
603604 // the rewritten telemetry pipeline can account for logical NDJSON writes.
apps/server/src/terminal/Manager.test.ts+1-1
......@@ -41,7 +41,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
4141import * as ProcessRunner from "../processRunner.ts";
4242import * as ServerSettings from "../serverSettings.ts";
4343import * as TerminalManager from "./Manager.ts";
44import * as PtyAdapter from "./PtyAdapter.ts";
44import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
4545
4646const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
4747
apps/server/src/terminal/Manager.ts+2-2
......@@ -45,7 +45,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock";
4545import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4646import { mergePathEntries } from "@t3tools/shared/shell";
4747
48import { acpRegistryManagedBinaryDirectories } from "../provider/acp/AcpRegistrySupport.ts";
48import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server";
4949import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
5050import * as DateTime from "effect/DateTime";
5151import * as Context from "effect/Context";
......@@ -77,7 +77,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
7777import * as ProcessRunner from "../processRunner.ts";
7878import * as PortScanner from "../preview/PortScanner.ts";
7979import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
80import * as PtyAdapter from "./PtyAdapter.ts";
80import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
8181
8282export {
8383 TerminalCwdError,
apps/server/src/terminal/NodePtyAdapter.test.ts+1-1
......@@ -14,7 +14,7 @@ import * as Scheduler from "effect/Scheduler";
1414import { expect, vi } from "vite-plus/test";
1515
1616import * as NodePtyAdapter from "./NodePtyAdapter.ts";
17import * as PtyAdapter from "./PtyAdapter.ts";
17import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
1818
1919function makeNativeProcess(pid = 42) {
2020 const events = new NodeEvents.EventEmitter();
apps/server/src/terminal/NodePtyAdapter.ts+1-1
......@@ -9,7 +9,7 @@ import * as Path from "effect/Path";
99import * as Schema from "effect/Schema";
1010import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1111
12import * as PtyAdapter from "./PtyAdapter.ts";
12import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
1313
1414export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoadError>()(
1515 "NodePtyModuleLoadError",
apps/server/src/terminal/PtyAdapter.ts deleted-66
......@@ -1,66 +0,0 @@
1/**
2 * PtyAdapter - Terminal PTY adapter service contract.
3 *
4 * Defines the process primitives required by terminal session management
5 * without binding to a specific PTY implementation.
6 *
7 * @module PtyAdapter
8 */
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12
13/**
14 * PtySpawnError - Error type for PTY spawn failures.
15 */
16export class PtySpawnError extends Schema.TaggedError<PtySpawnError>()("PtySpawnError", {
17 adapter: Schema.String,
18 shell: Schema.optional(Schema.String),
19 attemptedShells: Schema.optional(Schema.Array(Schema.String)),
20 cause: Schema.optional(Schema.Defect()),
21}) {
22 override get message(): string {
23 const shell = this.shell === undefined ? "" : ` '${this.shell}'`;
24 const attemptedShells =
25 this.attemptedShells === undefined || this.attemptedShells.length === 0
26 ? ""
27 : ` Tried shells: ${this.attemptedShells.join(", ")}.`;
28 return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`;
29 }
30}
31
32export interface PtyExitEvent {
33 exitCode: number;
34 signal: number | null;
35}
36
37export interface PtyProcess {
38 readonly pid: number;
39 write(data: string): void;
40 resize(cols: number, rows: number): void;
41 kill(signal?: string): void;
42 onData(callback: (data: string) => void): () => void;
43 onExit(callback: (event: PtyExitEvent) => void): () => void;
44}
45
46export interface PtySpawnInput {
47 shell: string;
48 args?: string[];
49 cwd: string;
50 cols: number;
51 rows: number;
52 env: NodeJS.ProcessEnv;
53}
54
55/**
56 * PtyAdapter - Service tag for PTY process integration.
57 */
58export class PtyAdapter extends Context.Service<
59 PtyAdapter,
60 {
61 /**
62 * Spawn a PTY process for a terminal session.
63 */
64 readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>;
65 }
66>()("t3/terminal/PtyAdapter") {}
apps/server/src/ws.ts+2-2
......@@ -165,8 +165,8 @@ import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.
165165import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
166166import * as ProviderRegistry from "./provider/ProviderRegistry.ts";
167167import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
168import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts";
169import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts";
168import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
169import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator";
170170import * as ModelManifest from "./provider/ModelManifest.ts";
171171import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver";
172172import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
apps/web/package.json+1
......@@ -28,6 +28,7 @@
2828 "@pierre/trees": "1.0.0-beta.6",
2929 "@t3tools/client-runtime": "workspace:*",
3030 "@t3tools/contracts": "workspace:*",
31 "@t3tools/provider-acp-registry": "workspace:*",
3132 "@t3tools/provider-core": "workspace:*",
3233 "@t3tools/provider-cursor": "workspace:*",
3334 "@t3tools/provider-grok": "workspace:*",
apps/web/src/components/settings/providerDriverMeta.ts+2-7
......@@ -1,10 +1,10 @@
11import {
2 AcpRegistrySettings,
32 AntigravitySettings,
43 ClaudeSettings,
54 CodexSettings,
65 ProviderDriverKind,
76} from "@t3tools/contracts";
7import { acpRegistryClient } from "@t3tools/provider-acp-registry/client";
88import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
99import { cursorClient } from "@t3tools/provider-cursor/client";
1010import { grokClient } from "@t3tools/provider-grok/client";
......@@ -34,10 +34,5 @@ export const providerClients = makeProviderClientRegistry([
3434 },
3535 museClient,
3636 piClient,
37 {
38 driverKind: ProviderDriverKind.make("acpRegistry"),
39 label: "ACP Registry",
40 settingsSchema: AcpRegistrySettings,
41 hasDefaultInstance: false,
42 },
37 acpRegistryClient,
4338]);
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/contracts/src/settings.ts-67
......@@ -788,73 +788,6 @@ export const AntigravitySettings = makeProviderSettingsSchema(
788788);
789789export type AntigravitySettings = typeof AntigravitySettings.Type;
790790
791export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]);
792export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type;
793
794export const AcpRegistrySettings = makeProviderSettingsSchema(
795 {
796 source: Schema.Literals(["registry", "local"]).pipe(
797 Schema.withDecodingDefault(Effect.succeed("registry")),
798 Schema.annotateKey({
799 title: "ACP source",
800 providerSettingsForm: {
801 control: "select",
802 options: [
803 { value: "registry", label: "ACP Registry" },
804 { value: "local", label: "Local command" },
805 ],
806 },
807 }),
808 ),
809 enabled: Schema.Boolean.pipe(
810 Schema.withDecodingDefault(Effect.succeed(true)),
811 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
812 ),
813 agentId: TrimmedString.pipe(
814 Schema.withDecodingDefault(Effect.succeed("")),
815 Schema.annotateKey({
816 title: "Registry agent ID",
817 description: "Agent identifier from the official ACP Registry, for example 'devin'.",
818 providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" },
819 }),
820 ),
821 commandPath: TrimmedString.pipe(
822 Schema.withDecodingDefault(Effect.succeed("")),
823 Schema.annotateKey({
824 title: "Executable override",
825 description:
826 "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.",
827 providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" },
828 }),
829 ),
830 commandArgs: Schema.Array(Schema.String).pipe(
831 Schema.withDecodingDefault(Effect.succeed([])),
832 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
833 ),
834 authMethodId: TrimmedString.pipe(
835 Schema.withDecodingDefault(Effect.succeed("")),
836 Schema.annotateKey({
837 title: "Authentication method",
838 description:
839 "Optional ACP authentication method ID. By default, the first agent-managed method is selected.",
840 providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" },
841 }),
842 ),
843 distribution: AcpRegistryDistributionPreference.pipe(
844 Schema.withDecodingDefault(Effect.succeed("auto")),
845 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
846 ),
847 customModels: Schema.Array(Schema.String).pipe(
848 Schema.withDecodingDefault(Effect.succeed([])),
849 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
850 ),
851 },
852 {
853 order: ["source", "agentId", "commandPath", "authMethodId"],
854 },
855);
856export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
857
858791/**
859792 * A read-only quota source outside this environment's provider CLIs. The
860793 * only kind today is a CLIProxyAPI hub, whose management API reports the
packages/provider-acp-registry/package.json created+51
......@@ -0,0 +1,51 @@
1{
2 "name": "@t3tools/provider-acp-registry",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./settings": {
11 "types": "./src/settings.ts",
12 "import": "./src/settings.ts"
13 },
14 "./server": {
15 "types": "./src/server.ts",
16 "import": "./src/server.ts"
17 },
18 "./testing": {
19 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"
21 },
22 "./server/AcpRegistrySupport": {
23 "types": "./src/server/AcpRegistrySupport.ts",
24 "import": "./src/server/AcpRegistrySupport.ts"
25 },
26 "./server/AcpRegistryRuntimeCoordinator": {
27 "types": "./src/server/AcpRegistryRuntimeCoordinator.ts",
28 "import": "./src/server/AcpRegistryRuntimeCoordinator.ts"
29 }
30 },
31 "scripts": {
32 "typecheck": "tsc --noEmit",
33 "test": "vp test run --config ../../vite.config.ts --dir ."
34 },
35 "dependencies": {
36 "@noble/hashes": "catalog:",
37 "@t3tools/contracts": "workspace:*",
38 "@t3tools/provider-acp": "workspace:*",
39 "@t3tools/provider-core": "workspace:*",
40 "@t3tools/shared": "workspace:*",
41 "effect": "catalog:"
42 },
43 "devDependencies": {
44 "@effect/platform-node": "catalog:",
45 "@effect/vitest": "catalog:",
46 "@t3tools/provider-testing": "workspace:*",
47 "@types/node": "catalog:",
48 "effect-acp": "workspace:*",
49 "vite-plus": "catalog:"
50 }
51}
packages/provider-acp-registry/src/client.ts created+28
......@@ -0,0 +1,28 @@
1/**
2 * ACP Registry's client definition: settings form, label, and the registry
3 * glyph clients fall back to while an agent's own icon loads.
4 * Browser- and React Native-safe.
5 *
6 * @module provider-acp-registry/client
7 */
8import { ProviderDriverKind } from "@t3tools/contracts";
9import { defineProviderClient } from "@t3tools/provider-core/client";
10
11import { AcpRegistrySettings } from "./settings.ts";
12
13export const acpRegistryClient = defineProviderClient({
14 driverKind: ProviderDriverKind.make("acpRegistry"),
15 label: "ACP Registry",
16 settingsSchema: AcpRegistrySettings,
17 // Each instance is an agent the user adds from the registry.
18 hasDefaultInstance: false,
19 icon: {
20 viewBox: "0 0 576 220",
21 fill: { light: "#000000", dark: "#FFFFFF" },
22 paths: [
23 {
24 d: "M568.003 115.821L517.278 27.9661C507.183 10.4816 489.084 0.0227367 468.894 0.0227367C448.727 0.0227367 430.674 10.4361 420.556 27.8752L343.251 161.749H242.755C236.23 161.749 230.386 158.384 227.135 152.745C223.861 147.106 223.861 140.376 227.135 134.715L277.861 46.8603C281.112 41.2216 286.955 37.8338 293.481 37.8338C300.006 37.8338 305.827 41.1988 309.101 46.8603L312.125 52.0897C313.353 54.2042 315.604 55.5002 318.036 55.5002C320.469 55.5002 322.743 54.1815 323.948 52.067L337.385 28.5118C338.795 26.0335 338.522 22.9413 336.703 20.7586C325.699 7.57131 309.874 0 293.322 0C292.662 0 292.003 0 291.321 0.0454733C272.04 0.75031 254.76 11.1864 245.074 27.9434L200.215 105.657L155.81 29.1484C145.465 11.2092 126.594 0.0227367 106.608 0.0227367C105.949 0.0227367 105.289 0.0227367 104.607 0.06821C85.3265 0.773047 68.0467 11.2092 58.3608 27.9661L7.65806 115.821C-6.25678 139.899 -0.868187 168.82 21.05 187.759C29.8945 195.422 41.6039 199.628 54.0181 199.628H148.648C151.081 199.628 153.332 198.332 154.56 196.217L168.52 172.026C169.748 169.911 169.748 167.319 168.52 165.205C167.292 163.09 165.041 161.794 162.608 161.794H56.0417C49.5163 161.794 43.6729 158.429 40.4216 152.79C37.1475 147.152 37.1475 140.422 40.4216 134.76L91.1471 46.9057C94.3985 41.2671 100.242 37.8793 106.767 37.8793C113.293 37.8793 119.113 41.2443 122.387 46.9057L194.826 172.526C195.031 172.89 195.258 173.208 195.531 173.526C198.76 178.665 202.83 183.485 207.786 187.782C216.631 195.444 228.34 199.651 240.754 199.651H321.424L315.581 209.769C314.353 211.883 314.353 214.475 315.581 216.59C316.809 218.704 319.059 220 321.492 220H349.436C351.868 220 354.119 218.704 355.347 216.59L364.396 200.901L367.17 196.468C367.17 196.468 367.261 196.331 367.284 196.263L453.274 46.9285C456.525 41.2898 462.369 37.902 468.894 37.902C475.42 37.902 481.263 41.2671 484.514 46.9285L535.24 134.783C538.491 140.422 538.514 147.174 535.24 152.813C531.988 158.452 526.145 161.84 519.62 161.84H418.669C416.236 161.84 413.985 163.136 412.757 165.25L398.774 189.442C397.546 191.556 397.546 194.148 398.774 196.263C400.002 198.377 402.253 199.673 404.686 199.673H518.21C539.81 199.673 559.295 188.237 569.026 169.843C578.053 152.79 577.666 132.623 567.981 115.843L568.003 115.821Z",
25 },
26 ],
27 },
28});
packages/provider-acp-registry/src/server.ts created+16
......@@ -0,0 +1,16 @@
1/**
2 * ACP Registry's server entry: the driver and adapter driver the server
3 * registers, the catalog layer it provides once, and the managed-binary
4 * directories the terminal manager keeps off agent PATHs.
5 *
6 * The catalog and runtime coordinator are services; import them from
7 * `./server/AcpRegistrySupport` and `./server/AcpRegistryRuntimeCoordinator`.
8 *
9 * @module provider-acp-registry/server
10 */
11export { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./server/driver.ts";
12export {
13 AcpRegistryAdapterV2Driver,
14 type AcpRegistryAdapterV2DriverEnv,
15} from "./server/adapter.ts";
16export { acpRegistryManagedBinaryDirectories } from "./server/AcpRegistrySupport.ts";
packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.test.ts created+336
......@@ -0,0 +1,336 @@
1import { describe, expect, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Option from "effect/Option";
7import * as TestClock from "effect/testing/TestClock";
8
9import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts";
10import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
11
12describe("AcpRegistryRuntimeCoordinator", () => {
13 it.effect("suppresses a background probe while foreground startup is active", () =>
14 Effect.gen(function* () {
15 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
16 const foregroundStarted = yield* Deferred.make<void>();
17 const releaseForeground = yield* Deferred.make<void>();
18 const foreground = yield* coordinator
19 .withForegroundStartup(
20 "kilo",
21 Deferred.succeed(foregroundStarted, undefined).pipe(
22 Effect.andThen(Deferred.await(releaseForeground)),
23 ),
24 )
25 .pipe(Effect.forkChild);
26 yield* Deferred.await(foregroundStarted);
27
28 const probed = yield* coordinator.runBackgroundProbe("kilo", Effect.succeed("unexpected"));
29 expect(Option.isNone(probed)).toBe(true);
30
31 yield* Deferred.succeed(releaseForeground, undefined);
32 yield* Fiber.join(foreground);
33 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
34 );
35
36 it.effect("interrupts an active background probe when foreground startup begins", () =>
37 Effect.gen(function* () {
38 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
39 const probeStarted = yield* Deferred.make<void>();
40 const probeFinalized = yield* Deferred.make<void>();
41 const releaseForeground = yield* Deferred.make<void>();
42 const probe = yield* coordinator
43 .runBackgroundProbe(
44 "kilo",
45 Deferred.succeed(probeStarted, undefined).pipe(
46 Effect.andThen(Effect.never),
47 Effect.ensuring(Deferred.succeed(probeFinalized, undefined).pipe(Effect.ignore)),
48 ),
49 )
50 .pipe(Effect.forkChild);
51 yield* Deferred.await(probeStarted);
52
53 const foreground = yield* coordinator
54 .withForegroundStartup("kilo", Deferred.await(releaseForeground))
55 .pipe(Effect.forkChild);
56 expect(Option.isNone(yield* Fiber.join(probe))).toBe(true);
57 yield* Deferred.await(probeFinalized);
58
59 yield* Deferred.succeed(releaseForeground, undefined);
60 yield* Fiber.join(foreground);
61 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
62 );
63
64 it.effect("serializes native session mutations across callers", () =>
65 Effect.gen(function* () {
66 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
67 const firstEntered = yield* Deferred.make<void>();
68 const releaseFirst = yield* Deferred.make<void>();
69 const secondEntered = yield* Deferred.make<void>();
70 const first = yield* coordinator
71 .withSessionMutation(
72 Deferred.succeed(firstEntered, undefined).pipe(
73 Effect.andThen(Deferred.await(releaseFirst)),
74 ),
75 )
76 .pipe(Effect.forkChild);
77 yield* Deferred.await(firstEntered);
78
79 const second = yield* coordinator
80 .withSessionMutation(Deferred.succeed(secondEntered, undefined))
81 .pipe(Effect.forkChild);
82 expect(yield* Deferred.poll(secondEntered)).toEqual(Option.none());
83
84 yield* Deferred.succeed(releaseFirst, undefined);
85 yield* Fiber.join(first);
86 yield* Fiber.join(second);
87 expect(Option.isSome(yield* Deferred.poll(secondEntered))).toBe(true);
88 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
89 );
90
91 it.effect("replays and replaces late command advertisements per provider instance", () =>
92 Effect.gen(function* () {
93 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
94 const codex = ProviderInstanceId.make("acpRegistry_codex");
95 const kilo = ProviderInstanceId.make("acpRegistry_kilo");
96 const firstSeen = yield* Deferred.make<void>();
97 const replacementSeen = yield* Deferred.make<void>();
98 const received: Array<AcpRegistryAvailableCommands> = [];
99
100 yield* coordinator.publishAvailableCommands(codex, {
101 slashCommands: [{ name: "status" }],
102 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
103 });
104 const consumer = yield* coordinator
105 .watchAvailableCommands(codex, (commands) =>
106 Effect.gen(function* () {
107 received.push(commands);
108 yield* received.length === 1
109 ? Deferred.succeed(firstSeen, undefined)
110 : Deferred.succeed(replacementSeen, undefined);
111 }),
112 )
113 .pipe(Effect.forkChild);
114 yield* Deferred.await(firstSeen);
115
116 yield* coordinator.publishAvailableCommands(kilo, {
117 slashCommands: [{ name: "review" }],
118 skills: [],
119 });
120 yield* coordinator.publishAvailableCommands(codex, { slashCommands: [], skills: [] });
121 yield* Deferred.await(replacementSeen);
122 yield* Fiber.interrupt(consumer);
123
124 expect(received).toEqual([
125 {
126 slashCommands: [{ name: "status" }],
127 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
128 },
129 { slashCommands: [], skills: [] },
130 ]);
131 expect(yield* coordinator.getAvailableCommands(kilo)).toEqual(
132 Option.some({ slashCommands: [{ name: "review" }], skills: [] }),
133 );
134 yield* coordinator.clearAvailableCommands(codex);
135 expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true);
136 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
137 );
138
139 it.effect("replays and replaces live configuration per provider instance", () =>
140 Effect.gen(function* () {
141 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
142 const instanceId = ProviderInstanceId.make("acpRegistry_kilo");
143 const firstSeen = yield* Deferred.make<void>();
144 const replacementSeen = yield* Deferred.make<void>();
145 const received: Array<AcpRegistryLiveConfiguration> = [];
146 const first = {
147 models: [{ id: "sonnet", name: "Sonnet", description: null }],
148 currentModelId: "sonnet",
149 configOptions: [],
150 } satisfies AcpRegistryLiveConfiguration;
151 const replacement = {
152 models: [{ id: "opus", name: "Opus", description: null }],
153 currentModelId: "opus",
154 configOptions: [],
155 } satisfies AcpRegistryLiveConfiguration;
156
157 yield* coordinator.publishLiveConfiguration(instanceId, first);
158 const consumer = yield* coordinator
159 .watchLiveConfiguration(instanceId, (configuration) =>
160 Effect.gen(function* () {
161 received.push(configuration);
162 yield* received.length === 1
163 ? Deferred.succeed(firstSeen, undefined)
164 : Deferred.succeed(replacementSeen, undefined);
165 }),
166 )
167 .pipe(Effect.forkChild);
168 yield* Deferred.await(firstSeen);
169 yield* coordinator.publishLiveConfiguration(instanceId, replacement);
170 yield* Deferred.await(replacementSeen);
171 yield* Fiber.interrupt(consumer);
172
173 expect(received).toEqual([first, replacement]);
174 expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement));
175 yield* coordinator.clearLiveConfiguration(instanceId);
176 expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true);
177 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
178 );
179
180 it.effect("requires matching user consent before accepting URL authentication", () =>
181 Effect.gen(function* () {
182 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
183 const instanceId = ProviderInstanceId.make("acpRegistry_antigravity");
184 const action = {
185 elicitationId: "login-1",
186 url: "https://accounts.example.com/login",
187 message: "Continue in your browser",
188 };
189 const actionSeen = yield* Deferred.make<void>();
190 const consumer = yield* coordinator
191 .watchUrlAuthAction(instanceId, (current) =>
192 current === null
193 ? Effect.void
194 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
195 )
196 .pipe(Effect.forkChild);
197 const request = yield* coordinator
198 .requestUrlAuthentication(instanceId, action)
199 .pipe(Effect.forkChild);
200 yield* Deferred.await(actionSeen);
201
202 expect(yield* coordinator.getUrlAuthAction(instanceId)).toEqual(
203 Option.some({
204 ...action,
205 createdAt: "1970-01-01T00:00:00.000Z",
206 expiresAt: "1970-01-01T00:10:00.000Z",
207 }),
208 );
209
210 expect(
211 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "stale" }),
212 ).toBe(false);
213 expect(
214 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-1" }),
215 ).toBe(true);
216 expect(yield* Fiber.join(request)).toBe(true);
217 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
218 yield* Fiber.interrupt(consumer);
219 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
220 );
221
222 it.effect("expires stale URL authentication actions without accepting them", () =>
223 Effect.gen(function* () {
224 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
225 const instanceId = ProviderInstanceId.make("acpRegistry_expired");
226 const actionSeen = yield* Deferred.make<void>();
227 const consumer = yield* coordinator
228 .watchUrlAuthAction(instanceId, (current) =>
229 current === null
230 ? Effect.void
231 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
232 )
233 .pipe(Effect.forkChild);
234 const request = yield* coordinator
235 .requestUrlAuthentication(instanceId, {
236 elicitationId: "expired-login",
237 url: "https://accounts.example.com/login",
238 message: "Continue in your browser",
239 })
240 .pipe(Effect.forkChild);
241 yield* Deferred.await(actionSeen);
242 yield* TestClock.adjust("10 minutes");
243
244 expect(yield* Fiber.join(request)).toBe(false);
245 expect(
246 yield* coordinator.acceptUrlAuthentication({
247 instanceId,
248 elicitationId: "expired-login",
249 }),
250 ).toBe(false);
251 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
252 yield* Fiber.interrupt(consumer);
253 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
254 );
255
256 it.effect("publishes a replacement URL action before retiring the previous request", () =>
257 Effect.gen(function* () {
258 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
259 const instanceId = ProviderInstanceId.make("acpRegistry_replacement");
260 const received: Array<string | null> = [];
261 const firstSeen = yield* Deferred.make<void>();
262 const replacementSeen = yield* Deferred.make<void>();
263 const consumer = yield* coordinator
264 .watchUrlAuthAction(instanceId, (current) =>
265 Effect.sync(() => {
266 received.push(current?.elicitationId ?? null);
267 }).pipe(
268 Effect.andThen(
269 current?.elicitationId === "login-1"
270 ? Deferred.succeed(firstSeen, undefined).pipe(Effect.asVoid)
271 : current?.elicitationId === "login-2"
272 ? Deferred.succeed(replacementSeen, undefined).pipe(Effect.asVoid)
273 : Effect.void,
274 ),
275 ),
276 )
277 .pipe(Effect.forkChild);
278 const first = yield* coordinator
279 .requestUrlAuthentication(instanceId, {
280 elicitationId: "login-1",
281 url: "https://accounts.example.com/first",
282 message: "Continue with the first login",
283 })
284 .pipe(Effect.forkChild);
285 yield* Deferred.await(firstSeen);
286 const replacement = yield* coordinator
287 .requestUrlAuthentication(instanceId, {
288 elicitationId: "login-2",
289 url: "https://accounts.example.com/replacement",
290 message: "Continue with the replacement login",
291 })
292 .pipe(Effect.forkChild);
293 yield* Deferred.await(replacementSeen);
294
295 expect(yield* Fiber.join(first)).toBe(false);
296 expect(
297 (yield* coordinator.getUrlAuthAction(instanceId)).pipe(Option.getOrThrow),
298 ).toMatchObject({ elicitationId: "login-2" });
299 expect(received.slice(0, 2)).toEqual(["login-1", "login-2"]);
300 expect(
301 yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-2" }),
302 ).toBe(true);
303 expect(yield* Fiber.join(replacement)).toBe(true);
304 yield* Fiber.interrupt(consumer);
305 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
306 );
307
308 it.effect("clears a published URL action when its request is interrupted", () =>
309 Effect.gen(function* () {
310 const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
311 const instanceId = ProviderInstanceId.make("acpRegistry_interrupted");
312 const actionSeen = yield* Deferred.make<void>();
313 const cleared = yield* Deferred.make<void>();
314 const consumer = yield* coordinator
315 .watchUrlAuthAction(instanceId, (current) =>
316 current === null
317 ? Deferred.succeed(cleared, undefined).pipe(Effect.asVoid)
318 : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid),
319 )
320 .pipe(Effect.forkChild);
321 const request = yield* coordinator
322 .requestUrlAuthentication(instanceId, {
323 elicitationId: "interrupted-login",
324 url: "https://accounts.example.com/interrupted",
325 message: "Continue with login",
326 })
327 .pipe(Effect.forkChild);
328 yield* Deferred.await(actionSeen);
329
330 yield* Fiber.interrupt(request);
331 yield* Deferred.await(cleared);
332 expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true);
333 yield* Fiber.interrupt(consumer);
334 }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
335 );
336});
packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts created+362
......@@ -0,0 +1,362 @@
1import {
2 type AcpRegistryAcceptUrlAuthInput,
3 type AcpRegistryUrlAuthAction,
4 type ProviderInstanceId,
5} from "@t3tools/contracts";
6import * as Context from "effect/Context";
7import * as Deferred from "effect/Deferred";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17
18import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts";
19
20interface AvailableCommandsUpdate {
21 readonly instanceId: ProviderInstanceId;
22 readonly commands: AcpRegistryAvailableCommands;
23}
24
25interface LiveConfigurationUpdate {
26 readonly instanceId: ProviderInstanceId;
27 readonly configuration: AcpRegistryLiveConfiguration;
28}
29
30interface UrlAuthActionUpdate {
31 readonly instanceId: ProviderInstanceId;
32 readonly action: AcpRegistryUrlAuthAction | null;
33}
34
35/** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */
36const URL_AUTH_ACTION_TTL = Duration.minutes(10);
37
38interface PendingUrlAuthAction {
39 readonly action: AcpRegistryUrlAuthAction;
40 readonly consent: Deferred.Deferred<boolean>;
41 readonly expiresAt: DateTime.Utc;
42}
43
44/** Gives a user-started ACP process priority over disposable discovery for the same agent. */
45export class AcpRegistryRuntimeCoordinator extends Context.Service<
46 AcpRegistryRuntimeCoordinator,
47 {
48 readonly withForegroundStartup: <A, E, R>(
49 agentId: string,
50 effect: Effect.Effect<A, E, R>,
51 ) => Effect.Effect<A, E, R>;
52 readonly runBackgroundProbe: <A, E, R>(
53 agentId: string,
54 effect: Effect.Effect<A, E, R>,
55 ) => Effect.Effect<Option.Option<A>, E, R>;
56 /** Serializes native-session imports and deletes across all client connections. */
57 readonly withSessionMutation: <A, E, R>(
58 effect: Effect.Effect<A, E, R>,
59 ) => Effect.Effect<A, E, R>;
60 readonly clearAvailableCommands: (instanceId: ProviderInstanceId) => Effect.Effect<void>;
61 readonly publishAvailableCommands: (
62 instanceId: ProviderInstanceId,
63 commands: AcpRegistryAvailableCommands,
64 ) => Effect.Effect<void>;
65 readonly getAvailableCommands: (
66 instanceId: ProviderInstanceId,
67 ) => Effect.Effect<Option.Option<AcpRegistryAvailableCommands>>;
68 readonly watchAvailableCommands: (
69 instanceId: ProviderInstanceId,
70 onUpdate: (commands: AcpRegistryAvailableCommands) => Effect.Effect<void>,
71 ) => Effect.Effect<void>;
72 readonly clearLiveConfiguration: (instanceId: ProviderInstanceId) => Effect.Effect<void>;
73 readonly publishLiveConfiguration: (
74 instanceId: ProviderInstanceId,
75 configuration: AcpRegistryLiveConfiguration,
76 ) => Effect.Effect<void>;
77 readonly getLiveConfiguration: (
78 instanceId: ProviderInstanceId,
79 ) => Effect.Effect<Option.Option<AcpRegistryLiveConfiguration>>;
80 readonly watchLiveConfiguration: (
81 instanceId: ProviderInstanceId,
82 onUpdate: (configuration: AcpRegistryLiveConfiguration) => Effect.Effect<void>,
83 ) => Effect.Effect<void>;
84 readonly requestUrlAuthentication: (
85 instanceId: ProviderInstanceId,
86 action: AcpRegistryUrlAuthAction,
87 ) => Effect.Effect<boolean>;
88 readonly acceptUrlAuthentication: (
89 input: AcpRegistryAcceptUrlAuthInput,
90 ) => Effect.Effect<boolean>;
91 readonly getUrlAuthAction: (
92 instanceId: ProviderInstanceId,
93 ) => Effect.Effect<Option.Option<AcpRegistryUrlAuthAction>>;
94 readonly watchUrlAuthAction: (
95 instanceId: ProviderInstanceId,
96 onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect<void>,
97 ) => Effect.Effect<void>;
98 }
99>()("@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator") {}
100
101const make = Effect.gen(function* () {
102 const foregroundStarts = yield* PubSub.unbounded<string>();
103 const activeForegroundCounts = yield* Ref.make(new Map<string, number>());
104 const availableCommandsUpdates = yield* PubSub.unbounded<AvailableCommandsUpdate>();
105 const availableCommandsByInstance = yield* Ref.make(
106 new Map<ProviderInstanceId, AcpRegistryAvailableCommands>(),
107 );
108 const liveConfigurationUpdates = yield* PubSub.unbounded<LiveConfigurationUpdate>();
109 const liveConfigurationByInstance = yield* Ref.make(
110 new Map<ProviderInstanceId, AcpRegistryLiveConfiguration>(),
111 );
112 const urlAuthActionUpdates = yield* PubSub.unbounded<UrlAuthActionUpdate>();
113 const pendingUrlAuthActions = yield* Ref.make(
114 new Map<ProviderInstanceId, PendingUrlAuthAction>(),
115 );
116 const urlAuthActionPermit = yield* Semaphore.make(1);
117 const sessionMutationPermit = yield* Semaphore.make(1);
118
119 const withForegroundStartup: AcpRegistryRuntimeCoordinator["Service"]["withForegroundStartup"] = (
120 agentId,
121 effect,
122 ) =>
123 Effect.acquireUseRelease(
124 Ref.update(activeForegroundCounts, (current) => {
125 const next = new Map(current);
126 next.set(agentId, (next.get(agentId) ?? 0) + 1);
127 return next;
128 }).pipe(Effect.andThen(PubSub.publish(foregroundStarts, agentId))),
129 () => effect,
130 () =>
131 Ref.update(activeForegroundCounts, (current) => {
132 const next = new Map(current);
133 const remaining = (next.get(agentId) ?? 1) - 1;
134 if (remaining <= 0) next.delete(agentId);
135 else next.set(agentId, remaining);
136 return next;
137 }),
138 );
139
140 const runBackgroundProbe: AcpRegistryRuntimeCoordinator["Service"]["runBackgroundProbe"] = (
141 agentId,
142 effect,
143 ) =>
144 Effect.scoped(
145 Effect.gen(function* () {
146 // Subscribe before checking state so a foreground start cannot land
147 // in the gap between the check and the interruptible probe.
148 const subscription = yield* PubSub.subscribe(foregroundStarts);
149 if ((yield* Ref.get(activeForegroundCounts)).has(agentId)) {
150 return Option.none();
151 }
152 const foregroundStarted = Stream.fromSubscription(subscription).pipe(
153 Stream.filter((candidate) => candidate === agentId),
154 Stream.runHead,
155 Effect.as(Option.none()),
156 );
157 return yield* Effect.raceFirst(effect.pipe(Effect.map(Option.some)), foregroundStarted);
158 }),
159 );
160
161 const withSessionMutation: AcpRegistryRuntimeCoordinator["Service"]["withSessionMutation"] = (
162 effect,
163 ) => sessionMutationPermit.withPermit(effect);
164
165 const clearAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["clearAvailableCommands"] =
166 (instanceId) =>
167 Ref.update(availableCommandsByInstance, (current) => {
168 const next = new Map(current);
169 next.delete(instanceId);
170 return next;
171 });
172
173 const publishAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["publishAvailableCommands"] =
174 (instanceId, commands) =>
175 Ref.update(availableCommandsByInstance, (current) => {
176 const next = new Map(current);
177 next.set(instanceId, commands);
178 return next;
179 }).pipe(
180 Effect.andThen(
181 PubSub.publish(availableCommandsUpdates, {
182 instanceId,
183 commands,
184 }),
185 ),
186 Effect.asVoid,
187 );
188
189 const getAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["getAvailableCommands"] =
190 Effect.fn("AcpRegistryRuntimeCoordinator.getAvailableCommands")(function* (instanceId) {
191 return Option.fromNullishOr((yield* Ref.get(availableCommandsByInstance)).get(instanceId));
192 });
193
194 const watchAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["watchAvailableCommands"] =
195 (instanceId, onUpdate) =>
196 Effect.scoped(
197 Effect.gen(function* () {
198 // Subscribe before reading the current value so a publication in
199 // between is either observed from the snapshot, the queue, or both.
200 const subscription = yield* PubSub.subscribe(availableCommandsUpdates);
201 const current = yield* getAvailableCommands(instanceId);
202 if (Option.isSome(current)) {
203 yield* onUpdate(current.value);
204 }
205 yield* Stream.fromSubscription(subscription).pipe(
206 Stream.filter((update) => update.instanceId === instanceId),
207 Stream.runForEach((update) => onUpdate(update.commands)),
208 );
209 }),
210 );
211
212 const clearLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["clearLiveConfiguration"] =
213 (instanceId) =>
214 Ref.update(liveConfigurationByInstance, (current) => {
215 const next = new Map(current);
216 next.delete(instanceId);
217 return next;
218 });
219
220 const publishLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["publishLiveConfiguration"] =
221 (instanceId, configuration) =>
222 Ref.update(liveConfigurationByInstance, (current) =>
223 new Map(current).set(instanceId, configuration),
224 ).pipe(
225 Effect.andThen(
226 PubSub.publish(liveConfigurationUpdates, {
227 instanceId,
228 configuration,
229 }),
230 ),
231 Effect.asVoid,
232 );
233
234 const getLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["getLiveConfiguration"] =
235 Effect.fn("AcpRegistryRuntimeCoordinator.getLiveConfiguration")(function* (instanceId) {
236 return Option.fromNullishOr((yield* Ref.get(liveConfigurationByInstance)).get(instanceId));
237 });
238
239 const watchLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["watchLiveConfiguration"] =
240 (instanceId, onUpdate) =>
241 Effect.scoped(
242 Effect.gen(function* () {
243 const subscription = yield* PubSub.subscribe(liveConfigurationUpdates);
244 const current = yield* getLiveConfiguration(instanceId);
245 if (Option.isSome(current)) {
246 yield* onUpdate(current.value);
247 }
248 yield* Stream.fromSubscription(subscription).pipe(
249 Stream.filter((update) => update.instanceId === instanceId),
250 Stream.runForEach((update) => onUpdate(update.configuration)),
251 );
252 }),
253 );
254
255 const publishUrlAuthAction = (
256 instanceId: ProviderInstanceId,
257 action: AcpRegistryUrlAuthAction | null,
258 ) => PubSub.publish(urlAuthActionUpdates, { instanceId, action }).pipe(Effect.asVoid);
259
260 const requestUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["requestUrlAuthentication"] =
261 Effect.fn("AcpRegistryRuntimeCoordinator.requestUrlAuthentication")(
262 function* (instanceId, action) {
263 const consent = yield* Deferred.make<boolean>();
264 const createdAt = yield* DateTime.now;
265 const expiresAt = DateTime.addDuration(createdAt, URL_AUTH_ACTION_TTL);
266 const publishedAction = {
267 ...action,
268 createdAt: DateTime.formatIso(createdAt),
269 expiresAt: DateTime.formatIso(expiresAt),
270 } satisfies AcpRegistryUrlAuthAction;
271 const cleanup = Ref.modify(pendingUrlAuthActions, (current) => {
272 if (current.get(instanceId)?.consent !== consent) {
273 return [false, current] as const;
274 }
275 const next = new Map(current);
276 next.delete(instanceId);
277 return [true, next] as const;
278 }).pipe(
279 Effect.flatMap((removed) =>
280 removed ? publishUrlAuthAction(instanceId, null) : Effect.void,
281 ),
282 );
283 return yield* Effect.gen(function* () {
284 yield* urlAuthActionPermit.withPermits(1)(
285 Effect.gen(function* () {
286 const previous = yield* Ref.modify(pendingUrlAuthActions, (current) => {
287 const next = new Map(current);
288 const existing = next.get(instanceId);
289 next.set(instanceId, { action: publishedAction, consent, expiresAt });
290 return [existing, next] as const;
291 });
292 if (previous !== undefined) {
293 yield* Deferred.succeed(previous.consent, false).pipe(Effect.ignore);
294 }
295 yield* publishUrlAuthAction(instanceId, publishedAction);
296 }),
297 );
298 return yield* Deferred.await(consent).pipe(
299 Effect.timeoutOrElse({
300 duration: URL_AUTH_ACTION_TTL,
301 orElse: () => Effect.succeed(false),
302 }),
303 );
304 }).pipe(Effect.ensuring(cleanup));
305 },
306 );
307
308 const acceptUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["acceptUrlAuthentication"] =
309 Effect.fn("AcpRegistryRuntimeCoordinator.acceptUrlAuthentication")(function* (input) {
310 const pending = (yield* Ref.get(pendingUrlAuthActions)).get(input.instanceId);
311 if (pending?.action.elicitationId !== input.elicitationId) return false;
312 if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, pending.expiresAt)) {
313 yield* Deferred.succeed(pending.consent, false).pipe(Effect.ignore);
314 return false;
315 }
316 return yield* Deferred.succeed(pending.consent, true);
317 });
318
319 const getUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["getUrlAuthAction"] = Effect.fn(
320 "AcpRegistryRuntimeCoordinator.getUrlAuthAction",
321 )(function* (instanceId) {
322 return Option.fromNullishOr((yield* Ref.get(pendingUrlAuthActions)).get(instanceId)?.action);
323 });
324
325 const watchUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["watchUrlAuthAction"] = (
326 instanceId,
327 onUpdate,
328 ) =>
329 Effect.scoped(
330 Effect.gen(function* () {
331 const subscription = yield* PubSub.subscribe(urlAuthActionUpdates);
332 const current = yield* getUrlAuthAction(instanceId);
333 if (Option.isSome(current)) {
334 yield* onUpdate(current.value);
335 }
336 yield* Stream.fromSubscription(subscription).pipe(
337 Stream.filter((update) => update.instanceId === instanceId),
338 Stream.runForEach((update) => onUpdate(update.action)),
339 );
340 }),
341 );
342
343 return AcpRegistryRuntimeCoordinator.of({
344 withForegroundStartup,
345 runBackgroundProbe,
346 withSessionMutation,
347 clearAvailableCommands,
348 publishAvailableCommands,
349 getAvailableCommands,
350 watchAvailableCommands,
351 clearLiveConfiguration,
352 publishLiveConfiguration,
353 getLiveConfiguration,
354 watchLiveConfiguration,
355 requestUrlAuthentication,
356 acceptUrlAuthentication,
357 getUrlAuthAction,
358 watchUrlAuthAction,
359 });
360});
361
362export const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make);
packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts created+1740
......@@ -0,0 +1,1740 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 DEFAULT_SERVER_SETTINGS,
5 ProviderDriverKind,
6 ProviderInstanceId,
7} from "@t3tools/contracts";
8import { AcpRegistrySettings } from "../settings.ts";
9import {
10 HostProcessArchitecture,
11 HostProcessEnvironment,
12 HostProcessPlatform,
13} from "@t3tools/shared/hostProcess";
14import { SpawnExecutableResolution } from "@t3tools/shared/shell";
15import * as Deferred from "effect/Deferred";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Path from "effect/Path";
19import * as Fiber from "effect/Fiber";
20import * as Layer from "effect/Layer";
21import * as Schema from "effect/Schema";
22import { HttpClient, HttpClientResponse } from "effect/http";
23import * as TestClock from "effect/testing/TestClock";
24import { ChildProcess, ChildProcessSpawner } from "effect/process";
25
26import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
27import { layerTestProviderHost, TestProviderHostSettings } from "@t3tools/provider-testing/host";
28
29const registryUrl = "https://registry.test/registry.json";
30const archiveUrl = "https://registry.test/example-agent.bin";
31const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings);
32const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
33
34function makeAgent(
35 distribution: AcpRegistrySupport.AcpRegistryAgent["distribution"],
36): AcpRegistrySupport.AcpRegistryAgent {
37 return {
38 id: "example-agent",
39 name: "Example Agent",
40 version: "1.2.3",
41 description: "ACP Registry test agent",
42 distribution,
43 };
44}
45
46function makeRegistry(agent: AcpRegistrySupport.AcpRegistryAgent): string {
47 return JSON.stringify({ version: "1.0.0", agents: [agent] });
48}
49
50function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings {
51 return decodeAcpRegistrySettings({
52 agentId: "example-agent",
53 ...input,
54 });
55}
56
57function layerResolver(
58 execute: Parameters<typeof HttpClient.make>[0],
59 environment: NodeJS.ProcessEnv = process.env,
60) {
61 return Layer.mergeAll(
62 NodeServices.layer,
63 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
64 Layer.succeed(HostProcessPlatform, "linux"),
65 Layer.succeed(HostProcessArchitecture, "x64"),
66 Layer.succeed(HostProcessEnvironment, environment),
67 Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)),
68 );
69}
70
71const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* (
72 rootDirectory: string,
73) {
74 const fileSystem = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 const toolchainBin = path.join(rootDirectory, "fake-node", "bin");
77 const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm");
78 const globalBin = path.join(globalPrefix, "bin");
79 const executablePath = path.join(globalBin, "example-agent");
80 const npmPath = path.join(toolchainBin, "npm");
81 const logPath = path.join(rootDirectory, "npm.log");
82 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
83 yield* fileSystem.makeDirectory(globalPrefix, { recursive: true });
84 yield* fileSystem.writeFileString(logPath, "");
85 yield* fileSystem.copyFile(
86 yield* path.fromFileUrl(
87 new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url),
88 ),
89 npmPath,
90 );
91 yield* fileSystem.chmod(npmPath, 0o755);
92 return {
93 environment: {
94 ...process.env,
95 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
96 FAKE_NPM_LOG: logPath,
97 FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"),
98 FAKE_NPM_MANIFEST: encodeUnknownJson({
99 name: "@example/acp",
100 version: "1.2.3",
101 bin: { "example-agent": "dist/cli.js" },
102 }),
103 } satisfies NodeJS.ProcessEnv,
104 executablePath,
105 globalBin,
106 logPath,
107 npmPath,
108 };
109});
110
111const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* (
112 rootDirectory: string,
113) {
114 const fileSystem = yield* FileSystem.FileSystem;
115 const path = yield* Path.Path;
116 const toolchainBin = path.join(rootDirectory, "fake-uv", "bin");
117 const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin");
118 const executablePath = path.join(globalBin, "fast-agent");
119 const uvPath = path.join(toolchainBin, "uv");
120 const logPath = path.join(rootDirectory, "uv.log");
121 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
122 yield* fileSystem.writeFileString(logPath, "");
123 yield* fileSystem.copyFile(
124 yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)),
125 uvPath,
126 );
127 yield* fileSystem.chmod(uvPath, 0o755);
128 return {
129 environment: {
130 ...process.env,
131 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
132 FAKE_UV_LOG: logPath,
133 FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"),
134 FAKE_UV_EXECUTABLE: executablePath,
135 } satisfies NodeJS.ProcessEnv,
136 executablePath,
137 globalBin,
138 logPath,
139 uvPath,
140 };
141});
142
143describe("AcpRegistrySupport", () => {
144 it("preserves the registry failure when translating it for clients", () => {
145 const cause = new Error("registry unavailable");
146 const failure = new AcpRegistrySupport.AcpRegistryError({
147 reason: "registry_unavailable",
148 detail: "Could not load the ACP Registry.",
149 cause,
150 });
151
152 expect(AcpRegistrySupport.toAcpRegistryOperationError(failure)).toMatchObject({
153 reason: "registry_unavailable",
154 message: "Could not load the ACP Registry.",
155 cause: failure,
156 });
157 });
158
159 it("maps supported Node platforms to ACP Registry target keys", () => {
160 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe(
161 "darwin-aarch64",
162 );
163 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "x64")).toBe(
164 "linux-x86_64",
165 );
166 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe(
167 "windows-aarch64",
168 );
169 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined();
170 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined();
171 });
172
173 it("selects the preferred compatible distribution", () => {
174 const agent = makeAgent({
175 binary: {
176 "linux-x86_64": {
177 archive: archiveUrl,
178 cmd: "./bin/example-agent",
179 args: ["acp"],
180 },
181 },
182 npx: {
183 package: "@example/acp@1.2.3",
184 args: ["--stdio"],
185 },
186 });
187
188 expect(
189 AcpRegistrySupport.resolveAcpRegistryDistribution({
190 agent,
191 preference: "auto",
192 platformTarget: "linux-x86_64",
193 }),
194 ).toMatchObject({ kind: "binary", args: ["acp"] });
195 expect(
196 AcpRegistrySupport.resolveAcpRegistryDistribution({
197 agent,
198 preference: "npx",
199 platformTarget: "linux-x86_64",
200 }),
201 ).toEqual({
202 kind: "npx",
203 packageName: "@example/acp@1.2.3",
204 args: ["--stdio"],
205 env: {},
206 });
207 expect(
208 AcpRegistrySupport.resolveAcpRegistryDistribution({
209 agent,
210 preference: "binary",
211 platformTarget: "darwin-aarch64",
212 }),
213 ).toBeUndefined();
214 });
215
216 it.effect("resolves a local command on the selected environment without the registry", () => {
217 const requests: Array<string> = [];
218 return Effect.gen(function* () {
219 const fileSystem = yield* FileSystem.FileSystem;
220 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" });
221 const commandPath = `${cacheDir}/dsh`;
222 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
223 yield* fileSystem.chmod(commandPath, 0o755);
224 const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" };
225 const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" };
226 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
227 cacheDir,
228 toolsDir: `${cacheDir}/tools`,
229 registryUrl,
230 }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment));
231 const localSettings = decodeAcpRegistrySettings({
232 source: "local",
233 commandPath: "dsh",
234 commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
235 });
236
237 expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" });
238 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
239 status: "ready",
240 agentId: "dsh",
241 version: null,
242 distribution: "local",
243 });
244 const resolved = yield* resolver.resolve(localSettings, "/workspace", environment);
245 expect(resolved).toEqual({
246 distribution: "local",
247 spawn: {
248 command: commandPath,
249 args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
250 cwd: "/workspace",
251 env: environment,
252 shell: false,
253 },
254 });
255 expect(requests).toEqual([]);
256 expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false);
257 }).pipe(
258 Effect.scoped,
259 Effect.provide(
260 layerResolver((request) => {
261 requests.push(request.url);
262 return Effect.die("unexpected registry request for a local provider");
263 }),
264 ),
265 );
266 });
267
268 it.effect("resolves a local executable path and inherits the host environment", () =>
269 Effect.gen(function* () {
270 const fileSystem = yield* FileSystem.FileSystem;
271 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" });
272 const commandPath = `${cacheDir}/dsh wrapper`;
273 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
274 yield* fileSystem.chmod(commandPath, 0o755);
275 const environment = { PATH: "/unused", INHERITED: "host" };
276 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
277 cacheDir,
278 toolsDir: `${cacheDir}/tools`,
279 registryUrl,
280 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
281 const resolved = yield* resolver.resolve(
282 decodeAcpRegistrySettings({ source: "local", commandPath }),
283 "/workspace",
284 );
285
286 expect(resolved.spawn).toEqual({
287 command: commandPath,
288 args: [],
289 cwd: "/workspace",
290 env: environment,
291 shell: false,
292 });
293 expect(resolved.agent).toBeUndefined();
294
295 for (const extension of [".cmd", ".BAT"]) {
296 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
297 cacheDir,
298 toolsDir: `${cacheDir}/tools`,
299 registryUrl,
300 }).pipe(
301 Effect.provideService(HostProcessPlatform, "win32"),
302 Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`),
303 );
304 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" });
305 for (const failure of [
306 yield* windowsResolver.inspect(localSettings).pipe(Effect.flip),
307 yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip),
308 ]) {
309 expect(failure).toMatchObject({
310 reason: "runner_unavailable",
311 detail: expect.stringContaining("node.exe"),
312 });
313 }
314 }
315 }).pipe(
316 Effect.scoped,
317 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
318 ),
319 );
320
321 it.effect("reports missing or non-executable local commands without registry fallback", () =>
322 Effect.gen(function* () {
323 const fileSystem = yield* FileSystem.FileSystem;
324 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
325 prefix: "t3-acp-local-missing-",
326 });
327 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
328 cacheDir,
329 toolsDir: `${cacheDir}/tools`,
330 registryUrl,
331 });
332 const environment = { PATH: cacheDir };
333 const unconfigured = decodeAcpRegistrySettings({ source: "local" });
334 expect(yield* resolver.inspect(unconfigured, environment)).toEqual({
335 status: "unconfigured",
336 });
337 expect(
338 yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip),
339 ).toMatchObject({ reason: "agent_not_configured" });
340
341 const nonExecutable = `${cacheDir}/non-executable`;
342 yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n");
343 yield* fileSystem.chmod(nonExecutable, 0o644);
344 for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) {
345 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath });
346 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
347 status: "missing_runner",
348 version: null,
349 distribution: "local",
350 });
351 expect(
352 yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip),
353 ).toMatchObject({
354 reason: "runner_unavailable",
355 detail: "Local ACP executable is not available on this environment's PATH.",
356 });
357 }
358 }).pipe(
359 Effect.scoped,
360 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
361 ),
362 );
363
364 it.effect("resolves command overrides while preserving registry args and environment", () => {
365 const agent = makeAgent({
366 binary: {
367 "linux-x86_64": {
368 archive: archiveUrl,
369 cmd: "./bin/example-agent",
370 args: ["acp", "--stdio"],
371 env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" },
372 },
373 },
374 });
375 const requests: Array<string> = [];
376 return Effect.gen(function* () {
377 const fileSystem = yield* FileSystem.FileSystem;
378 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
379 prefix: "t3-acp-registry-override-",
380 });
381 const commandPath = `${cacheDir}/example-agent`;
382 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
383 yield* fileSystem.chmod(commandPath, 0o755);
384 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
385 cacheDir,
386 toolsDir: `${cacheDir}/tools`,
387 registryUrl,
388 });
389 const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", {
390 HOST_VALUE: "host",
391 OVERRIDE_ME: "host",
392 });
393
394 expect(resolved.distribution).toBe("binary");
395 expect(resolved.spawn).toEqual({
396 command: commandPath,
397 args: ["acp", "--stdio"],
398 cwd: "/workspace",
399 env: {
400 HOST_VALUE: "host",
401 OVERRIDE_ME: "registry",
402 REGISTRY_VALUE: "registry",
403 },
404 });
405 expect(requests).toEqual([registryUrl]);
406 }).pipe(
407 Effect.scoped,
408 Effect.provide(
409 layerResolver((request) => {
410 requests.push(request.url);
411 return Effect.succeed(
412 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
413 );
414 }),
415 ),
416 );
417 });
418
419 it.effect("installs into T3 home a package and launches its exposed command", () => {
420 const agent = makeAgent({
421 npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] },
422 });
423 return Effect.gen(function* () {
424 const fileSystem = yield* FileSystem.FileSystem;
425 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
426 prefix: "t3-acp-registry-global-package-",
427 });
428 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
429 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
430 cacheDir,
431 toolsDir: `${cacheDir}/tools`,
432 registryUrl,
433 }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment));
434
435 const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
436 const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
437
438 expect(first.spawn).toMatchObject({
439 command: toolchain.executablePath,
440 args: ["--stdio"],
441 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
442 });
443 expect(second.spawn.command).toBe(toolchain.executablePath);
444 const npmCommands = yield* fileSystem.readFileString(toolchain.logPath);
445 expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1);
446 expect(npmCommands).toContain("install --global @example/acp@V1.2.3");
447 }).pipe(
448 Effect.scoped,
449 Effect.provide(
450 layerResolver((request) =>
451 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
452 ),
453 ),
454 );
455 });
456
457 it.effect("uses its managed prefix despite a system-owned npm prefix", () => {
458 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
459 return Effect.gen(function* () {
460 const fileSystem = yield* FileSystem.FileSystem;
461 const path = yield* Path.Path;
462 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
463 prefix: "t3-acp-registry-user-global-package-",
464 });
465 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
466 const systemPrefix = path.join(cacheDir, "system-global");
467 yield* fileSystem.makeDirectory(systemPrefix);
468 yield* fileSystem.chmod(systemPrefix, 0o555);
469 const userHome = path.join(cacheDir, "home");
470 const environment = {
471 ...toolchain.environment,
472 HOME: userHome,
473 FAKE_NPM_PREFIX: systemPrefix,
474 };
475 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
476 cacheDir,
477 toolsDir: `${cacheDir}/tools`,
478 registryUrl,
479 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
480
481 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
482 const userGlobalBin = toolchain.globalBin;
483 expect(resolved.spawn).toMatchObject({
484 command: path.join(userGlobalBin, "example-agent"),
485 env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) },
486 });
487 }).pipe(
488 Effect.scoped,
489 Effect.provide(
490 layerResolver((request) =>
491 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
492 ),
493 ),
494 );
495 });
496
497 it.effect("installs into T3 home a uv tool and launches its exposed command", () => {
498 const agent = makeAgent({
499 uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] },
500 });
501 return Effect.gen(function* () {
502 const fileSystem = yield* FileSystem.FileSystem;
503 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
504 prefix: "t3-acp-registry-global-uv-tool-",
505 });
506 const toolchain = yield* makeFakeUvToolchain(cacheDir);
507 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
508 cacheDir,
509 toolsDir: `${cacheDir}/tools`,
510 registryUrl,
511 });
512
513 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
514
515 expect(resolved.spawn).toMatchObject({
516 command: toolchain.executablePath,
517 args: ["--acp"],
518 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
519 });
520 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
521 "tool install --force fast-agent-acp==V0.10.1",
522 );
523 }).pipe(
524 Effect.scoped,
525 Effect.provide(
526 layerResolver((request) =>
527 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
528 ),
529 ),
530 );
531 });
532
533 it.effect("honors an exact Windows PATH override when launching a global package", () => {
534 const agent = makeAgent({
535 uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] },
536 });
537 return Effect.gen(function* () {
538 const fileSystem = yield* FileSystem.FileSystem;
539 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
540 prefix: "t3-acp-registry-windows-package-path-",
541 });
542 const toolchain = yield* makeFakeUvToolchain(cacheDir);
543 const linuxResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
544 cacheDir,
545 toolsDir: `${cacheDir}/tools`,
546 registryUrl,
547 });
548 yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment);
549
550 const windowsEnvironment = {
551 ...toolchain.environment,
552 Path: "C:\\host\\bin",
553 PATH: "C:\\provider\\bin",
554 };
555 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
556 cacheDir,
557 toolsDir: `${cacheDir}/tools`,
558 registryUrl,
559 }).pipe(
560 Effect.provideService(HostProcessPlatform, "win32"),
561 Effect.provideService(SpawnExecutableResolution, (command) => {
562 if (command === "uv") return toolchain.uvPath;
563 if (command === "fast-agent") return toolchain.executablePath;
564 return undefined;
565 }),
566 );
567 const resolved = yield* windowsResolver.resolve(
568 settings(),
569 "C:\\workspace",
570 windowsEnvironment,
571 );
572
573 expect(resolved.spawn).toMatchObject({
574 env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` },
575 });
576 }).pipe(
577 Effect.scoped,
578 Effect.provide(
579 layerResolver((request) =>
580 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
581 ),
582 ),
583 );
584 });
585
586 it.effect("installs and reuses a registry binary in the managed cache", () => {
587 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
588 const agent = makeAgent({
589 binary: {
590 "linux-x86_64": {
591 archive: archiveUrl,
592 cmd: "./bin/example-agent",
593 args: ["acp"],
594 // SHA-256 of binaryBytes.
595 sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7",
596 },
597 },
598 });
599 const requests: Array<string> = [];
600 return Effect.gen(function* () {
601 const fileSystem = yield* FileSystem.FileSystem;
602 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
603 prefix: "t3-acp-registry-install-",
604 });
605 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
606 cacheDir,
607 toolsDir: `${cacheDir}/tools`,
608 registryUrl,
609 });
610 const first = yield* resolver.resolve(settings(), "/workspace");
611 const second = yield* resolver.resolve(settings(), "/workspace");
612
613 expect(first.spawn.command).toBe(second.spawn.command);
614 expect(first.spawn.command).toContain(
615 "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent",
616 );
617 expect(yield* fileSystem.readFileString(first.spawn.command)).toBe(
618 "#!/bin/sh\necho example\n",
619 );
620 expect(requests).toEqual([registryUrl, archiveUrl]);
621 }).pipe(
622 Effect.scoped,
623 Effect.provide(
624 layerResolver((request) => {
625 requests.push(request.url);
626 const response =
627 request.url === registryUrl
628 ? new Response(makeRegistry(agent))
629 : new Response(
630 new ReadableStream<Uint8Array>({
631 start(controller) {
632 controller.enqueue(binaryBytes.slice(0, 8));
633 controller.enqueue(binaryBytes.slice(8));
634 controller.close();
635 },
636 }),
637 );
638 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
639 }),
640 ),
641 );
642 });
643
644 it.effect(
645 "prepares the registry version despite an older PATH binary and permits explicit overrides",
646 () => {
647 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n");
648 const agent = makeAgent({
649 binary: {
650 "linux-x86_64": {
651 archive: archiveUrl,
652 cmd: "./bin/example-agent",
653 args: ["acp"],
654 // SHA-256 of binaryBytes.
655 sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31",
656 },
657 },
658 });
659 const requests: Array<string> = [];
660 return Effect.gen(function* () {
661 const fileSystem = yield* FileSystem.FileSystem;
662 const path = yield* Path.Path;
663 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
664 prefix: "t3-acp-registry-system-",
665 });
666 const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({
667 prefix: "t3-acp-registry-system-bin-",
668 });
669 const systemBinary = path.join(systemBinDir, "example-agent");
670 yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n");
671 yield* fileSystem.chmod(systemBinary, 0o755);
672 const environment = { PATH: systemBinDir };
673
674 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
675 cacheDir,
676 toolsDir: `${cacheDir}/tools`,
677 registryUrl,
678 }).pipe(Effect.provideService(HostProcessEnvironment, environment));
679 yield* resolver.search({ query: "example" });
680 expect(yield* resolver.inspect(settings(), environment)).toMatchObject({
681 status: "unprepared",
682 });
683
684 const overridden = settings({ commandPath: systemBinary });
685 expect(yield* resolver.inspect(overridden, environment)).toMatchObject({
686 status: "ready",
687 version: null,
688 });
689 const custom = yield* resolver.resolve(overridden, "/workspace", environment);
690 expect(custom.spawn.command).toBe(systemBinary);
691 expect(custom.spawn.args).toEqual(["acp"]);
692 expect(requests).toEqual([registryUrl]);
693
694 yield* resolver.prepare({ agentId: "example-agent" });
695 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
696 expect(resolved.spawn.command).not.toBe(systemBinary);
697 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
698 "#!/bin/sh\necho managed\n",
699 );
700 expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n");
701
702 const inspection = yield* resolver.inspect(settings(), environment);
703 expect(inspection).toMatchObject({
704 status: "ready",
705 distribution: "binary",
706 version: "1.2.3",
707 });
708
709 expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]);
710 }).pipe(
711 Effect.scoped,
712 Effect.provide(
713 layerResolver((request) => {
714 requests.push(request.url);
715 return Effect.succeed(
716 HttpClientResponse.fromWeb(
717 request,
718 request.url === registryUrl
719 ? new Response(makeRegistry(agent))
720 : new Response(binaryBytes.buffer as ArrayBuffer),
721 ),
722 );
723 }),
724 ),
725 );
726 },
727 );
728
729 it.effect("installs a tar archive containing a root directory entry", () => {
730 const downloadUrl = "https://registry.test/agent.tar.gz";
731 const agent = makeAgent({
732 binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } },
733 });
734 return Effect.gen(function* () {
735 const fileSystem = yield* FileSystem.FileSystem;
736 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" });
737 const source = `${cacheDir}/source`;
738 yield* fileSystem.makeDirectory(source);
739 yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n");
740 const archivePath = `${cacheDir}/agent.tar.gz`;
741 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
742 const tar = yield* spawner.spawn(
743 ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]),
744 );
745 expect(yield* tar.exitCode).toBe(0);
746 const archive = yield* fileSystem.readFile(archivePath);
747 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
748 cacheDir,
749 toolsDir: `${cacheDir}/tools`,
750 registryUrl,
751 }).pipe(
752 Effect.provideService(
753 HttpClient.HttpClient,
754 HttpClient.make((request) =>
755 Effect.succeed(
756 HttpClientResponse.fromWeb(
757 request,
758 request.url === registryUrl
759 ? new Response(makeRegistry(agent))
760 : new Response(archive),
761 ),
762 ),
763 ),
764 ),
765 );
766 yield* resolver.prepare({ agentId: agent.id });
767 const resolved = yield* resolver.resolve(settings(), "/workspace", {});
768 expect(resolved.spawn.command).toBe(
769 yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`),
770 );
771 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
772 "#!/bin/sh\necho managed\n",
773 );
774 }).pipe(
775 Effect.scoped,
776 Effect.provide(
777 Layer.mergeAll(
778 NodeServices.layer,
779 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
780 ),
781 ),
782 Effect.provideService(HostProcessPlatform, "linux"),
783 Effect.provideService(HostProcessArchitecture, "x64"),
784 );
785 });
786
787 it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => {
788 const exact = {
789 ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }),
790 id: "codex-acp",
791 name: "Codex",
792 authors: ["OpenAI", "Zed Industries"],
793 license: "Apache-2.0",
794 website: "https://example.test/codex",
795 repository: "https://example.test/codex/source",
796 icon: "https://example.test/codex.svg",
797 } satisfies AcpRegistrySupport.AcpRegistryAgent;
798 const descriptionMatch = {
799 ...makeAgent({ npx: { package: "other-agent@1.2.3" } }),
800 id: "other-agent",
801 name: "Other Agent",
802 description: "An adapter for Codex workflows",
803 } satisfies AcpRegistrySupport.AcpRegistryAgent;
804 const incompatible = {
805 ...makeAgent({
806 binary: {
807 "darwin-aarch64": { archive: archiveUrl, cmd: "agent" },
808 },
809 }),
810 id: "darwin-only",
811 name: "Codex Darwin",
812 } satisfies AcpRegistrySupport.AcpRegistryAgent;
813
814 return Effect.gen(function* () {
815 const fileSystem = yield* FileSystem.FileSystem;
816 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
817 prefix: "t3-acp-registry-search-",
818 });
819 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
820 cacheDir,
821 toolsDir: `${cacheDir}/tools`,
822 registryUrl,
823 });
824 const result = yield* resolver.search({ query: "codex" });
825
826 expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]);
827 expect(result.agents[0]).toMatchObject({
828 authors: ["OpenAI", "Zed Industries"],
829 distribution: "npx",
830 integrity: "registry",
831 license: "Apache-2.0",
832 });
833 }).pipe(
834 Effect.scoped,
835 Effect.provide(
836 layerResolver((request) =>
837 Effect.succeed(
838 HttpClientResponse.fromWeb(
839 request,
840 new Response(
841 JSON.stringify({
842 version: "1.0.0",
843 agents: [descriptionMatch, incompatible, exact],
844 }),
845 ),
846 ),
847 ),
848 ),
849 ),
850 );
851 });
852
853 it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => {
854 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
855 let requests = 0;
856 return Effect.gen(function* () {
857 const fileSystem = yield* FileSystem.FileSystem;
858 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
859 prefix: "t3-acp-registry-refresh-",
860 });
861 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
862 cacheDir,
863 toolsDir: `${cacheDir}/tools`,
864 registryUrl,
865 });
866 yield* Effect.all(
867 [resolver.search({ query: "example" }), resolver.search({ query: "example" })],
868 { concurrency: "unbounded" },
869 );
870 expect(requests).toBe(1);
871
872 yield* resolver.search({ query: "example" });
873 expect(requests).toBe(2);
874 }).pipe(
875 Effect.scoped,
876 Effect.provide(
877 layerResolver((request) => {
878 requests += 1;
879 return Effect.yieldNow.pipe(
880 Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
881 );
882 }),
883 ),
884 );
885 });
886
887 it.effect("filters runner recipes that the environment cannot prepare", () => {
888 const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
889 const binaryAgent = {
890 ...makeAgent({
891 binary: {
892 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
893 },
894 }),
895 id: "binary-agent",
896 name: "Binary Agent",
897 } satisfies AcpRegistrySupport.AcpRegistryAgent;
898 return Effect.gen(function* () {
899 const fileSystem = yield* FileSystem.FileSystem;
900 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
901 prefix: "t3-acp-registry-runner-filter-",
902 });
903 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
904 cacheDir,
905 toolsDir: `${cacheDir}/tools`,
906 registryUrl,
907 });
908 const result = yield* resolver.search({ query: "" });
909
910 expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]);
911 }).pipe(
912 Effect.scoped,
913 Effect.provide(
914 layerResolver(
915 (request) =>
916 Effect.succeed(
917 HttpClientResponse.fromWeb(
918 request,
919 new Response(
920 JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }),
921 ),
922 ),
923 ),
924 { PATH: "" },
925 ),
926 ),
927 );
928 });
929
930 it.effect("discards registry agents with blank names or unsafe versions", () => {
931 const distribution = {
932 binary: {
933 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
934 },
935 } satisfies AcpRegistrySupport.AcpRegistryAgent["distribution"];
936 const valid = makeAgent(distribution);
937 const blankName = { ...valid, id: "blank-name", name: " " };
938 const blankVersion = { ...valid, id: "blank-version", version: "\t" };
939 const parentVersion = { ...valid, id: "parent-version", version: ".." };
940 return Effect.gen(function* () {
941 const fileSystem = yield* FileSystem.FileSystem;
942 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
943 prefix: "t3-acp-registry-blank-fields-",
944 });
945 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
946 cacheDir,
947 toolsDir: `${cacheDir}/tools`,
948 registryUrl,
949 });
950 const result = yield* resolver.search({ query: "" });
951
952 expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]);
953 }).pipe(
954 Effect.scoped,
955 Effect.provide(
956 layerResolver((request) =>
957 Effect.succeed(
958 HttpClientResponse.fromWeb(
959 request,
960 new Response(
961 JSON.stringify({
962 version: "1.0.0",
963 agents: [blankName, blankVersion, parentVersion, valid],
964 }),
965 ),
966 ),
967 ),
968 ),
969 ),
970 );
971 });
972
973 it.effect("ignores unpinned runner recipes from the registry", () => {
974 const agent = makeAgent({ npx: { package: "@example/acp@latest" } });
975 return Effect.gen(function* () {
976 const fileSystem = yield* FileSystem.FileSystem;
977 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
978 prefix: "t3-acp-registry-unpinned-",
979 });
980 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
981 cacheDir,
982 toolsDir: `${cacheDir}/tools`,
983 registryUrl,
984 });
985 const result = yield* resolver.search({ query: "" });
986
987 expect(result.agents).toEqual([]);
988 }).pipe(
989 Effect.scoped,
990 Effect.provide(
991 layerResolver((request) =>
992 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
993 ),
994 ),
995 );
996 });
997
998 it.effect("rejects package syntax for the wrong runner", () => {
999 const invalidAgents = [
1000 { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" },
1001 ];
1002 const validAgents = [
1003 { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" },
1004 { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" },
1005 ];
1006 return Effect.gen(function* () {
1007 const fileSystem = yield* FileSystem.FileSystem;
1008 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1009 prefix: "t3-acp-registry-runner-syntax-",
1010 });
1011 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1012 cacheDir,
1013 toolsDir: `${cacheDir}/tools`,
1014 registryUrl,
1015 });
1016 const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip);
1017 const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip);
1018 const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip);
1019
1020 expect(invalid.reason).toBe("agent_not_found");
1021 expect(validAt.reason).toBe("runner_unavailable");
1022 expect(validEquals.reason).toBe("runner_unavailable");
1023 }).pipe(
1024 Effect.scoped,
1025 Effect.provide(
1026 layerResolver(
1027 (request) =>
1028 Effect.succeed(
1029 HttpClientResponse.fromWeb(
1030 request,
1031 new Response(
1032 JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }),
1033 ),
1034 ),
1035 ),
1036 { PATH: "" },
1037 ),
1038 ),
1039 );
1040 });
1041
1042 it.effect("verifies declared SHA-256 before installing a binary", () => {
1043 const agent = makeAgent({
1044 binary: {
1045 "linux-x86_64": {
1046 archive: archiveUrl,
1047 cmd: "example-agent",
1048 sha256: "0".repeat(64),
1049 },
1050 },
1051 });
1052 const binaryBytes = new TextEncoder().encode("not the declared binary");
1053
1054 return Effect.gen(function* () {
1055 const fileSystem = yield* FileSystem.FileSystem;
1056 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1057 prefix: "t3-acp-registry-checksum-",
1058 });
1059 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1060 cacheDir,
1061 toolsDir: `${cacheDir}/tools`,
1062 registryUrl,
1063 });
1064 const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip);
1065
1066 expect(error.reason).toBe("checksum_mismatch");
1067 }).pipe(
1068 Effect.scoped,
1069 Effect.provide(
1070 layerResolver((request) =>
1071 Effect.succeed(
1072 HttpClientResponse.fromWeb(
1073 request,
1074 request.url === registryUrl
1075 ? new Response(makeRegistry(agent))
1076 : new Response(binaryBytes.buffer as ArrayBuffer),
1077 ),
1078 ),
1079 ),
1080 ),
1081 );
1082 });
1083
1084 it.effect("installs into T3 home package recipes during preparation", () => {
1085 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } });
1086 const requests: string[] = [];
1087
1088 return Effect.gen(function* () {
1089 const fileSystem = yield* FileSystem.FileSystem;
1090 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1091 prefix: "t3-acp-registry-runner-",
1092 });
1093 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1094 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1095 cacheDir,
1096 toolsDir: `${cacheDir}/tools`,
1097 registryUrl,
1098 }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment));
1099 const prepared = yield* resolver.prepare({ agentId: agent.id });
1100 expect(prepared).toEqual({
1101 agentId: "example-agent",
1102 version: "1.2.3",
1103 distribution: "npx",
1104 prepared: true,
1105 });
1106 expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true);
1107 expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false);
1108 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
1109 "install --global @example/acp@1.2.3",
1110 );
1111 expect(requests).toEqual([registryUrl]);
1112 }).pipe(
1113 Effect.scoped,
1114 Effect.provide(
1115 layerResolver((request) => {
1116 requests.push(request.url);
1117 return Effect.succeed(
1118 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1119 );
1120 }, process.env),
1121 ),
1122 );
1123 });
1124
1125 it.effect("falls back to a valid cached registry index when refresh fails", () => {
1126 const agent = makeAgent({
1127 npx: {
1128 package: "@example/acp@1.2.3",
1129 args: ["--stdio"],
1130 },
1131 });
1132 return Effect.gen(function* () {
1133 const fileSystem = yield* FileSystem.FileSystem;
1134 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1135 prefix: "t3-acp-registry-cache-",
1136 });
1137 const registryDirectory = `${cacheDir}/acp-registry`;
1138 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1139 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1140 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1141 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1142 cacheDir,
1143 toolsDir: `${cacheDir}/tools`,
1144 registryUrl,
1145 });
1146 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
1147
1148 expect(resolved.spawn).toMatchObject({
1149 command: toolchain.executablePath,
1150 args: ["--stdio"],
1151 });
1152 }).pipe(
1153 Effect.scoped,
1154 Effect.provide(
1155 layerResolver((request) =>
1156 Effect.succeed(
1157 HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })),
1158 ),
1159 ),
1160 ),
1161 );
1162 });
1163
1164 it.effect("rejects unsafe command paths before downloading an archive", () => {
1165 const agent = makeAgent({
1166 binary: {
1167 "linux-x86_64": {
1168 archive: archiveUrl,
1169 cmd: "../outside",
1170 },
1171 },
1172 });
1173 const requests: Array<string> = [];
1174 return Effect.gen(function* () {
1175 const fileSystem = yield* FileSystem.FileSystem;
1176 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1177 prefix: "t3-acp-registry-invalid-",
1178 });
1179 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1180 cacheDir,
1181 toolsDir: `${cacheDir}/tools`,
1182 registryUrl,
1183 });
1184 const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip);
1185
1186 expect(error.reason).toBe("archive_invalid");
1187 expect(requests).toEqual([registryUrl]);
1188 }).pipe(
1189 Effect.scoped,
1190 Effect.provide(
1191 layerResolver((request) => {
1192 requests.push(request.url);
1193 return Effect.succeed(
1194 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1195 );
1196 }),
1197 ),
1198 );
1199 });
1200
1201 it.effect("inspects from disk without waiting for a registry refresh", () => {
1202 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1203 let requests = 0;
1204 return Effect.gen(function* () {
1205 const fileSystem = yield* FileSystem.FileSystem;
1206 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1207 prefix: "t3-acp-registry-cold-inspect-",
1208 });
1209 const registryDirectory = `${cacheDir}/acp-registry`;
1210 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1211 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1212
1213 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1214 cacheDir,
1215 toolsDir: `${cacheDir}/tools`,
1216 registryUrl,
1217 });
1218 const inspection = yield* resolver.inspect(settings());
1219
1220 expect(inspection).toMatchObject({ status: "ready", agentId: agent.id });
1221 expect(requests).toBe(0);
1222 }).pipe(
1223 Effect.scoped,
1224 Effect.provide(
1225 layerResolver((request) => {
1226 requests += 1;
1227 return Effect.succeed(
1228 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1229 );
1230 }),
1231 ),
1232 );
1233 });
1234
1235 it.effect("fails a cold inspection immediately when no local registry is available", () => {
1236 let requests = 0;
1237 return Effect.gen(function* () {
1238 const fileSystem = yield* FileSystem.FileSystem;
1239 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1240 prefix: "t3-acp-registry-empty-inspect-",
1241 });
1242 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1243 cacheDir,
1244 toolsDir: `${cacheDir}/tools`,
1245 registryUrl,
1246 });
1247 const error = yield* resolver.inspect(settings()).pipe(Effect.flip);
1248
1249 expect(error.reason).toBe("registry_unavailable");
1250 expect(requests).toBe(0);
1251 }).pipe(
1252 Effect.scoped,
1253 Effect.provide(
1254 layerResolver((request) => {
1255 requests += 1;
1256 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused")));
1257 }),
1258 ),
1259 );
1260 });
1261
1262 it.effect("uses the effective provider environment for inspection and resolution", () => {
1263 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1264 return Effect.gen(function* () {
1265 const fileSystem = yield* FileSystem.FileSystem;
1266 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1267 prefix: "t3-acp-registry-effective-env-",
1268 });
1269 const registryDirectory = `${cacheDir}/acp-registry`;
1270 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1271 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1272 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1273
1274 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1275 cacheDir,
1276 toolsDir: `${cacheDir}/tools`,
1277 registryUrl,
1278 });
1279 const hostInspection = yield* resolver.inspect(settings());
1280 const providerEnvironment = toolchain.environment;
1281 const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment);
1282 const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment);
1283
1284 expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" });
1285 expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" });
1286 expect(resolved.spawn).toMatchObject({
1287 command: toolchain.executablePath,
1288 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
1289 });
1290 }).pipe(
1291 Effect.scoped,
1292 Effect.provide(
1293 layerResolver(
1294 (request) =>
1295 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1296 { PATH: "" },
1297 ),
1298 ),
1299 );
1300 });
1301
1302 it.effect("requires a regular executable file in the managed binary cache", () => {
1303 const agent = makeAgent({
1304 binary: {
1305 "linux-x86_64": {
1306 archive: archiveUrl,
1307 cmd: "bin/example-agent",
1308 },
1309 },
1310 });
1311 return Effect.gen(function* () {
1312 const fileSystem = yield* FileSystem.FileSystem;
1313 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1314 prefix: "t3-acp-registry-non-file-",
1315 });
1316 const registryDirectory = `${cacheDir}/acp-registry`;
1317 const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`;
1318 yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true });
1319 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1320 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1321
1322 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1323 cacheDir,
1324 toolsDir: `${cacheDir}/tools`,
1325 registryUrl,
1326 });
1327 const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1328
1329 expect(directoryError.reason).toBe("archive_invalid");
1330
1331 yield* fileSystem.remove(fakeExecutable, { recursive: true });
1332 yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n");
1333 yield* fileSystem.chmod(fakeExecutable, 0o644);
1334 const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1335
1336 expect(modeError.reason).toBe("archive_invalid");
1337
1338 yield* fileSystem.chmod(fakeExecutable, 0o755);
1339 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1340 }).pipe(
1341 Effect.scoped,
1342 Effect.provide(
1343 layerResolver((request) =>
1344 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1345 ),
1346 ),
1347 );
1348 });
1349
1350 it.effect.each([
1351 { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true },
1352 { name: "registry", source: "registry", agentId: "example-agent", referenced: true },
1353 { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true },
1354 { name: "local command", source: "local", agentId: "example-agent", referenced: false },
1355 ] as const)(
1356 "checks $name references before uninstalling only managed binaries",
1357 ({ source, agentId, referenced }) => {
1358 return Effect.gen(function* () {
1359 const fileSystem = yield* FileSystem.FileSystem;
1360 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1361 prefix: "t3-acp-registry-uninstall-",
1362 });
1363 const agentRoot = `${cacheDir}/tools/example-agent`;
1364 const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`;
1365 yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true });
1366 yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary");
1367 yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, {
1368 recursive: true,
1369 });
1370 yield* fileSystem.writeFileString(runnerCache, "{}");
1371
1372 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1373 cacheDir,
1374 toolsDir: `${cacheDir}/tools`,
1375 registryUrl,
1376 });
1377 const hostSettings = yield* TestProviderHostSettings;
1378 const instanceId = ProviderInstanceId.make("uninstall-reference");
1379 yield* hostSettings.set({
1380 ...DEFAULT_SERVER_SETTINGS,
1381 providerInstances: {
1382 [instanceId]: {
1383 driver: ProviderDriverKind.make("acpRegistry"),
1384 displayName: "Uninstall reference",
1385 enabled: true,
1386 config: {
1387 agentId,
1388 commandPath: "/local/agent",
1389 ...(source ? { source } : {}),
1390 },
1391 },
1392 },
1393 });
1394 const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1395 expect(first).toEqual({ agentId: "example-agent", removed: !referenced });
1396 expect(yield* fileSystem.exists(agentRoot)).toBe(referenced);
1397 yield* hostSettings.set(DEFAULT_SERVER_SETTINGS);
1398 const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1399
1400 expect(second).toEqual({ agentId: "example-agent", removed: referenced });
1401 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1402 agentId: "example-agent",
1403 removed: false,
1404 });
1405 expect(yield* fileSystem.exists(agentRoot)).toBe(false);
1406 expect(yield* fileSystem.exists(runnerCache)).toBe(true);
1407 }).pipe(
1408 Effect.scoped,
1409 Effect.provide(
1410 layerResolver((request) =>
1411 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))),
1412 ),
1413 ),
1414 );
1415 },
1416 );
1417
1418 it.effect("keeps managed package installs when removing the same agent's binaries", () =>
1419 Effect.gen(function* () {
1420 const fileSystem = yield* FileSystem.FileSystem;
1421 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1422 prefix: "t3-acp-uninstall-packages-",
1423 });
1424 const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`;
1425 yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true });
1426 yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary");
1427 yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true });
1428 yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command");
1429 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1430 cacheDir,
1431 toolsDir: `${cacheDir}/tools`,
1432 registryUrl,
1433 });
1434 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1435 agentId: "example-agent",
1436 removed: true,
1437 });
1438 expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false);
1439 expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe(
1440 "package command",
1441 );
1442 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1443 agentId: "example-agent",
1444 removed: false,
1445 });
1446 }).pipe(
1447 Effect.scoped,
1448 Effect.provide(layerResolver(() => Effect.die("unexpected HTTP request"))),
1449 ),
1450 );
1451
1452 it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => {
1453 const agent = makeAgent({
1454 binary: {
1455 "linux-x86_64": {
1456 archive: archiveUrl,
1457 cmd: "./bin/example-agent",
1458 },
1459 },
1460 });
1461 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1462 return Effect.gen(function* () {
1463 const downloadStarted = yield* Deferred.make<void>();
1464 const releaseDownload = yield* Deferred.make<void>();
1465 return yield* Effect.gen(function* () {
1466 const fileSystem = yield* FileSystem.FileSystem;
1467 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1468 prefix: "t3-acp-registry-uninstall-race-",
1469 });
1470 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1471 cacheDir,
1472 toolsDir: `${cacheDir}/tools`,
1473 registryUrl,
1474 });
1475
1476 const prepareFiber = yield* resolver
1477 .prepare({ agentId: agent.id })
1478 .pipe(Effect.forkChild({ startImmediately: true }));
1479 yield* Deferred.await(downloadStarted);
1480 const uninstallFiber = yield* resolver
1481 .uninstallManagedBinary({ agentId: agent.id })
1482 .pipe(Effect.forkChild({ startImmediately: true }));
1483
1484 expect(uninstallFiber.pollUnsafe()).toBeUndefined();
1485 yield* Deferred.succeed(releaseDownload, undefined);
1486
1487 expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true });
1488 expect(yield* Fiber.join(uninstallFiber)).toEqual({
1489 agentId: agent.id,
1490 removed: false,
1491 });
1492 const agentRoot = `${cacheDir}/tools/${agent.id}`;
1493 expect(yield* fileSystem.exists(agentRoot)).toBe(true);
1494
1495 const hostSettings = yield* TestProviderHostSettings;
1496 const instanceId = ProviderInstanceId.make("registry-reference");
1497 yield* hostSettings.set({
1498 ...DEFAULT_SERVER_SETTINGS,
1499 providerInstances: {
1500 [instanceId]: {
1501 driver: ProviderDriverKind.make("acpRegistry"),
1502 displayName: "Registry reference",
1503 enabled: true,
1504 config: { agentId: agent.id },
1505 },
1506 },
1507 });
1508 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1509 agentId: agent.id,
1510 removed: false,
1511 });
1512 yield* hostSettings.set(DEFAULT_SERVER_SETTINGS);
1513 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1514 agentId: agent.id,
1515 removed: true,
1516 });
1517 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1518 agentId: agent.id,
1519 removed: false,
1520 });
1521 }).pipe(
1522 Effect.scoped,
1523 Effect.provide(
1524 layerResolver((request) => {
1525 if (request.url === registryUrl) {
1526 return Effect.succeed(
1527 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1528 );
1529 }
1530 return Deferred.succeed(downloadStarted, undefined).pipe(
1531 Effect.andThen(Deferred.await(releaseDownload)),
1532 Effect.as(
1533 HttpClientResponse.fromWeb(
1534 request,
1535 new Response(binaryBytes.buffer as ArrayBuffer),
1536 ),
1537 ),
1538 );
1539 }),
1540 ),
1541 );
1542 });
1543 });
1544
1545 it.effect("reserves a prepared binary until a configured instance inspects it", () => {
1546 const agent = makeAgent({
1547 binary: {
1548 "linux-x86_64": {
1549 archive: archiveUrl,
1550 cmd: "./bin/example-agent",
1551 },
1552 },
1553 });
1554 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1555 return Effect.gen(function* () {
1556 const fileSystem = yield* FileSystem.FileSystem;
1557 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1558 prefix: "t3-acp-registry-uninstall-reservation-",
1559 });
1560 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1561 cacheDir,
1562 toolsDir: `${cacheDir}/tools`,
1563 registryUrl,
1564 });
1565
1566 yield* resolver.prepare({ agentId: agent.id });
1567 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1568 agentId: agent.id,
1569 removed: false,
1570 });
1571 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1572 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1573 agentId: agent.id,
1574 removed: true,
1575 });
1576 }).pipe(
1577 Effect.scoped,
1578 Effect.provide(
1579 layerResolver((request) =>
1580 Effect.succeed(
1581 HttpClientResponse.fromWeb(
1582 request,
1583 request.url === registryUrl
1584 ? new Response(makeRegistry(agent))
1585 : new Response(binaryBytes.buffer as ArrayBuffer),
1586 ),
1587 ),
1588 ),
1589 ),
1590 );
1591 });
1592
1593 it.effect("expires an abandoned prepared-binary reservation", () => {
1594 const agent = makeAgent({
1595 binary: {
1596 "linux-x86_64": {
1597 archive: archiveUrl,
1598 cmd: "./bin/example-agent",
1599 },
1600 },
1601 });
1602 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1603 return Effect.gen(function* () {
1604 const fileSystem = yield* FileSystem.FileSystem;
1605 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1606 prefix: "t3-acp-registry-uninstall-expiry-",
1607 });
1608 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1609 cacheDir,
1610 toolsDir: `${cacheDir}/tools`,
1611 registryUrl,
1612 });
1613
1614 yield* resolver.prepare({ agentId: agent.id });
1615 yield* TestClock.adjust("31 seconds");
1616 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1617 agentId: agent.id,
1618 removed: true,
1619 });
1620 }).pipe(
1621 Effect.scoped,
1622 Effect.provide(
1623 layerResolver((request) =>
1624 Effect.succeed(
1625 HttpClientResponse.fromWeb(
1626 request,
1627 request.url === registryUrl
1628 ? new Response(makeRegistry(agent))
1629 : new Response(binaryBytes.buffer as ArrayBuffer),
1630 ),
1631 ),
1632 ),
1633 ),
1634 );
1635 });
1636});
1637
1638describe("acpRegistryManagedBinaryDirectories", () => {
1639 it.effect("lists managed package and binary command directories", () =>
1640 Effect.gen(function* () {
1641 const fileSystem = yield* FileSystem.FileSystem;
1642 const path = yield* Path.Path;
1643 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1644 prefix: "t3-acp-registry-bins-",
1645 });
1646 const install = (agent: string, version: string, target: string, commandDirectory = "") =>
1647 fileSystem.makeDirectory(
1648 path.join(cacheDir, "tools", agent, version, target, commandDirectory),
1649 { recursive: true },
1650 );
1651 yield* install("kimi", "1.49.0", "linux-x86_64", "bin");
1652 yield* install("kimi", "1.50.0", "linux-x86_64", "cmd");
1653 yield* install("kimi", "1.50.0", "darwin-aarch64");
1654 yield* install("other-agent", "0.2.0", "darwin-aarch64");
1655 const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin");
1656 const geminiCommand = path.join(globalBin, "gemini");
1657 const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs");
1658 yield* fileSystem.makeDirectory(globalBin, { recursive: true });
1659 yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true });
1660 yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n");
1661 yield* fileSystem.chmod(geminiCommand, 0o755);
1662 yield* fileSystem.writeFileString(
1663 path.join(receiptsDirectory, "gemini.json"),
1664 encodeUnknownJson({
1665 agentId: "gemini",
1666 agentVersion: "0.56.0",
1667 distribution: "npx",
1668 packageSpec: "@google/gemini-cli@0.56.0",
1669 managerPath: "/usr/bin/npm",
1670 binDirectory: globalBin,
1671 executablePath: geminiCommand,
1672 packageRoot: path.join(
1673 cacheDir,
1674 "tools",
1675 "gemini",
1676 "0.56.0",
1677 "npm",
1678 "lib",
1679 "node_modules",
1680 "@google",
1681 "gemini-cli",
1682 ),
1683 packageVersion: "0.56.0",
1684 }),
1685 );
1686 yield* fileSystem.writeFileString(
1687 path.join(cacheDir, "acp-registry", "registry.json"),
1688 encodeUnknownJson({
1689 version: "1.0.0",
1690 agents: [
1691 {
1692 ...makeAgent({
1693 binary: {
1694 "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" },
1695 },
1696 }),
1697 id: "kimi",
1698 name: "Kimi",
1699 version: "1.49.0",
1700 },
1701 {
1702 ...makeAgent({
1703 binary: {
1704 "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" },
1705 },
1706 }),
1707 id: "kimi",
1708 name: "Kimi",
1709 version: "1.50.0",
1710 },
1711 ],
1712 }),
1713 );
1714
1715 const directories = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1716 fileSystem,
1717 path,
1718 cacheDir,
1719 toolsDir: path.join(cacheDir, "tools"),
1720 platform: "linux",
1721 architecture: "x64",
1722 });
1723 expect(directories).toEqual([
1724 globalBin,
1725 path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"),
1726 path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"),
1727 ]);
1728
1729 const missing = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1730 fileSystem,
1731 path,
1732 cacheDir: path.join(cacheDir, "does-not-exist"),
1733 toolsDir: path.join(cacheDir, "does-not-exist", "tools"),
1734 platform: "linux",
1735 architecture: "x64",
1736 });
1737 expect(missing).toEqual([]);
1738 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
1739 );
1740});
packages/provider-acp-registry/src/server/AcpRegistrySupport.ts created+1963
......@@ -0,0 +1,1963 @@
1import {
2 AcpRegistryOperationError,
3 AcpRegistryOperationErrorReason,
4 TrimmedNonEmptyString,
5 type AcpRegistryManagedBinaryUninstallInput,
6 type AcpRegistryManagedBinaryUninstallResult,
7 type AcpRegistryPrepareInput,
8 type AcpRegistryPrepareResult,
9 type AcpRegistrySearchInput,
10 type AcpRegistrySearchResult,
11 type AcpRegistryDistribution as AcpRegistryDistributionKind,
12} from "@t3tools/contracts";
13import type { AcpRegistryDistributionPreference } from "../settings.ts";
14import type { AcpRegistrySettings } from "../settings.ts";
15import {
16 HostProcessArchitecture,
17 HostProcessEnvironment,
18 HostProcessPlatform,
19} from "@t3tools/shared/hostProcess";
20import {
21 mergePathEntries,
22 resolveSpawnCommand,
23 SpawnExecutableResolution,
24} from "@t3tools/shared/shell";
25import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";
27import * as Duration from "effect/Duration";
28import * as Crypto from "effect/Crypto";
29import * as Effect from "effect/Effect";
30import * as Hex from "effect/encoding/Hex";
31import * as FileSystem from "effect/FileSystem";
32import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";
34import * as Path from "effect/Path";
35import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
36import * as PlatformError from "effect/PlatformError";
37import * as Ref from "effect/Ref";
38import * as Schema from "effect/Schema";
39import * as Semaphore from "effect/Semaphore";
40import * as Stream from "effect/Stream";
41import { sha256 } from "@noble/hashes/sha2";
42import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
43import { ChildProcess, ChildProcessSpawner } from "effect/process";
44
45import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
46import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
47import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
48
49const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
50
51const MAX_REGISTRY_BYTES = 1024 * 1024;
52const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
53const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024;
54const MAX_SEARCH_RESULTS = 20;
55const REGISTRY_REQUEST_TIMEOUT = "30 seconds";
56const ARCHIVE_REQUEST_TIMEOUT = "20 minutes";
57const PACKAGE_INSTALL_TIMEOUT = "20 minutes";
58const PACKAGE_QUERY_TIMEOUT = "30 seconds";
59
60const BoundedAgentId = Schema.String.check(
61 Schema.isMaxLength(128),
62 Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u),
63);
64const BoundedName = TrimmedNonEmptyString.check(Schema.isMaxLength(160));
65// Versions become install directory names, so "." and ".." must not decode.
66const BoundedVersion = TrimmedNonEmptyString.check(
67 Schema.isMaxLength(128),
68 Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u),
69);
70const BoundedDescription = Schema.String.check(Schema.isMaxLength(1_024));
71const BoundedMetadata = Schema.String.check(Schema.isMaxLength(256));
72const BoundedArgument = Schema.String.check(Schema.isMaxLength(1_024));
73const PackageCommandName = Schema.String.check(
74 Schema.isMaxLength(128),
75 Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/iu),
76);
77const isPackageCommandName = Schema.is(PackageCommandName);
78const EXACT_RUNNER_VERSION = "v?[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?";
79const EXACT_NPX_PACKAGE = new RegExp(
80 `^(?:@[^/@\\s]+/[^@\\s]+|[a-z0-9][a-z0-9._-]*)@${EXACT_RUNNER_VERSION}$`,
81 "iu",
82);
83const EXACT_UVX_PACKAGE = new RegExp(`^[a-z0-9][a-z0-9._-]*(?:@|==)${EXACT_RUNNER_VERSION}$`, "iu");
84const NpxPackage = Schema.String.check(
85 Schema.isMaxLength(256),
86 Schema.makeFilter(
87 (value) =>
88 EXACT_NPX_PACKAGE.test(value) || "ACP Registry npx packages must include an exact version.",
89 ),
90);
91const UvxPackage = Schema.String.check(
92 Schema.isMaxLength(256),
93 Schema.makeFilter(
94 (value) =>
95 EXACT_UVX_PACKAGE.test(value) || "ACP Registry uvx packages must include an exact version.",
96 ),
97);
98const HttpsUrl = Schema.String.check(
99 Schema.isMaxLength(2_048),
100 Schema.makeFilter((value) => {
101 try {
102 const url = new URL(value);
103 return (
104 (url.protocol === "https:" && url.username.length === 0 && url.password.length === 0) ||
105 "ACP Registry URLs must use HTTPS without embedded credentials."
106 );
107 } catch {
108 return "ACP Registry URLs must be valid absolute URLs.";
109 }
110 }),
111);
112
113const AcpRegistryPackageDistributionFields = {
114 args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))),
115 env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)),
116} as const;
117const AcpRegistryNpxDistribution = Schema.Struct({
118 package: NpxPackage,
119 ...AcpRegistryPackageDistributionFields,
120});
121const AcpRegistryUvxDistribution = Schema.Struct({
122 package: UvxPackage,
123 ...AcpRegistryPackageDistributionFields,
124});
125
126const AcpRegistryBinaryTarget = Schema.Struct({
127 archive: HttpsUrl,
128 cmd: BoundedArgument,
129 sha256: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/iu))),
130 args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))),
131 env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)),
132});
133
134const AcpRegistryAgent = Schema.Struct({
135 id: BoundedAgentId,
136 name: BoundedName,
137 version: BoundedVersion,
138 description: BoundedDescription,
139 authors: Schema.optionalKey(Schema.Array(BoundedMetadata).check(Schema.isMaxLength(16))),
140 license: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))),
141 website: Schema.optionalKey(HttpsUrl),
142 repository: Schema.optionalKey(HttpsUrl),
143 icon: Schema.optionalKey(HttpsUrl),
144 distribution: Schema.Struct({
145 binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)),
146 npx: Schema.optionalKey(AcpRegistryNpxDistribution),
147 uvx: Schema.optionalKey(AcpRegistryUvxDistribution),
148 }),
149});
150export type AcpRegistryAgent = typeof AcpRegistryAgent.Type;
151
152const NpmPackageManifest = Schema.Struct({
153 name: Schema.String,
154 version: Schema.String,
155 bin: Schema.Union([BoundedArgument, Schema.Record(PackageCommandName, BoundedArgument)]),
156});
157
158const AcpRegistryPackageInstallReceipt = Schema.Struct({
159 agentId: BoundedAgentId,
160 agentVersion: BoundedVersion,
161 distribution: Schema.Literals(["npx", "uvx"]),
162 packageSpec: Schema.String,
163 managerPath: Schema.String,
164 binDirectory: Schema.String,
165 executablePath: Schema.String,
166 packageRoot: Schema.optional(Schema.String),
167 packageVersion: Schema.optional(Schema.String),
168});
169type AcpRegistryPackageInstallReceipt = typeof AcpRegistryPackageInstallReceipt.Type;
170
171const AcpRegistryIndexEnvelope = Schema.Struct({
172 version: BoundedVersion,
173 agents: Schema.Array(Schema.Unknown).check(Schema.isMaxLength(512)),
174});
175export interface AcpRegistryIndex {
176 readonly version: string;
177 readonly agents: ReadonlyArray<AcpRegistryAgent>;
178}
179
180const decodeRegistryIndexEnvelope = Schema.decodeUnknownEffect(AcpRegistryIndexEnvelope);
181const decodeRegistryAgent = Schema.decodeUnknownOption(AcpRegistryAgent);
182const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
183const decodeNpmPackageManifest = Schema.decodeUnknownEffect(
184 Schema.fromJsonString(NpmPackageManifest),
185);
186const decodePackageInstallReceipt = Schema.decodeUnknownOption(
187 Schema.fromJsonString(AcpRegistryPackageInstallReceipt),
188);
189const encodePackageInstallReceipt = Schema.encodeSync(
190 Schema.fromJsonString(AcpRegistryPackageInstallReceipt),
191);
192const decodeHttpsUrl = Schema.decodeUnknownEffect(HttpsUrl);
193const decodeBoundedAgentId = Schema.decodeUnknownEffect(BoundedAgentId);
194
195export const AcpRegistryErrorReason = AcpRegistryOperationErrorReason;
196export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type;
197
198export class AcpRegistryError extends Schema.TaggedError<AcpRegistryError>()("AcpRegistryError", {
199 reason: AcpRegistryErrorReason,
200 detail: Schema.String,
201 cause: Schema.optional(Schema.Defect()),
202}) {
203 override get message(): string {
204 return this.detail;
205 }
206}
207
208export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistryOperationError {
209 return new AcpRegistryOperationError({
210 reason: error.reason,
211 message: error.detail,
212 cause: error,
213 });
214}
215
216const isAcpRegistryError = Schema.is(AcpRegistryError);
217
218export type AcpRegistryPlatformTarget =
219 | "darwin-aarch64"
220 | "darwin-x86_64"
221 | "linux-aarch64"
222 | "linux-x86_64"
223 | "windows-aarch64"
224 | "windows-x86_64";
225
226export function resolveAcpRegistryPlatformTarget(
227 platform: NodeJS.Platform,
228 architecture: NodeJS.Architecture,
229): AcpRegistryPlatformTarget | undefined {
230 const os =
231 platform === "darwin"
232 ? "darwin"
233 : platform === "linux"
234 ? "linux"
235 : platform === "win32"
236 ? "windows"
237 : undefined;
238 const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined;
239 return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined;
240}
241
242interface ExactPackageSpec {
243 readonly name: string;
244 readonly version: string;
245}
246
247function parseNpxPackageSpec(packageSpec: string): ExactPackageSpec {
248 const separator = packageSpec.lastIndexOf("@");
249 return {
250 name: packageSpec.slice(0, separator),
251 version: packageSpec.slice(separator + 1).replace(/^v/iu, ""),
252 };
253}
254
255function parseUvxPackageSpec(packageSpec: string): ExactPackageSpec {
256 const equalsSeparator = packageSpec.lastIndexOf("==");
257 const separator = equalsSeparator >= 0 ? equalsSeparator : packageSpec.lastIndexOf("@");
258 const separatorLength = equalsSeparator >= 0 ? 2 : 1;
259 return {
260 name: packageSpec.slice(0, separator),
261 version: packageSpec.slice(separator + separatorLength).replace(/^v/iu, ""),
262 };
263}
264
265function packageManagerFor(distribution: AcpRegistryDistributionKind): "npm" | "uv" | undefined {
266 return distribution === "npx" ? "npm" : distribution === "uvx" ? "uv" : undefined;
267}
268
269function packageCommandCandidates(
270 agent: AcpRegistryAgent,
271 packageName: string,
272): ReadonlyArray<string> {
273 const unscopedPackageName = packageName.split("/").pop() ?? packageName;
274 return Array.from(
275 new Set(
276 [agent.id, unscopedPackageName, unscopedPackageName.replace(/-acp$/u, "")].filter(
277 isPackageCommandName,
278 ),
279 ),
280 );
281}
282
283function readEnvironmentPath(
284 environment: NodeJS.ProcessEnv,
285 platform: NodeJS.Platform,
286): string | undefined {
287 if (platform !== "win32") return environment.PATH;
288 return (
289 environment.PATH ??
290 Object.entries(environment).find(([key]) => key.toLowerCase() === "path")?.[1]
291 );
292}
293
294function withPreferredPath(
295 environment: NodeJS.ProcessEnv,
296 directory: string,
297 platform: NodeJS.Platform,
298): NodeJS.ProcessEnv {
299 return {
300 ...environment,
301 PATH: mergePathEntries(directory, readEnvironmentPath(environment, platform), platform),
302 };
303}
304
305/**
306 * Directories exposing installed ACP Registry commands. The integrated
307 * terminal appends them to PATH so managed package commands and
308 * managed binary agents are both available by name. Best effort: unreadable
309 * directories resolve to no entries.
310 */
311export const acpRegistryManagedBinaryDirectories = (input: {
312 readonly fileSystem: FileSystem.FileSystem;
313 readonly path: Path.Path;
314 readonly cacheDir: string;
315 readonly toolsDir: string;
316 readonly platform: NodeJS.Platform;
317 readonly architecture: NodeJS.Architecture;
318}): Effect.Effect<ReadonlyArray<string>> =>
319 Effect.gen(function* () {
320 const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture);
321 const registryDirectory = input.path.join(input.cacheDir, "acp-registry");
322 const installsDirectory = input.toolsDir;
323 const packageReceiptsDirectory = input.path.join(registryDirectory, "package-installs");
324 const listDirectories = (directory: string) =>
325 input.fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): Array<string> => []));
326 const directories: Array<string> = [];
327
328 for (const receiptFile of (yield* listDirectories(packageReceiptsDirectory)).toSorted()) {
329 const receipt = yield* input.fileSystem
330 .readFileString(input.path.join(packageReceiptsDirectory, receiptFile))
331 .pipe(
332 Effect.map(decodePackageInstallReceipt),
333 Effect.orElseSucceed(() => Option.none()),
334 );
335 if (
336 Option.isSome(receipt) &&
337 receipt.value.binDirectory ===
338 input.path.join(
339 installsDirectory,
340 receipt.value.agentId,
341 encodeURIComponent(receipt.value.agentVersion),
342 receipt.value.distribution === "npx" ? "npm" : "python",
343 ...(receipt.value.distribution === "npx" && input.platform === "win32" ? [] : ["bin"]),
344 ) &&
345 input.path.dirname(receipt.value.executablePath) === receipt.value.binDirectory &&
346 (yield* input.fileSystem
347 .exists(receipt.value.executablePath)
348 .pipe(Effect.orElseSucceed(() => false)))
349 ) {
350 directories.push(receipt.value.binDirectory);
351 }
352 }
353
354 if (target === undefined) return Array.from(new Set(directories));
355 const cachedAgents = yield* input.fileSystem
356 .readFileString(input.path.join(registryDirectory, "registry.json"))
357 .pipe(
358 Effect.flatMap(decodeJson),
359 Effect.flatMap(decodeRegistryIndexEnvelope),
360 Effect.map((envelope) =>
361 envelope.agents.flatMap((candidate) => {
362 const decoded = decodeRegistryAgent(candidate);
363 return Option.isSome(decoded) ? [decoded.value] : [];
364 }),
365 ),
366 Effect.orElseSucceed((): ReadonlyArray<AcpRegistryAgent> => []),
367 );
368 const cachedAgentByInstall = new Map(
369 // Install directories are named by the URI-encoded version.
370 cachedAgents.map(
371 (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const,
372 ),
373 );
374 for (const agent of (yield* listDirectories(installsDirectory)).toSorted()) {
375 const versions = (yield* listDirectories(input.path.join(installsDirectory, agent))).toSorted(
376 (left, right) => right.localeCompare(left, undefined, { numeric: true }),
377 );
378 for (const version of versions) {
379 const installRoot = input.path.join(installsDirectory, agent, version, target);
380 if (yield* input.fileSystem.exists(installRoot).pipe(Effect.orElseSucceed(() => false))) {
381 const binaryTarget = cachedAgentByInstall.get(`${agent}\0${version}`)?.distribution
382 .binary?.[target];
383 const commandSegments =
384 binaryTarget === undefined ? undefined : normalizeRegistryCommandPath(binaryTarget.cmd);
385 const directory =
386 commandSegments === undefined
387 ? installRoot
388 : input.path.join(installRoot, ...commandSegments.slice(0, -1));
389 if (yield* input.fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) {
390 directories.push(directory);
391 }
392 }
393 }
394 }
395 return Array.from(new Set(directories));
396 });
397
398export interface ResolvedAcpRegistryDistribution {
399 readonly kind: AcpRegistryDistributionKind;
400 readonly args: ReadonlyArray<string>;
401 readonly env: Readonly<Record<string, string>>;
402 readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type;
403 readonly packageName?: string;
404}
405
406export function resolveAcpRegistryDistribution(input: {
407 readonly agent: AcpRegistryAgent;
408 readonly preference: AcpRegistryDistributionPreference;
409 readonly platformTarget: AcpRegistryPlatformTarget | undefined;
410}): ResolvedAcpRegistryDistribution | undefined {
411 const { agent, platformTarget } = input;
412 const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined;
413 const candidates: ReadonlyArray<AcpRegistryDistributionKind> =
414 input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference];
415
416 for (const kind of candidates) {
417 if (kind === "binary" && binary) {
418 return {
419 kind,
420 args: binary.args ?? [],
421 env: binary.env ?? {},
422 binaryTarget: binary,
423 };
424 }
425 if (kind === "npx" && agent.distribution.npx) {
426 return {
427 kind,
428 args: agent.distribution.npx.args ?? [],
429 env: agent.distribution.npx.env ?? {},
430 packageName: agent.distribution.npx.package,
431 };
432 }
433 if (kind === "uvx" && agent.distribution.uvx) {
434 return {
435 kind,
436 args: agent.distribution.uvx.args ?? [],
437 env: agent.distribution.uvx.env ?? {},
438 packageName: agent.distribution.uvx.package,
439 };
440 }
441 }
442 return undefined;
443}
444
445export interface ResolvedAcpRegistryAgent {
446 readonly agent?: AcpRegistryAgent;
447 readonly distribution: AcpRegistryDistributionKind | "local";
448 readonly spawn: AcpSessionRuntime.AcpSpawnInput;
449}
450
451export type AcpRegistryInspection =
452 | { readonly status: "unconfigured" }
453 | { readonly status: "not_found"; readonly agentId: string }
454 | {
455 readonly status: "unsupported";
456 readonly agentId: string;
457 readonly version: string;
458 }
459 | {
460 readonly status: "missing_runner";
461 readonly agentId: string;
462 readonly version: string;
463 readonly distribution: AcpRegistryDistributionKind;
464 readonly runner: string;
465 }
466 | {
467 readonly status: "missing_runner";
468 readonly version: null;
469 readonly distribution: "local";
470 }
471 | {
472 readonly status: "unprepared";
473 readonly agentId: string;
474 readonly version: string;
475 readonly distribution: "binary";
476 }
477 | {
478 readonly status: "ready";
479 readonly agentId: string;
480 readonly version: string | null;
481 readonly distribution: AcpRegistryDistributionKind | "local";
482 readonly documentationUrl?: string;
483 };
484
485export class AcpRegistryCatalog extends Context.Service<
486 AcpRegistryCatalog,
487 {
488 readonly search: (
489 input: AcpRegistrySearchInput,
490 ) => Effect.Effect<AcpRegistrySearchResult, AcpRegistryError>;
491 readonly prepare: (
492 input: AcpRegistryPrepareInput,
493 ) => Effect.Effect<AcpRegistryPrepareResult, AcpRegistryError>;
494 readonly inspect: (
495 settings: AcpRegistrySettings,
496 environment?: NodeJS.ProcessEnv,
497 ) => Effect.Effect<AcpRegistryInspection, AcpRegistryError>;
498 readonly resolve: (
499 settings: AcpRegistrySettings,
500 cwd: string,
501 environment?: NodeJS.ProcessEnv,
502 ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>;
503 readonly uninstallManagedBinary: (
504 input: AcpRegistryManagedBinaryUninstallInput,
505 ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>;
506 }
507>()("@t3tools/provider-acp-registry/server/AcpRegistrySupport/AcpRegistryCatalog") {}
508
509const layer = (options: AcpRegistryCatalogOptions) =>
510 Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options));
511
512/**
513 * The server-lifetime catalog, with the runtime coordinator built alongside
514 * it so setup, snapshots, and turn launch share one of each. Its cache lives
515 * in the host's provider status cache and installed agents under the T3
516 * home's `tools` directory.
517 */
518export const layerFromHost = Layer.merge(
519 Layer.unwrap(
520 Effect.gen(function* () {
521 const { paths } = yield* ProviderHost.ProviderHost;
522 const path = yield* Path.Path;
523 return layer({
524 cacheDir: paths.providerStatusCacheDir,
525 toolsDir: path.join(paths.baseDir, "tools"),
526 });
527 }),
528 ),
529 AcpRegistryRuntimeCoordinator.layer,
530);
531
532export interface AcpRegistryCatalogOptions {
533 readonly cacheDir: string;
534 readonly toolsDir: string;
535 readonly registryUrl?: string;
536}
537
538const INSTALL_LOCK_RETRY_COUNT = 300;
539const INSTALL_LOCK_RETRY_DELAY = "100 millis";
540const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
541const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000;
542
543function isAlreadyExists(error: PlatformError.PlatformError): boolean {
544 return error.reason._tag === "AlreadyExists";
545}
546
547function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined {
548 const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, "");
549 const segments = normalized.split("/").filter((segment) => segment.length > 0);
550 if (
551 segments.length === 0 ||
552 normalized.startsWith("/") ||
553 /^[a-zA-Z]:/u.test(normalized) ||
554 segments.some((segment) => segment === "." || segment === "..")
555 ) {
556 return undefined;
557 }
558 return segments;
559}
560
561function validateArchiveEntries(output: string): boolean {
562 return output
563 .split(/\r?\n/u)
564 .map((entry) => entry.trim())
565 .filter((entry) => entry.length > 0)
566 .every((entry) => entry === "./" || normalizeRegistryCommandPath(entry) !== undefined);
567}
568
569type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip";
570
571function archiveKind(url: string): ArchiveKind {
572 const pathname = new URL(url).pathname.toLowerCase();
573 if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz";
574 if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2";
575 if (pathname.endsWith(".zip")) return "zip";
576 return "raw";
577}
578
579function archiveFileName(kind: ArchiveKind): string {
580 switch (kind) {
581 case "tar_gz":
582 return "agent.tar.gz";
583 case "tar_bz2":
584 return "agent.tar.bz2";
585 case "zip":
586 return "agent.zip";
587 case "raw":
588 return "agent.bin";
589 }
590}
591
592function compareText(left: string, right: string): number {
593 return left < right ? -1 : left > right ? 1 : 0;
594}
595
596function searchRank(agent: AcpRegistryAgent, query: string): number | undefined {
597 const normalized = query.trim().toLowerCase();
598 if (normalized.length === 0) return 100;
599
600 const id = agent.id.toLowerCase();
601 const name = agent.name.toLowerCase();
602 const authors = (agent.authors ?? []).join(" ").toLowerCase();
603 const description = agent.description.toLowerCase();
604 const terms = normalized.split(/\s+/u);
605 if (id === normalized || name === normalized) return 0;
606 if (id.startsWith(normalized) || name.startsWith(normalized)) return 10;
607
608 const identityTokens = `${id} ${name}`.split(/[^a-z0-9]+/u).filter(Boolean);
609 if (terms.every((term) => identityTokens.some((token) => token.startsWith(term)))) return 20;
610 if (terms.every((term) => id.includes(term) || name.includes(term))) return 30;
611 if (terms.every((term) => authors.includes(term))) return 40;
612 if (terms.every((term) => `${id} ${name} ${authors} ${description}`.includes(term))) return 50;
613 return undefined;
614}
615
616export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(function* (
617 input: AcpRegistryCatalogOptions,
618): Effect.fn.Return<
619 AcpRegistryCatalog["Service"],
620 never,
621 | ChildProcessSpawner.ChildProcessSpawner
622 | Crypto.Crypto
623 | FileSystem.FileSystem
624 | HttpClient.HttpClient
625 | Path.Path
626 | ProviderHost.ProviderHost
627> {
628 const crypto = yield* Crypto.Crypto;
629 const sha256Hex = (data: Uint8Array) =>
630 crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie);
631 const fileSystem = yield* FileSystem.FileSystem;
632 const path = yield* Path.Path;
633 const httpClient = yield* HttpClient.HttpClient;
634 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
635 const { settings: hostSettings } = yield* ProviderHost.ProviderHost;
636 const platform = yield* HostProcessPlatform;
637 const architecture = yield* HostProcessArchitecture;
638 const hostEnvironment = yield* HostProcessEnvironment;
639 const resolveExecutable = yield* SpawnExecutableResolution;
640 const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture);
641 const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL;
642 const registryDirectory = path.join(input.cacheDir, "acp-registry");
643 const registryCachePath = path.join(registryDirectory, "registry.json");
644 const installsDirectory = input.toolsDir;
645 const agentInstallRoot = (agent: AcpRegistryAgent) =>
646 path.join(installsDirectory, agent.id, encodeURIComponent(agent.version));
647 const packageInstallRoot = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") =>
648 path.join(agentInstallRoot(agent), distribution === "npx" ? "npm" : "python");
649 const packageBinDirectory = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") =>
650 distribution === "npx" && platform === "win32"
651 ? packageInstallRoot(agent, distribution)
652 : path.join(packageInstallRoot(agent, distribution), "bin");
653 const packageReceiptsDirectory = path.join(registryDirectory, "package-installs");
654 const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined);
655 const registryRevision = yield* Ref.make(0);
656 const registrySemaphore = yield* Semaphore.make(1);
657 const installSemaphore = yield* Semaphore.make(1);
658 const preparedBinaryReservations = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
659
660 const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* (
661 runner: string,
662 ) {
663 const linkedTarget = yield* fileSystem.readLink(runner).pipe(Effect.option);
664 if (Option.isNone(linkedTarget)) return path.dirname(runner);
665 const target = path.isAbsolute(linkedTarget.value)
666 ? linkedTarget.value
667 : path.resolve(path.dirname(runner), linkedTarget.value);
668 // Some service installs expose npm/uv through a wrapper symlink into the
669 // actual toolchain bin directory. Do not mistake npm's internal
670 // `npm-cli.js` directory for the global command directory.
671 return path.basename(target) === path.basename(runner)
672 ? path.dirname(target)
673 : path.dirname(runner);
674 });
675
676 const reservePreparedBinary = Effect.fn("AcpRegistryCatalog.reservePreparedBinary")(function* (
677 agentId: string,
678 ) {
679 const now = yield* Clock.currentTimeMillis;
680 yield* Ref.update(preparedBinaryReservations, (current) => {
681 const next = new Map(Array.from(current).filter(([, expiresAt]) => expiresAt > now));
682 next.set(agentId, now + PREPARED_BINARY_RESERVATION_MS);
683 return next;
684 });
685 });
686
687 const consumePreparedBinaryReservation = (agentId: string) =>
688 Ref.update(preparedBinaryReservations, (current) => {
689 if (!current.has(agentId)) return current;
690 const next = new Map(current);
691 next.delete(agentId);
692 return next;
693 });
694
695 const hasPreparedBinaryReservation = Effect.fn("AcpRegistryCatalog.hasPreparedBinaryReservation")(
696 function* (agentId: string) {
697 const now = yield* Clock.currentTimeMillis;
698 return yield* Ref.modify(preparedBinaryReservations, (current) => {
699 const expiresAt = current.get(agentId);
700 if (expiresAt === undefined || expiresAt <= now) {
701 if (expiresAt === undefined) return [false, current] as const;
702 const next = new Map(current);
703 next.delete(agentId);
704 return [false, next] as const;
705 }
706 return [true, current] as const;
707 });
708 },
709 );
710
711 const assertHttpsUrl = Effect.fn("AcpRegistryCatalog.assertHttpsUrl")(function* (
712 value: string,
713 detail: string,
714 ) {
715 const valid = yield* decodeHttpsUrl(value).pipe(Effect.option);
716 if (Option.isNone(valid)) {
717 return yield* new AcpRegistryError({ reason: "registry_unavailable", detail });
718 }
719 return value;
720 });
721
722 const decodeRegistryText = Effect.fn("AcpRegistryCatalog.decodeRegistryText")(function* (
723 text: string,
724 ) {
725 const decoded = yield* decodeJson(text).pipe(
726 Effect.mapError(
727 (cause) =>
728 new AcpRegistryError({
729 reason: "registry_unavailable",
730 detail: "ACP Registry returned invalid JSON.",
731 cause,
732 }),
733 ),
734 );
735 const envelope = yield* decodeRegistryIndexEnvelope(decoded).pipe(
736 Effect.mapError(
737 (cause) =>
738 new AcpRegistryError({
739 reason: "registry_unavailable",
740 detail: "ACP Registry returned an invalid index.",
741 cause,
742 }),
743 ),
744 );
745 const agents = envelope.agents.flatMap((candidate) => {
746 const decodedAgent = decodeRegistryAgent(candidate);
747 return Option.isSome(decodedAgent) ? [decodedAgent.value] : [];
748 });
749 const discarded = envelope.agents.length - agents.length;
750 if (discarded > 0) {
751 yield* Effect.logWarning("ignored invalid ACP Registry entries", { discarded });
752 }
753 return { version: envelope.version, agents } satisfies AcpRegistryIndex;
754 });
755
756 const readCachedRegistry = fileSystem
757 .readFileString(registryCachePath)
758 .pipe(Effect.flatMap(decodeRegistryText), Effect.option);
759
760 const writeRegistryCache = (text: string) =>
761 crypto.randomUUIDv4.pipe(
762 Effect.flatMap((id) => {
763 const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`;
764 return fileSystem
765 .makeDirectory(registryDirectory, { recursive: true })
766 .pipe(
767 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),
768 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),
769 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),
770 );
771 }),
772 Effect.ignore,
773 );
774
775 const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () {
776 yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS.");
777 const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe(
778 Effect.flatMap(HttpClientResponse.filterStatusOk),
779 Effect.mapError(
780 (cause) =>
781 new AcpRegistryError({
782 reason: "registry_unavailable",
783 detail: `Could not fetch ACP Registry index from ${registryUrl}.`,
784 cause,
785 }),
786 ),
787 Effect.timeoutOrElse({
788 duration: REGISTRY_REQUEST_TIMEOUT,
789 orElse: () =>
790 Effect.fail(
791 new AcpRegistryError({
792 reason: "registry_unavailable",
793 detail: "Timed out fetching ACP Registry index.",
794 }),
795 ),
796 }),
797 );
798 const collected = yield* collectUint8StreamText({
799 stream: response.stream,
800 maxBytes: MAX_REGISTRY_BYTES + 1,
801 }).pipe(
802 Effect.mapError(
803 (cause) =>
804 new AcpRegistryError({
805 reason: "registry_unavailable",
806 detail: "Could not read ACP Registry response body.",
807 cause,
808 }),
809 ),
810 Effect.timeoutOrElse({
811 duration: REGISTRY_REQUEST_TIMEOUT,
812 orElse: () =>
813 Effect.fail(
814 new AcpRegistryError({
815 reason: "registry_unavailable",
816 detail: "Timed out reading ACP Registry response body.",
817 }),
818 ),
819 }),
820 );
821 if (collected.truncated || collected.bytes > MAX_REGISTRY_BYTES) {
822 return yield* new AcpRegistryError({
823 reason: "registry_unavailable",
824 detail: `ACP Registry index exceeds ${MAX_REGISTRY_BYTES} bytes.`,
825 });
826 }
827 if (collected.invalidUtf8) {
828 return yield* new AcpRegistryError({
829 reason: "registry_unavailable",
830 detail: "ACP Registry index is not valid UTF-8.",
831 });
832 }
833 const registry = yield* decodeRegistryText(collected.text);
834 yield* writeRegistryCache(collected.text);
835 return registry;
836 });
837
838 const refreshRegistry = Effect.fn("AcpRegistryCatalog.refreshRegistry")(function* () {
839 const observedRevision = yield* Ref.get(registryRevision);
840 return yield* registrySemaphore.withPermits(1)(
841 Effect.gen(function* () {
842 const currentRevision = yield* Ref.get(registryRevision);
843 const current = yield* Ref.get(registryRef);
844 if (current !== undefined && currentRevision !== observedRevision) return current;
845
846 const registry = yield* fetchRegistry().pipe(
847 Effect.catch((networkError) =>
848 readCachedRegistry.pipe(
849 Effect.flatMap(
850 Option.match({
851 onNone: () => Effect.fail(networkError),
852 onSome: Effect.succeed,
853 }),
854 ),
855 ),
856 ),
857 );
858 yield* Ref.set(registryRef, registry);
859 yield* Ref.update(registryRevision, (revision) => revision + 1);
860 return registry;
861 }),
862 );
863 });
864
865 const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () {
866 const current = yield* Ref.get(registryRef);
867 if (current !== undefined) return current;
868
869 const cached = yield* readCachedRegistry;
870 if (Option.isNone(cached)) {
871 return yield* new AcpRegistryError({
872 reason: "registry_unavailable",
873 detail: "No valid cached ACP Registry index is available.",
874 });
875 }
876 yield* Ref.set(registryRef, cached.value);
877 return cached.value;
878 });
879
880 const loadRegistry = Effect.fn("AcpRegistryCatalog.loadRegistry")(function* () {
881 return yield* loadCachedRegistry().pipe(Effect.catch(() => refreshRegistry()));
882 });
883
884 const runCommand = Effect.fn("AcpRegistryCatalog.runCommand")(function* (
885 command: string,
886 args: ReadonlyArray<string>,
887 options: {
888 readonly cwd?: string;
889 readonly env?: NodeJS.ProcessEnv;
890 readonly timeout?: Duration.Input;
891 readonly truncatedOutputReason?: AcpRegistryErrorReason;
892 } = {},
893 ) {
894 const collect = Effect.gen(function* () {
895 const resolved = yield* resolveSpawnCommand(
896 command,
897 args,
898 options.env === undefined ? {} : { env: options.env },
899 );
900 const child = yield* spawner.spawn(
901 ChildProcess.make(resolved.command, resolved.args, {
902 ...(options.cwd ? { cwd: options.cwd } : {}),
903 ...(options.env === undefined ? {} : { env: options.env }),
904 shell: resolved.shell,
905 }),
906 );
907 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
908 const [stdout, stderr, exitCode] = yield* Effect.all(
909 [
910 collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }),
911 collectUint8StreamText({ stream: child.stderr, maxBytes: 1024 * 1024 }),
912 child.exitCode,
913 ],
914 { concurrency: "unbounded" },
915 );
916 if (Number(exitCode) !== 0) {
917 return yield* new AcpRegistryError({
918 reason: "install_failed",
919 detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`,
920 });
921 }
922 // Archive listings feed validateArchiveEntries; a truncated listing would let
923 // unvalidated entries past the traversal check, so oversized output is fatal.
924 if (stdout.truncated) {
925 return yield* new AcpRegistryError({
926 reason: options.truncatedOutputReason ?? "archive_invalid",
927 detail: `ACP Registry install command '${command}' produced more output than expected.`,
928 });
929 }
930 return stdout.text;
931 });
932 const scoped = collect.pipe(
933 Effect.scoped,
934 Effect.mapError((cause) =>
935 isAcpRegistryError(cause)
936 ? cause
937 : new AcpRegistryError({
938 reason: "install_failed",
939 detail: `Could not run ACP Registry install command '${command}'.`,
940 cause,
941 }),
942 ),
943 );
944 return yield* options.timeout === undefined
945 ? scoped
946 : scoped.pipe(
947 Effect.timeoutOrElse({
948 duration: options.timeout,
949 orElse: () =>
950 Effect.fail(
951 new AcpRegistryError({
952 reason: "install_failed",
953 detail: `Timed out running ACP Registry install command '${command}'.`,
954 }),
955 ),
956 }),
957 );
958 });
959
960 const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) =>
961 sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe(
962 Effect.map((digest) =>
963 path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`),
964 ),
965 );
966
967 const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* (
968 agent: AcpRegistryAgent,
969 distribution: "npx" | "uvx",
970 packageSpec: string,
971 managerPath: string,
972 ) {
973 const receipt = yield* fileSystem
974 .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath))
975 .pipe(
976 Effect.map(decodePackageInstallReceipt),
977 Effect.orElseSucceed(() => Option.none()),
978 );
979 if (
980 Option.isNone(receipt) ||
981 receipt.value.agentId !== agent.id ||
982 receipt.value.agentVersion !== agent.version ||
983 receipt.value.distribution !== distribution ||
984 receipt.value.packageSpec !== packageSpec ||
985 receipt.value.managerPath !== managerPath ||
986 receipt.value.binDirectory !== packageBinDirectory(agent, distribution) ||
987 path.dirname(receipt.value.executablePath) !== receipt.value.binDirectory
988 ) {
989 return Option.none<AcpRegistryPackageInstallReceipt>();
990 }
991 const executableExists = yield* fileSystem
992 .exists(receipt.value.executablePath)
993 .pipe(Effect.orElseSucceed(() => false));
994 if (!executableExists) return Option.none<AcpRegistryPackageInstallReceipt>();
995
996 if (distribution === "npx") {
997 const packageIdentity = parseNpxPackageSpec(packageSpec);
998 if (receipt.value.packageRoot === undefined || receipt.value.packageVersion === undefined) {
999 return Option.none<AcpRegistryPackageInstallReceipt>();
1000 }
1001 const manifest = yield* fileSystem
1002 .readFileString(path.join(receipt.value.packageRoot, "package.json"))
1003 .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option);
1004 if (
1005 Option.isNone(manifest) ||
1006 manifest.value.name !== packageIdentity.name ||
1007 manifest.value.version !== packageIdentity.version ||
1008 receipt.value.packageVersion !== packageIdentity.version
1009 ) {
1010 return Option.none<AcpRegistryPackageInstallReceipt>();
1011 }
1012 }
1013 return receipt;
1014 });
1015
1016 const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")(
1017 function* (receipt: AcpRegistryPackageInstallReceipt) {
1018 const receiptPath = yield* packageReceiptPath(
1019 receipt.agentId,
1020 receipt.distribution,
1021 receipt.managerPath,
1022 );
1023 const temporaryPath = `${receiptPath}.${process.pid}.tmp`;
1024 yield* fileSystem.makeDirectory(packageReceiptsDirectory, { recursive: true });
1025 yield* fileSystem.remove(temporaryPath, { force: true });
1026 yield* fileSystem.writeFileString(temporaryPath, `${encodePackageInstallReceipt(receipt)}\n`);
1027 yield* fileSystem.remove(receiptPath, { force: true });
1028 yield* fileSystem.rename(temporaryPath, receiptPath);
1029 },
1030 Effect.mapError((cause) =>
1031 isAcpRegistryError(cause)
1032 ? cause
1033 : new AcpRegistryError({
1034 reason: "install_failed",
1035 detail: "Could not record the globally installed ACP Registry command.",
1036 cause,
1037 }),
1038 ),
1039 );
1040
1041 const parsePackageManagerPath = Effect.fn("AcpRegistryCatalog.parsePackageManagerPath")(
1042 function* (managerPath: string, output: string) {
1043 const lines = output
1044 .split(/\r?\n/u)
1045 .map((line) => line.trim())
1046 .filter((line) => line.length > 0);
1047 if (lines.length !== 1 || !path.isAbsolute(lines[0]!)) {
1048 return yield* new AcpRegistryError({
1049 reason: "install_failed",
1050 detail: `ACP Registry package manager '${managerPath}' returned an invalid global path.`,
1051 });
1052 }
1053 return lines[0]!;
1054 },
1055 );
1056
1057 const npmCommandName = (
1058 agent: AcpRegistryAgent,
1059 packageName: string,
1060 manifest: typeof NpmPackageManifest.Type,
1061 ): string | undefined => {
1062 const packageBaseName = packageName.split("/").pop() ?? packageName;
1063 if (typeof manifest.bin === "string") return packageBaseName;
1064 const entries = Object.entries(manifest.bin).toSorted(([left], [right]) =>
1065 compareText(left, right),
1066 );
1067 if (entries.length === 1) return entries[0]![0];
1068 if (manifest.bin[packageBaseName] !== undefined) return packageBaseName;
1069 if (manifest.bin[agent.id] !== undefined) return agent.id;
1070 return new Set(entries.map(([, commandPath]) => commandPath)).size === 1
1071 ? entries[0]?.[0]
1072 : undefined;
1073 };
1074
1075 const discoverNpmGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverNpmGlobalPackage")(
1076 function* (
1077 agent: AcpRegistryAgent,
1078 packageSpec: string,
1079 managerPath: string,
1080 environment: NodeJS.ProcessEnv,
1081 ) {
1082 const packageIdentity = parseNpxPackageSpec(packageSpec);
1083 const commandOptions = {
1084 env: environment,
1085 timeout: PACKAGE_QUERY_TIMEOUT,
1086 truncatedOutputReason: "install_failed" as const,
1087 } as const;
1088 const globalRoot = yield* runCommand(managerPath, ["root", "--global"], commandOptions).pipe(
1089 Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)),
1090 );
1091 const globalPrefix = yield* runCommand(
1092 managerPath,
1093 ["prefix", "--global"],
1094 commandOptions,
1095 ).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)));
1096 const packageRoot = path.join(globalRoot, ...packageIdentity.name.split("/"));
1097 const manifestPath = path.join(packageRoot, "package.json");
1098 const manifestInfo = yield* fileSystem.stat(manifestPath).pipe(Effect.option);
1099 if (Option.isNone(manifestInfo) || manifestInfo.value.size > MAX_PACKAGE_MANIFEST_BYTES) {
1100 return Option.none<AcpRegistryPackageInstallReceipt>();
1101 }
1102 const manifest = yield* fileSystem
1103 .readFileString(manifestPath)
1104 .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option);
1105 if (
1106 Option.isNone(manifest) ||
1107 manifest.value.name !== packageIdentity.name ||
1108 manifest.value.version !== packageIdentity.version
1109 ) {
1110 return Option.none<AcpRegistryPackageInstallReceipt>();
1111 }
1112 const commandName = npmCommandName(agent, packageIdentity.name, manifest.value);
1113 if (commandName === undefined) return Option.none<AcpRegistryPackageInstallReceipt>();
1114 const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin");
1115 const executablePath = resolveExecutable(
1116 commandName,
1117 platform,
1118 withPreferredPath(environment, binDirectory, platform),
1119 );
1120 return executablePath === undefined
1121 ? Option.none<AcpRegistryPackageInstallReceipt>()
1122 : Option.some<AcpRegistryPackageInstallReceipt>({
1123 agentId: agent.id,
1124 agentVersion: agent.version,
1125 distribution: "npx" as const,
1126 packageSpec,
1127 managerPath,
1128 binDirectory,
1129 executablePath,
1130 packageRoot,
1131 packageVersion: manifest.value.version,
1132 });
1133 },
1134 );
1135
1136 const discoverUvGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverUvGlobalPackage")(
1137 function* (
1138 agent: AcpRegistryAgent,
1139 packageSpec: string,
1140 managerPath: string,
1141 environment: NodeJS.ProcessEnv,
1142 ) {
1143 const packageIdentity = parseUvxPackageSpec(packageSpec);
1144 const installedTools = yield* runCommand(managerPath, ["tool", "list"], {
1145 env: environment,
1146 timeout: PACKAGE_QUERY_TIMEOUT,
1147 truncatedOutputReason: "install_failed",
1148 });
1149 const normalizedPackageName = packageIdentity.name.toLowerCase().replace(/[._-]+/gu, "-");
1150 const exactVersionInstalled = installedTools.split(/\r?\n/u).some((line) => {
1151 const match = /^(\S+) v(\S+)(?:\s|$)/u.exec(line);
1152 return (
1153 match?.[1]?.toLowerCase().replace(/[._-]+/gu, "-") === normalizedPackageName &&
1154 match[2] === packageIdentity.version
1155 );
1156 });
1157 if (!exactVersionInstalled) return Option.none<AcpRegistryPackageInstallReceipt>();
1158 const binDirectory = yield* runCommand(managerPath, ["tool", "dir", "--bin"], {
1159 env: environment,
1160 timeout: PACKAGE_QUERY_TIMEOUT,
1161 truncatedOutputReason: "install_failed",
1162 }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)));
1163 const commandEnvironment = withPreferredPath(environment, binDirectory, platform);
1164 const executablePath = packageCommandCandidates(agent, packageIdentity.name)
1165 .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment))
1166 .find((candidate): candidate is string => candidate !== undefined);
1167 return executablePath === undefined
1168 ? Option.none<AcpRegistryPackageInstallReceipt>()
1169 : Option.some<AcpRegistryPackageInstallReceipt>({
1170 agentId: agent.id,
1171 agentVersion: agent.version,
1172 distribution: "uvx" as const,
1173 packageSpec,
1174 managerPath,
1175 binDirectory,
1176 executablePath,
1177 packageVersion: packageIdentity.version,
1178 });
1179 },
1180 );
1181
1182 const ensureGlobalPackage = Effect.fn("AcpRegistryCatalog.ensureGlobalPackage")(function* (
1183 agent: AcpRegistryAgent,
1184 distribution: "npx" | "uvx",
1185 packageSpec: string,
1186 environment: NodeJS.ProcessEnv,
1187 ) {
1188 const managerName = packageManagerFor(distribution)!;
1189 const managerPath = resolveExecutable(managerName, platform, environment);
1190 if (managerPath === undefined) {
1191 return yield* new AcpRegistryError({
1192 reason: "runner_unavailable",
1193 detail: `ACP Registry agent ${agent.id} requires '${managerName}', but it is not available on this environment's PATH.`,
1194 });
1195 }
1196 const receipt = yield* readPackageReceipt(agent, distribution, packageSpec, managerPath);
1197 if (Option.isSome(receipt) && distribution === "npx") return receipt.value;
1198
1199 const packageRoot = packageInstallRoot(agent, distribution);
1200 yield* fileSystem.makeDirectory(packageRoot, { recursive: true }).pipe(
1201 Effect.mapError(
1202 (cause) =>
1203 new AcpRegistryError({
1204 reason: "install_failed",
1205 detail: `Could not create the managed install directory for ${agent.id}.`,
1206 cause,
1207 }),
1208 ),
1209 );
1210 const packageEnvironment =
1211 distribution === "npx"
1212 ? { ...environment, npm_config_prefix: packageRoot }
1213 : {
1214 ...environment,
1215 UV_TOOL_DIR: packageRoot,
1216 UV_TOOL_BIN_DIR: packageBinDirectory(agent, distribution),
1217 };
1218 if (Option.isSome(receipt)) {
1219 const installed = yield* discoverUvGlobalPackage(
1220 agent,
1221 packageSpec,
1222 managerPath,
1223 packageEnvironment,
1224 );
1225 if (Option.isSome(installed)) return installed.value;
1226 }
1227
1228 const discover =
1229 distribution === "npx"
1230 ? () => discoverNpmGlobalPackage(agent, packageSpec, managerPath, packageEnvironment)
1231 : () => discoverUvGlobalPackage(agent, packageSpec, managerPath, packageEnvironment);
1232 const existing = yield* discover();
1233 if (Option.isSome(existing)) {
1234 yield* writePackageReceipt(existing.value);
1235 return existing.value;
1236 }
1237
1238 const managerDirectory = yield* managerBinDirectory(managerPath);
1239 const installEnvironment = withPreferredPath(packageEnvironment, managerDirectory, platform);
1240 if (distribution === "npx") {
1241 yield* runCommand(managerPath, ["install", "--global", packageSpec], {
1242 env: installEnvironment,
1243 timeout: PACKAGE_INSTALL_TIMEOUT,
1244 truncatedOutputReason: "install_failed",
1245 });
1246 } else {
1247 yield* runCommand(managerPath, ["tool", "install", "--force", packageSpec], {
1248 env: installEnvironment,
1249 timeout: PACKAGE_INSTALL_TIMEOUT,
1250 truncatedOutputReason: "install_failed",
1251 });
1252 }
1253
1254 if (distribution === "npx") {
1255 const installed = yield* discoverNpmGlobalPackage(
1256 agent,
1257 packageSpec,
1258 managerPath,
1259 packageEnvironment,
1260 );
1261 if (Option.isNone(installed)) {
1262 return yield* new AcpRegistryError({
1263 reason: "install_failed",
1264 detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`,
1265 });
1266 }
1267 yield* writePackageReceipt(installed.value);
1268 return installed.value;
1269 }
1270
1271 const installed = yield* discoverUvGlobalPackage(
1272 agent,
1273 packageSpec,
1274 managerPath,
1275 packageEnvironment,
1276 );
1277 if (Option.isNone(installed)) {
1278 return yield* new AcpRegistryError({
1279 reason: "install_failed",
1280 detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`,
1281 });
1282 }
1283 yield* writePackageReceipt(installed.value);
1284 return installed.value;
1285 });
1286
1287 const acquireInstallLock = Effect.fn("AcpRegistryCatalog.acquireInstallLock")(function* (
1288 lockPath: string,
1289 ) {
1290 for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
1291 const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe(
1292 Effect.as(true),
1293 Effect.catch((error) =>
1294 isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error),
1295 ),
1296 );
1297 if (acquired) return;
1298 const now = yield* Clock.currentTimeMillis;
1299 const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
1300 const mtime = Option.flatMap(lockInfo, (info) => info.mtime);
1301 if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) {
1302 yield* fileSystem.remove(lockPath, { force: true });
1303 continue;
1304 }
1305 yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
1306 }
1307 return yield* new AcpRegistryError({
1308 reason: "install_failed",
1309 detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`,
1310 });
1311 });
1312
1313 const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* (
1314 root: string,
1315 executablePath: string,
1316 ) {
1317 const rootRealPath = yield* fileSystem.realPath(root);
1318 const executableRealPath = yield* fileSystem.realPath(executablePath);
1319 const relative = path.relative(rootRealPath, executableRealPath);
1320 if (relative.startsWith("..") || path.isAbsolute(relative)) {
1321 return yield* new AcpRegistryError({
1322 reason: "archive_invalid",
1323 detail: "ACP Registry archive command resolves outside its installation directory.",
1324 });
1325 }
1326 const info = yield* fileSystem.stat(executableRealPath);
1327 const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0;
1328 if (info.type !== "File" || !hasExecutableMode) {
1329 return yield* new AcpRegistryError({
1330 reason: "archive_invalid",
1331 detail: "ACP Registry archive command is not a regular executable file.",
1332 });
1333 }
1334 return executableRealPath;
1335 });
1336
1337 const binaryPaths = (agent: AcpRegistryAgent, target: typeof AcpRegistryBinaryTarget.Type) => {
1338 const commandSegments = normalizeRegistryCommandPath(target.cmd);
1339 if (platformTarget === undefined || commandSegments === undefined) return undefined;
1340 const installRoot = path.join(agentInstallRoot(agent), platformTarget);
1341 return {
1342 commandSegments,
1343 installRoot,
1344 executablePath: path.join(installRoot, ...commandSegments),
1345 };
1346 };
1347
1348 const installBinary = Effect.fn("AcpRegistryCatalog.installBinary")(function* (
1349 agent: AcpRegistryAgent,
1350 target: typeof AcpRegistryBinaryTarget.Type,
1351 ) {
1352 if (platformTarget === undefined) {
1353 return yield* new AcpRegistryError({
1354 reason: "unsupported_platform",
1355 detail: `ACP Registry does not support platform ${platform}-${architecture}.`,
1356 });
1357 }
1358 const paths = binaryPaths(agent, target);
1359 if (paths === undefined) {
1360 return yield* new AcpRegistryError({
1361 reason: "archive_invalid",
1362 detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`,
1363 });
1364 }
1365 const { commandSegments, installRoot, executablePath } = paths;
1366 if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
1367 return yield* assertExecutableInRoot(installRoot, executablePath).pipe(
1368 Effect.mapError((cause) =>
1369 isAcpRegistryError(cause)
1370 ? cause
1371 : new AcpRegistryError({
1372 reason: "install_failed",
1373 detail: `Could not validate cached ACP Registry agent ${agent.id}.`,
1374 cause,
1375 }),
1376 ),
1377 );
1378 }
1379
1380 yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe(
1381 Effect.mapError(
1382 (cause) =>
1383 new AcpRegistryError({
1384 reason: "install_failed",
1385 detail: `Could not create ACP Registry cache for ${agent.id}.`,
1386 cause,
1387 }),
1388 ),
1389 );
1390 const lockPath = `${installRoot}.lock`;
1391 yield* acquireInstallLock(lockPath).pipe(
1392 Effect.mapError((cause) =>
1393 isAcpRegistryError(cause)
1394 ? cause
1395 : new AcpRegistryError({
1396 reason: "install_failed",
1397 detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`,
1398 cause,
1399 }),
1400 ),
1401 );
1402
1403 return yield* Effect.gen(function* () {
1404 if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) {
1405 return yield* assertExecutableInRoot(installRoot, executablePath);
1406 }
1407 const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({
1408 directory: path.dirname(installRoot),
1409 prefix: `.${agent.id}-install-`,
1410 });
1411 const extractionRoot = path.join(temporaryRoot, "extracted");
1412 yield* fileSystem.makeDirectory(extractionRoot, { recursive: true });
1413 const kind = archiveKind(target.archive);
1414 const archivePath = path.join(temporaryRoot, archiveFileName(kind));
1415 const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe(
1416 Effect.flatMap(HttpClientResponse.filterStatusOk),
1417 Effect.mapError(
1418 (cause) =>
1419 new AcpRegistryError({
1420 reason: "download_failed",
1421 detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`,
1422 cause,
1423 }),
1424 ),
1425 Effect.timeoutOrElse({
1426 duration: ARCHIVE_REQUEST_TIMEOUT,
1427 orElse: () =>
1428 Effect.fail(
1429 new AcpRegistryError({
1430 reason: "download_failed",
1431 detail: `Timed out downloading ACP Registry agent ${agent.id} ${agent.version}.`,
1432 }),
1433 ),
1434 }),
1435 );
1436 // Hash while streaming: archives can be up to MAX_ARCHIVE_BYTES, and
1437 // Effect's Crypto only digests a whole buffer.
1438 const hash = sha256.create();
1439 let downloadedBytes = 0;
1440 yield* response.stream.pipe(
1441 Stream.tap((chunk) => {
1442 if (downloadedBytes + chunk.byteLength > MAX_ARCHIVE_BYTES) {
1443 return Effect.fail(
1444 new AcpRegistryError({
1445 reason: "archive_invalid",
1446 detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`,
1447 }),
1448 );
1449 }
1450 downloadedBytes += chunk.byteLength;
1451 hash.update(chunk);
1452 return Effect.void;
1453 }),
1454 Stream.run(fileSystem.sink(archivePath)),
1455 Effect.mapError((cause) =>
1456 isAcpRegistryError(cause)
1457 ? cause
1458 : new AcpRegistryError({
1459 reason: "download_failed",
1460 detail: `Could not save ACP Registry agent ${agent.id} ${agent.version}.`,
1461 cause,
1462 }),
1463 ),
1464 Effect.timeoutOrElse({
1465 duration: ARCHIVE_REQUEST_TIMEOUT,
1466 orElse: () =>
1467 Effect.fail(
1468 new AcpRegistryError({
1469 reason: "download_failed",
1470 detail: `Timed out reading ACP Registry agent ${agent.id} download.`,
1471 }),
1472 ),
1473 }),
1474 );
1475 if (target.sha256 !== undefined) {
1476 const actual = Hex.encode(hash.digest());
1477 if (actual !== target.sha256.toLowerCase()) {
1478 return yield* new AcpRegistryError({
1479 reason: "checksum_mismatch",
1480 detail: `ACP Registry agent ${agent.id} download did not match its declared SHA-256.`,
1481 });
1482 }
1483 }
1484
1485 if (kind === "raw") {
1486 const targetPath = path.join(extractionRoot, ...commandSegments);
1487 yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true });
1488 yield* fileSystem.rename(archivePath, targetPath);
1489 } else {
1490 const listArgs =
1491 kind === "tar_gz"
1492 ? ["-tzf", archivePath]
1493 : kind === "tar_bz2"
1494 ? ["-tjf", archivePath]
1495 : platform === "win32"
1496 ? ["-tf", archivePath]
1497 : undefined;
1498 const entries =
1499 listArgs === undefined
1500 ? yield* runCommand("unzip", ["-Z1", archivePath])
1501 : yield* runCommand("tar", listArgs);
1502 if (!validateArchiveEntries(entries)) {
1503 return yield* new AcpRegistryError({
1504 reason: "archive_invalid",
1505 detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`,
1506 });
1507 }
1508 if (kind === "zip" && platform !== "win32") {
1509 yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]);
1510 } else {
1511 const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf";
1512 yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]);
1513 }
1514 }
1515
1516 const stagedExecutable = path.join(extractionRoot, ...commandSegments);
1517 if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) {
1518 return yield* new AcpRegistryError({
1519 reason: "archive_invalid",
1520 detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`,
1521 });
1522 }
1523 if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755);
1524 yield* assertExecutableInRoot(extractionRoot, stagedExecutable);
1525 yield* fileSystem.remove(installRoot, { recursive: true, force: true });
1526 yield* fileSystem.rename(extractionRoot, installRoot);
1527 return yield* assertExecutableInRoot(installRoot, executablePath);
1528 }).pipe(
1529 Effect.scoped,
1530 Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)),
1531 Effect.mapError((cause) =>
1532 isAcpRegistryError(cause)
1533 ? cause
1534 : new AcpRegistryError({
1535 reason: "install_failed",
1536 detail: `Could not install ACP Registry agent ${agent.id}.`,
1537 cause,
1538 }),
1539 ),
1540 );
1541 });
1542
1543 const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* (
1544 registry: AcpRegistryIndex,
1545 agentId: string,
1546 ) {
1547 const agent = registry.agents.find((candidate) => candidate.id === agentId.trim());
1548 if (agent === undefined) {
1549 return yield* new AcpRegistryError({
1550 reason: "agent_not_found",
1551 detail: `ACP Registry does not contain agent '${agentId.trim()}'.`,
1552 });
1553 }
1554 return agent;
1555 });
1556
1557 const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* (
1558 agent: AcpRegistryAgent,
1559 preference: AcpRegistryDistributionPreference,
1560 ) {
1561 const distribution = resolveAcpRegistryDistribution({
1562 agent,
1563 preference,
1564 platformTarget,
1565 });
1566 if (distribution === undefined) {
1567 return yield* new AcpRegistryError({
1568 reason: platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution",
1569 detail: `ACP Registry agent ${agent.id} has no ${preference === "auto" ? "compatible" : preference} distribution for ${platform}-${architecture}.`,
1570 });
1571 }
1572 return distribution;
1573 });
1574
1575 const search: AcpRegistryCatalog["Service"]["search"] = (input) =>
1576 Effect.gen(function* () {
1577 const registry = yield* refreshRegistry();
1578 const ranked = registry.agents.flatMap((agent) => {
1579 const distribution = resolveAcpRegistryDistribution({
1580 agent,
1581 preference: "auto",
1582 platformTarget,
1583 });
1584 const rank = searchRank(agent, input.query);
1585 const packageManager =
1586 distribution === undefined ? undefined : packageManagerFor(distribution.kind);
1587 const packageManagerAvailable =
1588 packageManager === undefined ||
1589 resolveExecutable(packageManager, platform, hostEnvironment) !== undefined;
1590 return distribution === undefined || rank === undefined || !packageManagerAvailable
1591 ? []
1592 : [{ agent, distribution, rank }];
1593 });
1594 ranked.sort(
1595 (left, right) =>
1596 left.rank - right.rank ||
1597 compareText(left.agent.name.toLowerCase(), right.agent.name.toLowerCase()) ||
1598 compareText(left.agent.id, right.agent.id),
1599 );
1600 return {
1601 agents: ranked.slice(0, MAX_SEARCH_RESULTS).map(({ agent, distribution }) => ({
1602 id: agent.id,
1603 name: agent.name,
1604 version: agent.version,
1605 description: agent.description,
1606 authors: agent.authors ?? [],
1607 license: agent.license ?? null,
1608 website: agent.website ?? null,
1609 repository: agent.repository ?? null,
1610 icon: agent.icon ?? null,
1611 distribution: distribution.kind,
1612 integrity:
1613 distribution.kind === "binary" && distribution.binaryTarget?.sha256 !== undefined
1614 ? "sha256"
1615 : "registry",
1616 })),
1617 } satisfies AcpRegistrySearchResult;
1618 });
1619
1620 const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) =>
1621 Effect.gen(function* () {
1622 const registry = yield* refreshRegistry();
1623 const agent = yield* findAgent(registry, input.agentId);
1624 const distribution = yield* compatibleDistribution(agent, "auto");
1625 if (distribution.kind === "binary") {
1626 // A PATH executable can be a different version with different ACP
1627 // capabilities. Only an explicit command override opts into that copy.
1628 yield* installSemaphore.withPermits(1)(
1629 installBinary(agent, distribution.binaryTarget!).pipe(
1630 Effect.tap(() => reservePreparedBinary(agent.id)),
1631 ),
1632 );
1633 } else {
1634 yield* installSemaphore.withPermits(1)(
1635 ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment),
1636 );
1637 }
1638 return {
1639 agentId: agent.id,
1640 version: agent.version,
1641 distribution: distribution.kind,
1642 prepared: true,
1643 } satisfies AcpRegistryPrepareResult;
1644 });
1645
1646 const validateLocalExecutable = (command: string) =>
1647 platform === "win32" && /\.(?:cmd|bat)$/iu.test(command)
1648 ? Effect.fail(
1649 new AcpRegistryError({
1650 reason: "runner_unavailable",
1651 detail:
1652 "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.",
1653 }),
1654 )
1655 : Effect.void;
1656
1657 const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) =>
1658 Effect.gen(function* () {
1659 if (settings.source === "local") {
1660 const command = settings.commandPath.trim();
1661 if (!command) return { status: "unconfigured" } as const;
1662 const executable = resolveExecutable(command, platform, environment ?? hostEnvironment);
1663 if (executable !== undefined) yield* validateLocalExecutable(executable);
1664 return executable === undefined
1665 ? ({
1666 status: "missing_runner",
1667 version: null,
1668 distribution: "local",
1669 } as const)
1670 : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const);
1671 }
1672 const agentId = settings.agentId.trim();
1673 if (agentId.length === 0) return { status: "unconfigured" } as const;
1674 const registry = yield* loadCachedRegistry();
1675 const agent = registry.agents.find((candidate) => candidate.id === agentId);
1676 if (agent === undefined) return { status: "not_found", agentId } as const;
1677 const documentationUrl = agent.website ?? agent.repository;
1678 const distribution = resolveAcpRegistryDistribution({
1679 agent,
1680 preference: settings.distribution,
1681 platformTarget,
1682 });
1683 if (distribution === undefined) {
1684 return { status: "unsupported", agentId, version: agent.version } as const;
1685 }
1686
1687 const commandOverride = settings.commandPath.trim();
1688 if (commandOverride.length > 0) {
1689 const available =
1690 resolveExecutable(commandOverride, platform, environment ?? hostEnvironment) !==
1691 undefined;
1692 return available
1693 ? ({
1694 status: "ready",
1695 agentId,
1696 // The catalog version does not describe an overridden executable.
1697 version: null,
1698 distribution: distribution.kind,
1699 ...(documentationUrl ? { documentationUrl } : {}),
1700 } as const)
1701 : ({
1702 status: "missing_runner",
1703 agentId,
1704 version: agent.version,
1705 distribution: distribution.kind,
1706 runner: commandOverride,
1707 } as const);
1708 }
1709
1710 if (distribution.kind === "binary") {
1711 return yield* installSemaphore.withPermits(1)(
1712 Effect.gen(function* () {
1713 const paths = binaryPaths(agent, distribution.binaryTarget!);
1714 if (paths === undefined) {
1715 return { status: "unsupported", agentId, version: agent.version } as const;
1716 }
1717 yield* consumePreparedBinaryReservation(agent.id);
1718 const exists = yield* fileSystem
1719 .exists(paths.executablePath)
1720 .pipe(Effect.orElseSucceed(() => false));
1721 if (!exists) {
1722 return {
1723 status: "unprepared",
1724 agentId,
1725 version: agent.version,
1726 distribution: "binary",
1727 } as const;
1728 }
1729 yield* assertExecutableInRoot(paths.installRoot, paths.executablePath).pipe(
1730 Effect.mapError((cause) =>
1731 isAcpRegistryError(cause)
1732 ? cause
1733 : new AcpRegistryError({
1734 reason: "install_failed",
1735 detail: `Could not validate cached ACP Registry agent ${agent.id}.`,
1736 cause,
1737 }),
1738 ),
1739 );
1740 return {
1741 status: "ready",
1742 agentId,
1743 version: agent.version,
1744 distribution: "binary",
1745 ...(documentationUrl ? { documentationUrl } : {}),
1746 } as const;
1747 }),
1748 );
1749 }
1750
1751 const runner = packageManagerFor(distribution.kind)!;
1752 const available =
1753 resolveExecutable(runner, platform, environment ?? hostEnvironment) !== undefined;
1754 return available
1755 ? ({
1756 status: "ready",
1757 agentId,
1758 version: agent.version,
1759 distribution: distribution.kind,
1760 ...(documentationUrl ? { documentationUrl } : {}),
1761 } as const)
1762 : ({
1763 status: "missing_runner",
1764 agentId,
1765 version: agent.version,
1766 distribution: distribution.kind,
1767 runner,
1768 } as const);
1769 });
1770
1771 const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) =>
1772 Effect.gen(function* () {
1773 if (settings.source === "local") {
1774 const executable = settings.commandPath.trim();
1775 if (!executable) {
1776 return yield* new AcpRegistryError({
1777 reason: "agent_not_configured",
1778 detail: "Local ACP provider requires an executable.",
1779 });
1780 }
1781 const env = environment ?? hostEnvironment;
1782 const command = resolveExecutable(executable, platform, env);
1783 if (command === undefined) {
1784 return yield* new AcpRegistryError({
1785 reason: "runner_unavailable",
1786 detail: "Local ACP executable is not available on this environment's PATH.",
1787 });
1788 }
1789 yield* validateLocalExecutable(command);
1790 return {
1791 distribution: "local",
1792 spawn: { command, args: settings.commandArgs, cwd, env, shell: false },
1793 } satisfies ResolvedAcpRegistryAgent;
1794 }
1795 const agentId = settings.agentId.trim();
1796 if (agentId.length === 0) {
1797 return yield* new AcpRegistryError({
1798 reason: "agent_not_configured",
1799 detail: "ACP Registry provider requires a registry agent ID.",
1800 });
1801 }
1802 const registry = yield* loadRegistry();
1803 const agent = yield* findAgent(registry, agentId);
1804 const distribution = yield* compatibleDistribution(agent, settings.distribution);
1805
1806 let command: string;
1807 let args: ReadonlyArray<string>;
1808 let commandBinDirectory: string | undefined;
1809 const effectiveEnvironment = environment ?? hostEnvironment;
1810 const commandOverride = settings.commandPath.trim();
1811 if (commandOverride.length > 0) {
1812 const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment);
1813 if (resolvedOverride === undefined) {
1814 return yield* new AcpRegistryError({
1815 reason: "runner_unavailable",
1816 detail: `ACP Registry agent ${agent.id} requires '${commandOverride}', but it is not available on this provider instance's PATH.`,
1817 });
1818 }
1819 command = resolvedOverride;
1820 args = distribution.args;
1821 } else if (distribution.kind === "npx" || distribution.kind === "uvx") {
1822 const installed = yield* installSemaphore.withPermits(1)(
1823 ensureGlobalPackage(
1824 agent,
1825 distribution.kind,
1826 distribution.packageName!,
1827 effectiveEnvironment,
1828 ),
1829 );
1830 command = installed.executablePath;
1831 args = distribution.args;
1832 commandBinDirectory = installed.binDirectory;
1833 } else {
1834 const target = distribution.binaryTarget!;
1835 command = yield* installSemaphore.withPermits(1)(
1836 installBinary(agent, target).pipe(
1837 Effect.tap(() => consumePreparedBinaryReservation(agent.id)),
1838 ),
1839 );
1840 args = distribution.args;
1841 }
1842
1843 const baseSpawnEnvironment = {
1844 ...effectiveEnvironment,
1845 ...distribution.env,
1846 };
1847 const spawnEnvironment =
1848 commandBinDirectory === undefined
1849 ? baseSpawnEnvironment
1850 : withPreferredPath(baseSpawnEnvironment, commandBinDirectory, platform);
1851
1852 return {
1853 agent,
1854 distribution: distribution.kind,
1855 spawn: {
1856 command,
1857 args,
1858 cwd,
1859 env: spawnEnvironment,
1860 },
1861 } satisfies ResolvedAcpRegistryAgent;
1862 });
1863
1864 const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) =>
1865 hostSettings
1866 .withSnapshot((settings) =>
1867 installSemaphore.withPermits(1)(
1868 Effect.gen(function* () {
1869 const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe(
1870 Effect.mapError(
1871 (cause) =>
1872 new AcpRegistryError({
1873 reason: "install_failed",
1874 detail: "ACP Registry managed binary uninstall received an invalid agent ID.",
1875 cause,
1876 }),
1877 ),
1878 );
1879 const isReferenced = Object.values(settings.providerInstances).some((instance) => {
1880 if (
1881 instance.driver !== "acpRegistry" ||
1882 instance.config === null ||
1883 typeof instance.config !== "object"
1884 )
1885 return false;
1886 const config = instance.config as Record<string, unknown>;
1887 return (
1888 config.source !== "local" &&
1889 typeof config.agentId === "string" &&
1890 config.agentId.trim() === safeAgentId
1891 );
1892 });
1893 if (isReferenced) {
1894 yield* consumePreparedBinaryReservation(safeAgentId);
1895 return { agentId: safeAgentId, removed: false };
1896 }
1897 if (yield* hasPreparedBinaryReservation(safeAgentId)) {
1898 return { agentId: safeAgentId, removed: false };
1899 }
1900 const agentRoot = path.join(installsDirectory, safeAgentId);
1901 const existed = yield* fileSystem.exists(agentRoot).pipe(
1902 Effect.mapError(
1903 (cause) =>
1904 new AcpRegistryError({
1905 reason: "install_failed",
1906 detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`,
1907 cause,
1908 }),
1909 ),
1910 );
1911 if (!existed) return { agentId: safeAgentId, removed: false };
1912
1913 let removed = false;
1914 yield* Effect.gen(function* () {
1915 for (const version of yield* fileSystem.readDirectory(agentRoot)) {
1916 const versionRoot = path.join(agentRoot, version);
1917 for (const entry of yield* fileSystem.readDirectory(versionRoot)) {
1918 if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue;
1919 yield* fileSystem.remove(path.join(versionRoot, entry), {
1920 recursive: true,
1921 force: true,
1922 });
1923 removed = true;
1924 }
1925 if ((yield* fileSystem.readDirectory(versionRoot)).length === 0)
1926 yield* fileSystem.remove(versionRoot, { recursive: true });
1927 }
1928 if ((yield* fileSystem.readDirectory(agentRoot)).length === 0)
1929 yield* fileSystem.remove(agentRoot, { recursive: true });
1930 }).pipe(
1931 Effect.mapError(
1932 (cause) =>
1933 new AcpRegistryError({
1934 reason: "install_failed",
1935 detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`,
1936 cause,
1937 }),
1938 ),
1939 );
1940 return { agentId: safeAgentId, removed };
1941 }),
1942 ),
1943 )
1944 .pipe(
1945 Effect.catchTags({
1946 ServerSettingsError: (cause) =>
1947 new AcpRegistryError({
1948 reason: "install_failed",
1949 detail:
1950 "Could not read provider settings while checking managed ACP binary references.",
1951 cause,
1952 }),
1953 }),
1954 );
1955
1956 return AcpRegistryCatalog.of({
1957 search,
1958 prepare,
1959 inspect,
1960 resolve,
1961 uninstallManagedBinary,
1962 });
1963});
packages/provider-acp-registry/src/server/adapter.ts created+314
......@@ -0,0 +1,314 @@
1import {
2 normalizeDevinSessionUpdate,
3 normalizeDevinToolCall,
4 extractDevinSubagentUpdate,
5} from "./devinAcp.ts";
6import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts";
7import { AcpRegistrySettings } from "../settings.ts";
8import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
9import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import type * as Scope from "effect/Scope";
18import { ChildProcessSpawner } from "effect/process";
19import * as EffectAcpErrors from "effect-acp/errors";
20
21import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
22import {
23 normalizeAcpRegistryCommands,
24 normalizeAcpRegistryLiveConfiguration,
25 normalizeAcpRegistryWebUrl,
26} from "./probe.ts";
27import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
28import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
29import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
30import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
31import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
32import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
33import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
34import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
35import {
36 ProviderAdapterDriverCreateError,
37 type ProviderAdapterDriver,
38 type ProviderAdapterDriverCreateInput,
39} from "@t3tools/provider-core/server/adapterDriver";
40import {
41 AcpProviderCapabilitiesV2,
42 makeAcpAdapterV2,
43 type AcpAdapterV2ExtensionContext,
44 type AcpAdapterV2Flavor,
45 type AcpAdapterV2RuntimeInput,
46} from "@t3tools/provider-acp/server/adapter";
47
48export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry");
49export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER);
50
51const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({});
52const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError);
53
54export interface AcpRegistryAdapterV2Options {
55 readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
56 readonly settings: AcpRegistrySettings;
57 readonly environment: NodeJS.ProcessEnv;
58 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
59 readonly selfInvocation: SelfInvocation;
60 readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">;
61 readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"];
62 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
63 readonly makeRuntime?: (
64 input: AcpAdapterV2RuntimeInput,
65 ) => Effect.Effect<
66 AcpSessionRuntime.AcpSessionRuntime["Service"],
67 EffectAcpErrors.AcpError,
68 Crypto.Crypto | Scope.Scope
69 >;
70 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
71}
72
73// ─── Per-agent exceptions ────────────────────────────────────────────────────
74// This adapter serves every ACP registry agent through the plain ACP spec.
75// Agent-specific behavior does not belong here: an agent that needs it gets a
76// dedicated driver (as Grok and Antigravity have). The few exceptions below
77// predate that rule and are small presentation hooks, not permission or tool
78// behavior.
79//
80// Agents changing this file: do NOT add another `agentId === "..."` branch,
81// agent table, or agent-specific hook without explicit approval from the
82// maintainer in the conversation. Propose a dedicated driver instead.
83
84// Mistral Vibe: its application error code for rate limits (not an ACP code).
85const MISTRAL_VIBE_RATE_LIMITED = -31001;
86
87const MistralVibeSessionRetrying = Schema.Struct({
88 sessionId: Schema.String,
89 category: Schema.Literals(["rate_limited", "server_error", "timed_out", "connection", "unknown"]),
90 detail: Schema.String,
91});
92
93/** Mistral Vibe (v2.25.5) reports SDK backoff through this ACP extension. */
94export function registerMistralVibeAcpExtensions(context: AcpAdapterV2ExtensionContext) {
95 return context.runtime.handleExtNotification(
96 "_session/retrying",
97 MistralVibeSessionRetrying,
98 (notice) =>
99 context.reportProviderRetry({
100 sessionId: notice.sessionId,
101 failure: makeProviderFailure({
102 message: notice.detail,
103 class:
104 notice.category === "rate_limited"
105 ? "usage_limit"
106 : notice.category === "unknown"
107 ? "provider_error"
108 : "transport_error",
109 retryable: true,
110 }),
111 }),
112 );
113}
114// ─── End per-agent exceptions (Devin's gates are marked in makeAcpRegistryAdapterV2) ───
115
116export function acpRegistryPromptFailure(agentId: string, cause: unknown) {
117 return makeProviderFailure({
118 cause,
119 ...(isAcpRequestError(cause)
120 ? {
121 message: cause.errorMessage,
122 code: String(cause.code),
123 class:
124 // Per-agent exception: see the note above registerMistralVibeAcpExtensions.
125 agentId === "mistral-vibe" && cause.code === MISTRAL_VIBE_RATE_LIMITED
126 ? ("usage_limit" as const)
127 : ("provider_error" as const),
128 }
129 : { class: "provider_error" as const }),
130 });
131}
132
133function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
134 return (
135 input: AcpAdapterV2RuntimeInput,
136 ): Effect.Effect<
137 AcpSessionRuntime.AcpSessionRuntime["Service"],
138 EffectAcpErrors.AcpError,
139 Crypto.Crypto | Scope.Scope
140 > =>
141 Effect.gen(function* () {
142 const { processEnvironment, ...runtimeInput } = input;
143 const resolved = yield* options.resolver
144 .resolve(options.settings, input.cwd, options.environment)
145 .pipe(
146 Effect.mapError(
147 (cause) =>
148 new EffectAcpErrors.AcpSpawnError({
149 command: options.settings.agentId || ACP_REGISTRY_PROVIDER,
150 cause,
151 }),
152 ),
153 );
154 const context = yield* Layer.build(
155 AcpSessionRuntime.layer({
156 ...runtimeInput,
157 spawn:
158 processEnvironment === undefined
159 ? resolved.spawn
160 : {
161 ...resolved.spawn,
162 env: { ...resolved.spawn.env, ...processEnvironment },
163 },
164 ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),
165 }).pipe(
166 Layer.provide(
167 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner),
168 ),
169 ),
170 );
171 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
172 Effect.provide(context),
173 );
174 });
175}
176
177export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(function* (
178 options: AcpRegistryAdapterV2Options,
179) {
180 const runtimeCoordinator = options.runtimeCoordinator;
181 const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId;
182 const startupKey =
183 options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId;
184 const isDevin = registryAgentId === "devin";
185 const flavor: AcpAdapterV2Flavor = {
186 driver: ACP_REGISTRY_PROVIDER,
187 capabilities: AcpProviderCapabilitiesV2,
188 promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause),
189 // Per-agent exceptions (Mistral Vibe, Devin): see the note above
190 // registerMistralVibeAcpExtensions before adding any more.
191 ...(registryAgentId === "mistral-vibe"
192 ? { registerExtensions: registerMistralVibeAcpExtensions }
193 : {}),
194 ...(isDevin
195 ? {
196 clientCapabilitiesMeta: {
197 "cognition.ai/subagentSupport": true,
198 "cognition.ai/messageGrouping": true,
199 },
200 normalizeSessionUpdate: normalizeDevinSessionUpdate,
201 normalizeToolCall: normalizeDevinToolCall,
202 extractSubagentUpdate: extractDevinSubagentUpdate,
203 }
204 : {}),
205 makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options),
206 ...(runtimeCoordinator === undefined
207 ? {}
208 : {
209 onAvailableCommandsUpdate: (commands) =>
210 runtimeCoordinator.publishAvailableCommands(
211 options.instanceId,
212 normalizeAcpRegistryCommands(commands),
213 ),
214 onSessionConfigurationUpdate: (configOptions, modeState) =>
215 runtimeCoordinator.publishLiveConfiguration(
216 options.instanceId,
217 normalizeAcpRegistryLiveConfiguration(configOptions, modeState),
218 ),
219 onUrlElicitation: ({ elicitationId, url, message }) => {
220 const normalizedUrl = normalizeAcpRegistryWebUrl(url);
221 if (normalizedUrl === undefined || elicitationId.trim().length === 0) {
222 return Effect.succeed(false);
223 }
224 return runtimeCoordinator.requestUrlAuthentication(options.instanceId, {
225 elicitationId: elicitationId.trim().slice(0, 256),
226 url: normalizedUrl,
227 message: message.trim().slice(0, 1_024),
228 });
229 },
230 withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
231 runtimeCoordinator.withForegroundStartup(startupKey, effect),
232 }),
233 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
234 };
235 return yield* makeAcpAdapterV2({
236 instanceId: options.instanceId,
237 flavor,
238 selfInvocation: options.selfInvocation,
239 // Per-agent exception (see the note above registerMistralVibeAcpExtensions):
240 // Devin runs commands through client terminals and has no ask mode over
241 // ACP to fall back on. Every other registry agent runs its own.
242 ...(isDevin
243 ? {
244 clientTerminals: {
245 childProcessSpawner: options.childProcessSpawner,
246 environment: options.environment,
247 shellCommands: true,
248 },
249 }
250 : {}),
251 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
252 });
253});
254
255export type AcpRegistryAdapterV2DriverEnv =
256 | ChildProcessSpawner.ChildProcessSpawner
257 | Crypto.Crypto
258 | FileSystem.FileSystem
259 | AcpRegistrySupport.AcpRegistryCatalog
260 | IdAllocator.IdAllocatorV2
261 | Path.Path
262 | ProviderEventLoggers.ProviderEventLoggers
263 | ProviderHost.ProviderHost;
264
265export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver<
266 AcpRegistrySettings,
267 AcpRegistryAdapterV2DriverEnv
268> = {
269 driverKind: ACP_REGISTRY_PROVIDER,
270 configSchema: AcpRegistrySettings,
271 defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS,
272 create: Effect.fn("AcpRegistryAdapterV2Driver.create")(
273 function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) {
274 const hostEnvironment = yield* HostProcessEnvironment;
275 const selfInvocation = yield* resolveSelfInvocation();
276 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
277 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
278 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
279 const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog;
280 const runtimeCoordinator = yield* Effect.serviceOption(
281 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
282 );
283 return yield* makeAcpRegistryAdapterV2({
284 instanceId: input.instanceId,
285 settings: { ...input.config, enabled: input.enabled },
286 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
287 childProcessSpawner,
288 resolver,
289 ...(Option.isSome(runtimeCoordinator)
290 ? { runtimeCoordinator: runtimeCoordinator.value }
291 : {}),
292 selfInvocation,
293 nativeLogging: (threadId) =>
294 makeNativeLogger({
295 nativeEventLogger: providerEventLoggers.native,
296 provider: ACP_REGISTRY_PROVIDER,
297 threadId,
298 }),
299 });
300 },
301 (effect, input) =>
302 effect.pipe(
303 Effect.mapError(
304 (cause) =>
305 new ProviderAdapterDriverCreateError({
306 driver: ACP_REGISTRY_PROVIDER,
307 instanceId: input.instanceId,
308 detail: "Failed to create ACP Registry adapter.",
309 cause,
310 }),
311 ),
312 ),
313 ),
314};
packages/provider-acp-registry/src/server/auth.test.ts created+364
......@@ -0,0 +1,364 @@
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);
packages/provider-acp-registry/src/server/auth.ts created+370
......@@ -0,0 +1,370 @@
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 { ChildProcessSpawner } from "effect/process";
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});
packages/provider-acp-registry/src/server/authenticationState.test.ts created+100
......@@ -0,0 +1,100 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import { AcpRegistrySettings } from "../settings.ts";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Schema from "effect/Schema";
8
9import { makeAcpRegistryAuthenticationState } from "./authenticationState.ts";
10
11const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" });
12
13it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => {
14 it.effect("restores explicit sign-in across driver recreation and server restart", () =>
15 Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 const cacheDir = yield* fs.makeTempDirectoryScoped();
18 const input = {
19 cacheDir,
20 instanceId: ProviderInstanceId.make("acp_devin"),
21 settings,
22 environment: [],
23 processEnvironment: { HOME: "/home/test" },
24 };
25 const first = yield* makeAcpRegistryAuthenticationState(input);
26 assert.isFalse(yield* first.get);
27 yield* first.set(true);
28 const recreated = yield* makeAcpRegistryAuthenticationState({
29 ...input,
30 settings: { ...settings, customModels: ["new-model"] },
31 });
32 assert.isTrue(yield* recreated.get);
33 // Both logout and an explicit authentication failure revoke confirmation.
34 yield* recreated.set(false);
35 const restarted = yield* makeAcpRegistryAuthenticationState(input);
36 assert.isFalse(yield* restarted.get);
37 }),
38 );
39
40 it.effect(
41 "does not carry confirmation to a different agent, method, executable, or credential environment",
42 () =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const cacheDir = yield* fs.makeTempDirectoryScoped();
46 const input = {
47 cacheDir,
48 instanceId: ProviderInstanceId.make("acp_devin"),
49 settings,
50 environment: [{ name: "DEVIN_TOKEN", value: "test-only-token", sensitive: true }],
51 processEnvironment: { HOME: "/home/test" },
52 };
53 for (const change of [
54 { settings: { ...settings, agentId: "other-agent" } },
55 { settings: { ...settings, authMethodId: "enterprise" } },
56 { settings: { ...settings, commandPath: "/other/devin" } },
57 { environment: [{ name: "DEVIN_TOKEN", value: "different-token", sensitive: true }] },
58 { processEnvironment: { HOME: "/home/other" } },
59 ]) {
60 const signedIn = yield* makeAcpRegistryAuthenticationState(input);
61 yield* signedIn.set(true);
62 const changed = yield* makeAcpRegistryAuthenticationState({ ...input, ...change });
63 assert.isFalse(yield* changed.get);
64 const restoredConfig = yield* makeAcpRegistryAuthenticationState(input);
65 assert.isFalse(yield* restoredConfig.get);
66 }
67 for (const name of yield* fs.readDirectory(cacheDir)) {
68 const contents = yield* fs.readFileString(`${cacheDir}/${name}`);
69 assert.isFalse(contents.includes("test-only-token"));
70 assert.isFalse(contents.includes("different-token"));
71 }
72 }),
73 );
74
75 it.effect("ignores damaged confirmation and keeps another instance independent", () =>
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const cacheDir = yield* fs.makeTempDirectoryScoped();
79 const input = {
80 cacheDir,
81 instanceId: ProviderInstanceId.make("acp_devin"),
82 settings,
83 environment: [],
84 processEnvironment: {},
85 };
86 const first = yield* makeAcpRegistryAuthenticationState(input);
87 yield* first.set(true);
88 const other = yield* makeAcpRegistryAuthenticationState({
89 ...input,
90 instanceId: ProviderInstanceId.make("acp_other"),
91 });
92 assert.isFalse(yield* other.get);
93 for (const name of yield* fs.readDirectory(cacheDir)) {
94 yield* fs.writeFileString(`${cacheDir}/${name}`, "invalid");
95 }
96 const restored = yield* makeAcpRegistryAuthenticationState(input);
97 assert.isFalse(yield* restored.get);
98 }),
99 );
100});
packages/provider-acp-registry/src/server/authenticationState.ts created+82
......@@ -0,0 +1,82 @@
1import type { ProviderInstanceEnvironment, ProviderInstanceId } from "@t3tools/contracts";
2import type { AcpRegistrySettings } from "../settings.ts";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11
12import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
13
14const decodeState = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
16);
17const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
18const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) {
19 const crypto = yield* Crypto.Crypto;
20 const json = yield* encodeJson(value).pipe(Effect.orDie);
21 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie);
22 return Hex.encode(digest);
23});
24
25/** Remember explicit sign-in success, never discovery success or the agent's credentials. */
26export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(
27 function* (input: {
28 readonly cacheDir: string;
29 readonly instanceId: ProviderInstanceId;
30 readonly settings: AcpRegistrySettings;
31 readonly environment: ProviderInstanceEnvironment;
32 readonly processEnvironment: NodeJS.ProcessEnv;
33 }) {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`);
37 // Cosmetic settings and model discovery can rebuild the driver without
38 // changing the account. Credential overrides and profile paths cannot.
39 const binding = yield* hash({
40 ...(input.settings.source === "local"
41 ? { source: "local", commandArgs: input.settings.commandArgs }
42 : {}),
43 agentId: input.settings.agentId,
44 commandPath: input.settings.commandPath,
45 distribution: input.settings.distribution,
46 authMethodId: input.settings.authMethodId,
47 environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)),
48 profiles: [
49 "HOME",
50 "XDG_CONFIG_HOME",
51 "XDG_DATA_HOME",
52 "XDG_STATE_HOME",
53 "APPDATA",
54 "LOCALAPPDATA",
55 ].map((name) => input.processEnvironment[name] ?? null),
56 });
57 const saved = yield* fs.readFileString(filePath).pipe(
58 Effect.flatMap(decodeState),
59 Effect.orElseSucceed(() => undefined),
60 );
61 const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated);
62 const lock = yield* Semaphore.make(1);
63 const persist = (authenticated: boolean) =>
64 writeFileStringAtomically({
65 filePath,
66 contents: JSON.stringify({ binding, authenticated }),
67 }).pipe(
68 Effect.provideService(FileSystem.FileSystem, fs),
69 Effect.provideService(Path.Path, path),
70 Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")),
71 Effect.ignore,
72 );
73 if (saved && saved.binding !== binding) yield* persist(false);
74 return {
75 get: Ref.get(confirmed),
76 set: (authenticated: boolean) =>
77 lock.withPermit(
78 Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))),
79 ),
80 };
81 },
82);
packages/provider-acp-registry/src/server/devinAcp.ts created+57
......@@ -0,0 +1,57 @@
1import type * as AcpSchema from "effect-acp/compat";
2import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
3import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter";
4
5function record(value: unknown): Record<string, unknown> | undefined {
6 return typeof value === "object" && value !== null && !Array.isArray(value)
7 ? (value as Record<string, unknown>)
8 : undefined;
9}
10
11/** Devin's typed extension metadata, captured from its native ACP stream. */
12export function normalizeDevinSessionUpdate(
13 notification: AcpSchema.SessionNotification,
14): AcpSchema.SessionNotification {
15 const update = notification.update;
16 const meta = record("_meta" in update ? update._meta : undefined);
17 const parentAgentId = record(meta?.["cognition.ai/subagent_context"])?.parentAgentId;
18 const routed =
19 typeof parentAgentId === "string" && parentAgentId !== "root"
20 ? { ...notification, sessionId: parentAgentId }
21 : notification;
22 const messageId = meta?.["cognition.ai/streamingMessageId"];
23 if (
24 (update.sessionUpdate === "agent_message_chunk" ||
25 update.sessionUpdate === "agent_thought_chunk") &&
26 typeof messageId === "string"
27 ) {
28 return { ...routed, update: { ...update, messageId } };
29 }
30 return routed;
31}
32
33export function normalizeDevinToolCall(tool: AcpToolCallState): AcpToolCallState {
34 const name = record(tool.data.meta)?.["cognition.ai/inferenceToolName"];
35 return typeof name === "string" && (!tool.title || tool.title === "Tool")
36 ? { ...tool, title: name }
37 : tool;
38}
39
40export function extractDevinSubagentUpdate(
41 tool: AcpToolCallState,
42): AcpAdapterV2SubagentUpdate | undefined {
43 const meta = record(tool.data.meta);
44 const completed = record(meta?.["cognition.ai/subagent_completed"]);
45 const started = record(meta?.["cognition.ai/subagent_started"]);
46 const data = completed ?? started;
47 if (typeof data?.agentId !== "string") return undefined;
48 return {
49 nativeTaskId: data.agentId,
50 childSessionId: data.agentId,
51 prompt: typeof started?.task === "string" ? started.task : "",
52 title: typeof started?.title === "string" ? started.title : null,
53 model: typeof started?.model === "string" ? started.model : null,
54 status: completed ? (completed.success === false ? "failed" : "completed") : "running",
55 result: typeof completed?.summary === "string" ? completed.summary : null,
56 };
57}
packages/provider-acp-registry/src/server/driver.test.ts created+456
......@@ -0,0 +1,456 @@
1import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts";
2import { AcpRegistrySettings } from "../settings.ts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
9import {
10 acpRegistrySnapshotReadiness,
11 applyAcpRegistryAvailableCommands,
12 applyAcpRegistryLiveConfiguration,
13 buildCheckedAcpRegistrySnapshot,
14 checkAcpRegistryProviderReadiness,
15 checkAcpRegistryProviderStatus,
16} from "./driver.ts";
17
18const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
19const identity = {
20 instanceId: ProviderInstanceId.make("acpRegistry_test"),
21 displayName: "Test ACP",
22 accentColor: undefined,
23 continuationKey: "acpRegistry:instance:acpRegistry_test",
24};
25const noSessionManagement = {
26 canList: false,
27 canLoad: false,
28 canResume: false,
29 canLogout: false,
30 canDelete: false,
31 canConfigureProviders: false,
32} as const;
33
34function catalogWithInspection(
35 inspection: AcpRegistrySupport.AcpRegistryInspection,
36): AcpRegistrySupport.AcpRegistryCatalog["Service"] {
37 return {
38 search: () => Effect.die("unused search"),
39 prepare: () => Effect.die("unused prepare"),
40 inspect: () => Effect.succeed(inspection),
41 resolve: () => Effect.die("unused resolve"),
42 uninstallManagedBinary: () => Effect.die("unused uninstall"),
43 };
44}
45
46describe("acpRegistrySnapshotReadiness", () => {
47 it("treats a live empty command advertisement as an authoritative replacement", () => {
48 const provider = buildCheckedAcpRegistrySnapshot({
49 ...identity,
50 settings: decodeSettings({ agentId: "test-agent" }),
51 checkedAt: "2026-08-13T10:00:00.000Z",
52 inspection: {
53 status: "ready",
54 agentId: "test-agent",
55 version: "1.0.0",
56 distribution: "npx",
57 documentationUrl: "https://example.test/agent/setup",
58 },
59 probe: {
60 probe: {
61 instanceId: identity.instanceId,
62 ready: true,
63 icon: null,
64 authMethods: [],
65 models: [],
66 currentModelId: null,
67 configOptions: [],
68 sessionManagement: noSessionManagement,
69 },
70 slashCommands: [{ name: "stale" }],
71 skills: [{ name: "stale-skill", path: "stale", enabled: true }],
72 },
73 });
74
75 const replaced = applyAcpRegistryAvailableCommands(
76 provider,
77 Option.some({ slashCommands: [], skills: [] }),
78 );
79 expect(replaced.slashCommands).toEqual([]);
80 expect(replaced.skills).toEqual([]);
81 expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([
82 { name: "stale" },
83 ]);
84 expect(provider.iconUrl).toBe(
85 "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg",
86 );
87 expect(provider.setup).toMatchObject({
88 canAuthenticate: false,
89 documentationUrl: "https://example.test/agent/setup",
90 });
91 });
92
93 it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => {
94 const snapshot = buildCheckedAcpRegistrySnapshot({
95 ...identity,
96 settings: decodeSettings({ agentId: "test-agent" }),
97 checkedAt: "2026-08-13T10:00:00.000Z",
98 inspection: {
99 status: "ready",
100 agentId: "test-agent",
101 version: "1.0.0",
102 distribution: "binary",
103 },
104 probe: {
105 probe: {
106 instanceId: identity.instanceId,
107 ready: true,
108 icon: null,
109 authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }],
110 models: [],
111 currentModelId: null,
112 configOptions: [],
113 sessionManagement: noSessionManagement,
114 },
115 slashCommands: [],
116 skills: [],
117 },
118 });
119 expect(snapshot.auth.status).toBe("unknown");
120 expect(snapshot.setup?.canAuthenticate).toBe(true);
121 expect(
122 applyAcpRegistryLiveConfiguration(
123 snapshot,
124 { models: [], currentModelId: null, configOptions: [] },
125 [],
126 ).auth.status,
127 ).toBe("unknown");
128 });
129
130 it("overlays live configuration without dropping probe-owned session capabilities", () => {
131 const provider = buildCheckedAcpRegistrySnapshot({
132 ...identity,
133 settings: decodeSettings({ agentId: "test-agent" }),
134 checkedAt: "2026-08-13T10:00:00.000Z",
135 inspection: {
136 status: "ready",
137 agentId: "test-agent",
138 version: "1.0.0",
139 distribution: "npx",
140 },
141 probe: {
142 probe: {
143 instanceId: identity.instanceId,
144 ready: true,
145 icon: null,
146 authMethods: [],
147 models: [{ id: "probe-model", name: "Probe model", description: null }],
148 currentModelId: "probe-model",
149 configOptions: [],
150 sessionManagement: {
151 canList: true,
152 canLoad: true,
153 canResume: true,
154 canLogout: true,
155 canDelete: true,
156 canConfigureProviders: true,
157 },
158 },
159 slashCommands: [],
160 skills: [],
161 },
162 });
163
164 expect(
165 applyAcpRegistryLiveConfiguration(
166 provider,
167 {
168 models: [{ id: "live-model", name: "Live model", description: null }],
169 currentModelId: "live-model",
170 configOptions: [],
171 },
172 [],
173 ),
174 ).toMatchObject({
175 auth: { status: "unknown", canLogout: true },
176 nativeSessions: { canList: true, canLoad: true, canResume: true },
177 models: [{ slug: "live-model", isDefault: true }],
178 });
179 });
180
181 it("maps registry inspection status to provider readiness", () => {
182 expect(
183 acpRegistrySnapshotReadiness({
184 status: "ready",
185 agentId: "gemini-cli",
186 version: "1.2.3",
187 distribution: "npx",
188 }),
189 ).toEqual({ installed: true, version: "1.2.3", status: "ready" });
190
191 expect(
192 acpRegistrySnapshotReadiness({
193 status: "missing_runner",
194 agentId: "gemini-cli",
195 version: "1.2.3",
196 distribution: "npx",
197 runner: "npx",
198 }),
199 ).toMatchObject({ installed: false, version: "1.2.3", status: "error" });
200
201 expect(
202 acpRegistrySnapshotReadiness({
203 status: "missing_runner",
204 version: null,
205 distribution: "local",
206 }),
207 ).toEqual({
208 installed: false,
209 version: null,
210 status: "error",
211 message: "Local ACP executable is not available on this environment's PATH.",
212 });
213
214 expect(
215 acpRegistrySnapshotReadiness({
216 status: "unprepared",
217 agentId: "zed-agent",
218 version: "2.0.0",
219 distribution: "binary",
220 }),
221 ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" });
222
223 expect(
224 acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }),
225 ).toEqual({
226 installed: false,
227 version: null,
228 status: "error",
229 message: "Registry unavailable.",
230 });
231 });
232
233 it("projects discovery without claiming authentication when no login methods are advertised", () => {
234 const snapshot = buildCheckedAcpRegistrySnapshot({
235 ...identity,
236 settings: decodeSettings({
237 agentId: "test-agent",
238 customModels: [" custom-model ", "gpt-discovered"],
239 }),
240 checkedAt: "2026-08-13T10:00:00.000Z",
241 inspection: {
242 status: "ready",
243 agentId: "test-agent",
244 version: "1.0.0",
245 distribution: "npx",
246 },
247 probe: {
248 probe: {
249 instanceId: identity.instanceId,
250 ready: true,
251 icon: null,
252 authMethods: [],
253 models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }],
254 currentModelId: "gpt-discovered",
255 configOptions: [],
256 sessionManagement: noSessionManagement,
257 },
258 slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }],
259 skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }],
260 },
261 });
262
263 expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false });
264 expect(snapshot.supportsTextGeneration).toBe(false);
265 expect(
266 snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({
267 slug,
268 name,
269 isCustom,
270 isDefault,
271 })),
272 ).toEqual([
273 {
274 slug: "gpt-discovered",
275 name: "GPT Discovered",
276 isCustom: false,
277 isDefault: true,
278 },
279 {
280 slug: "custom-model",
281 name: "custom-model",
282 isCustom: true,
283 isDefault: undefined,
284 },
285 ]);
286 expect(snapshot.slashCommands).toEqual([
287 { name: "plan", description: "Create a plan", input: { hint: "topic" } },
288 ]);
289 expect(snapshot.skills).toEqual([
290 { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true },
291 ]);
292 });
293
294 it("keeps a default model only when the agent advertises none", () => {
295 const snapshot = buildCheckedAcpRegistrySnapshot({
296 ...identity,
297 settings: decodeSettings({ agentId: "test-agent" }),
298 checkedAt: "2026-08-13T10:00:00.000Z",
299 inspection: {
300 status: "ready",
301 agentId: "test-agent",
302 version: "1.0.0",
303 distribution: "uvx",
304 },
305 probe: {
306 probe: {
307 instanceId: identity.instanceId,
308 ready: true,
309 icon: null,
310 authMethods: [],
311 models: [],
312 currentModelId: null,
313 configOptions: [],
314 sessionManagement: noSessionManagement,
315 },
316 slashCommands: [],
317 skills: [],
318 },
319 });
320
321 expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]);
322 expect(snapshot.models[0]?.isDefault).toBe(true);
323 });
324
325 it.each(["registry", "local"] as const)(
326 "reports failed authentication after successful %s inspection",
327 (source) => {
328 const snapshot = buildCheckedAcpRegistrySnapshot({
329 ...identity,
330 settings: decodeSettings({
331 source,
332 ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }),
333 authMethodId: "grok-login",
334 }),
335 checkedAt: "2026-08-13T10:00:00.000Z",
336 inspection: {
337 status: "ready",
338 agentId: "test-agent",
339 version: source === "local" ? null : "1.0.0",
340 distribution: source === "local" ? "local" : "binary",
341 },
342 probeError: new AcpRegistryOperationError({
343 reason: "authentication_failed",
344 message: "Login required.",
345 authMethods: [
346 {
347 id: "api-key",
348 name: "API key",
349 description: null,
350 type: "env_var",
351 },
352 {
353 id: "grok-login",
354 name: "Log in with Grok",
355 description: null,
356 type: "agent",
357 },
358 ],
359 }),
360 });
361
362 expect(snapshot).toMatchObject({
363 installed: true,
364 version: source === "local" ? null : "1.0.0",
365 status: "warning",
366 setup: { canAuthenticate: true },
367 auth: {
368 status: "unauthenticated",
369 type: "agent",
370 label: "Log in with Grok",
371 },
372 message: 'Sign in in provider settings using "Log in with Grok".',
373 });
374 },
375 );
376
377 it.effect("runs the disposable probe only after local inspection is ready", () =>
378 Effect.gen(function* () {
379 const settings = decodeSettings({ agentId: "test-agent" });
380 const environment = { PATH: "/provider/bin" };
381 let receivedEnvironment: NodeJS.ProcessEnv | undefined;
382 const snapshot = yield* checkAcpRegistryProviderStatus(
383 {
384 ...identity,
385 settings,
386 cwd: "/workspace",
387 environment,
388 },
389 (input) =>
390 Effect.sync(() => {
391 receivedEnvironment = input.environment;
392 return {
393 probe: {
394 instanceId: identity.instanceId,
395 ready: true as const,
396 icon: null,
397 authMethods: [],
398 models: [{ id: "agent-model", name: "Agent Model", description: null }],
399 currentModelId: "agent-model",
400 configOptions: [],
401 sessionManagement: noSessionManagement,
402 },
403 slashCommands: [{ name: "review" }],
404 skills: [],
405 };
406 }),
407 ).pipe(
408 Effect.provideService(
409 AcpRegistrySupport.AcpRegistryCatalog,
410 catalogWithInspection({
411 status: "ready",
412 agentId: "test-agent",
413 version: "1.0.0",
414 distribution: "npx",
415 }),
416 ),
417 );
418
419 expect(receivedEnvironment).toBe(environment);
420 expect(snapshot).toMatchObject({
421 auth: { status: "unknown" },
422 models: [{ slug: "agent-model" }],
423 slashCommands: [{ name: "review" }],
424 skills: [],
425 });
426 }),
427 );
428
429 it.effect("publishes concrete local readiness before background ACP discovery", () =>
430 Effect.gen(function* () {
431 const snapshot = yield* checkAcpRegistryProviderReadiness({
432 ...identity,
433 settings: decodeSettings({ agentId: "test-agent" }),
434 environment: { PATH: "/provider/bin" },
435 }).pipe(
436 Effect.provideService(
437 AcpRegistrySupport.AcpRegistryCatalog,
438 catalogWithInspection({
439 status: "ready",
440 agentId: "test-agent",
441 version: "1.0.0",
442 distribution: "npx",
443 }),
444 ),
445 );
446
447 expect(snapshot).toMatchObject({
448 installed: true,
449 status: "ready",
450 version: "1.0.0",
451 auth: { status: "unknown" },
452 message: "Checking ACP authentication, models, and commands in the background...",
453 });
454 }),
455 );
456});
packages/provider-acp-registry/src/server/driver.ts created+903
......@@ -0,0 +1,903 @@
1import {
2 officialAcpRegistryIconUrlForAgentId,
3 ProviderDriverKind,
4 resolveOfficialAcpRegistryIconUrl,
5 TextGenerationError,
6 type AcpRegistryOperationError,
7 type ServerProvider,
8 type ServerProviderModel,
9} from "@t3tools/contracts";
10import { AcpRegistrySettings } from "../settings.ts";
11import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
12import { createModelCapabilities } from "@t3tools/shared/model";
13import * as Clock from "effect/Clock";
14import * as Crypto from "effect/Crypto";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import * as Result from "effect/Result";
20import * as Schema from "effect/Schema";
21import * as Semaphore from "effect/Semaphore";
22import { ChildProcessSpawner } from "effect/process";
23
24import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
25import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts";
26import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
27import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
28import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
29import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
30import {
31 defaultProviderContinuationIdentity,
32 type ProviderDriver,
33 type ProviderInstance,
34} from "@t3tools/provider-core/server/driver";
35import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe";
36import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
37import {
38 haveProviderSnapshotSettingsChanged,
39 makeProviderSnapshotSettingsSource,
40 type ProviderSnapshotSettings,
41} from "@t3tools/provider-core/server/snapshotSettings";
42import {
43 type AcpRegistryAvailableCommands,
44 type AcpRegistryConfigurationProbe,
45 type AcpRegistryConfigurationProbeResult,
46 type AcpRegistryLiveConfiguration,
47 deleteAcpRegistrySession,
48 disableAcpRegistryProvider,
49 listAcpRegistrySessions,
50 listAcpRegistryProviders,
51 logoutAcpRegistry,
52 probeAcpRegistryConfiguration,
53 setAcpRegistryProvider,
54} from "./probe.ts";
55import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
56import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
57import * as AcpRegistryAuth from "./auth.ts";
58import * as AcpRegistryAuthenticationState from "./authenticationState.ts";
59
60const DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
61const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
62// How long a successful discovery probe stays valid. Periodic health
63// refreshes reuse it instead of spawning a fresh disposable ACP session;
64// failed or unauthenticated probes are never cached so a completed sign-in
65// is detected on the next refresh.
66const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000;
67const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
68const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({
69 provider: DRIVER_KIND,
70 packageName: null,
71});
72
73const makeUnsupportedTextGeneration = (): ProviderTextGeneration => {
74 const unsupported = (operation: string) =>
75 Effect.fail(
76 new TextGenerationError({
77 operation,
78 detail: "ACP Registry instances do not provide application text generation.",
79 }),
80 );
81 return {
82 generateCommitMessage: () => unsupported("generateCommitMessage"),
83 generatePrContent: () => unsupported("generatePrContent"),
84 generateBranchName: () => unsupported("generateBranchName"),
85 generateThreadTitle: () => unsupported("generateThreadTitle"),
86 };
87};
88
89function modelsFromDiscovery(
90 discovery:
91 | Pick<AcpRegistryLiveConfiguration, "models" | "currentModelId" | "configOptions">
92 | undefined,
93 customModels: ReadonlyArray<string>,
94): ReadonlyArray<ServerProviderModel> {
95 const discovered = discovery?.models ?? [];
96 // Discovered session config options and modes ride on every model so the
97 // composer's generic option controls can drive them per thread.
98 const capabilities =
99 discovery === undefined || discovery.configOptions.length === 0
100 ? EMPTY_CAPABILITIES
101 : createModelCapabilities({ optionDescriptors: discovery.configOptions });
102 const builtInModels: ReadonlyArray<ServerProviderModel> =
103 discovered.length === 0
104 ? [
105 {
106 slug: "default",
107 name: "Default",
108 isCustom: false,
109 isDefault: true,
110 capabilities,
111 },
112 ]
113 : discovered.map((model) => ({
114 slug: model.id,
115 name: model.name,
116 isCustom: false,
117 ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}),
118 capabilities,
119 }));
120 return providerModelsFromSettings(builtInModels, customModels, capabilities);
121}
122
123export function acpRegistrySnapshotReadiness(
124 inspection:
125 | AcpRegistrySupport.AcpRegistryInspection
126 | { readonly status: "failed"; readonly message: string },
127): Pick<ServerProvider, "installed" | "version" | "status" | "message"> {
128 switch (inspection.status) {
129 case "ready":
130 return { installed: true, version: inspection.version, status: "ready" };
131 case "unconfigured":
132 return {
133 installed: false,
134 version: null,
135 status: "warning",
136 message:
137 "Select an ACP Registry agent or configure a local ACP executable before starting a thread.",
138 };
139 case "not_found":
140 return {
141 installed: false,
142 version: null,
143 status: "error",
144 message: `ACP Registry does not contain agent '${inspection.agentId}'.`,
145 };
146 case "unsupported":
147 return {
148 installed: false,
149 version: inspection.version,
150 status: "error",
151 message: `ACP Registry agent '${inspection.agentId}' has no compatible distribution for this environment.`,
152 };
153 case "missing_runner":
154 return {
155 installed: false,
156 version: inspection.version,
157 status: "error",
158 message:
159 inspection.distribution === "local"
160 ? "Local ACP executable is not available on this environment's PATH."
161 : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`,
162 };
163 case "unprepared":
164 return {
165 installed: false,
166 version: inspection.version,
167 status: "warning",
168 message: `ACP Registry agent '${inspection.agentId}' has not been prepared on this environment.`,
169 };
170 case "failed":
171 return {
172 installed: false,
173 version: null,
174 status: "error",
175 message: inspection.message,
176 };
177 }
178}
179
180interface SnapshotIdentity {
181 readonly instanceId: ProviderInstance["instanceId"];
182 readonly displayName: string | undefined;
183 readonly accentColor: string | undefined;
184 readonly continuationKey: string;
185}
186
187function baseSnapshot(
188 input: SnapshotIdentity & {
189 readonly settings: AcpRegistrySettings;
190 readonly checkedAt: string;
191 readonly installed: boolean;
192 readonly version: string | null;
193 readonly status: ServerProvider["status"];
194 readonly auth: ServerProvider["auth"];
195 readonly documentationUrl?: string;
196 readonly message?: string;
197 readonly probe?: AcpRegistryConfigurationProbeResult;
198 readonly probeError?: AcpRegistryOperationError;
199 },
200): ServerProvider {
201 const iconUrl =
202 input.settings.source === "local"
203 ? null
204 : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ??
205 officialAcpRegistryIconUrlForAgentId(input.settings.agentId));
206 return {
207 instanceId: input.instanceId,
208 driver: DRIVER_KIND,
209 ...(input.displayName ? { displayName: input.displayName } : {}),
210 ...(input.accentColor ? { accentColor: input.accentColor } : {}),
211 ...(iconUrl ? { iconUrl } : {}),
212 continuation: { groupKey: input.continuationKey },
213 // The registry driver rejects every application text-generation operation,
214 // so selectors must not offer these instances for commit, PR, branch, or
215 // title generation.
216 supportsTextGeneration: false,
217 enabled: input.settings.enabled,
218 installed: input.installed,
219 version: input.version,
220 status: input.settings.enabled ? input.status : "disabled",
221 auth: { ...input.auth, canLogout: input.auth.canLogout ?? false },
222 checkedAt: input.checkedAt,
223 setup: {
224 canInstall: false,
225 ...(input.documentationUrl ? { documentationUrl: input.documentationUrl } : {}),
226 canAuthenticate:
227 input.installed &&
228 (input.probe
229 ? input.probe.probe.authMethods.length > 0
230 : input.settings.source === "local"
231 ? (input.probeError?.authMethods?.length ?? 0) > 0
232 : input.settings.agentId.length > 0),
233 },
234 ...(input.message ? { message: input.message } : {}),
235 models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels),
236 ...(input.probe === undefined
237 ? {}
238 : {
239 nativeSessions: {
240 canList: input.probe.probe.sessionManagement.canList,
241 canLoad: input.probe.probe.sessionManagement.canLoad,
242 canResume: input.probe.probe.sessionManagement.canResume,
243 canDelete: input.probe.probe.sessionManagement.canDelete,
244 },
245 configurableProviders: input.probe.probe.sessionManagement.canConfigureProviders,
246 }),
247 slashCommands: input.probe?.slashCommands ?? [],
248 skills: input.probe?.skills ?? [],
249 };
250}
251
252export function applyAcpRegistryAvailableCommands(
253 provider: ServerProvider,
254 commands: Option.Option<AcpRegistryAvailableCommands>,
255): ServerProvider {
256 return Option.match(commands, {
257 onNone: () => provider,
258 onSome: ({ slashCommands, skills }) => ({ ...provider, slashCommands, skills }),
259 });
260}
261
262export function applyAcpRegistryLiveConfiguration(
263 provider: ServerProvider,
264 configuration: AcpRegistryLiveConfiguration,
265 customModels: ReadonlyArray<string>,
266): ServerProvider {
267 const { message: _staleProbeMessage, ...snapshot } = provider;
268 return {
269 ...snapshot,
270 status: provider.enabled ? "ready" : provider.status,
271 models: modelsFromDiscovery(configuration, customModels),
272 };
273}
274
275function applyAcpRegistryUrlAuthAction(
276 provider: ServerProvider,
277 action: Option.Option<NonNullable<ServerProvider["auth"]["action"]>>,
278): ServerProvider {
279 const { action: _previousAction, ...auth } = provider.auth;
280 return {
281 ...provider,
282 auth: Option.match(action, {
283 onNone: () => auth,
284 onSome: (nextAction) => ({ ...auth, action: nextAction }),
285 }),
286 };
287}
288
289const buildInitialAcpRegistrySnapshot = Effect.fn("AcpRegistryDriver.buildInitialSnapshot")(
290 function* (input: SnapshotIdentity & { readonly settings: AcpRegistrySettings }) {
291 const checkedAt = DateTime.formatIso(yield* DateTime.now);
292 return baseSnapshot({
293 ...input,
294 checkedAt,
295 installed: false,
296 version: null,
297 status: "warning",
298 auth: { status: "unknown" },
299 message: input.settings.enabled
300 ? "Checking ACP Registry agent readiness..."
301 : "ACP Registry is disabled in T3 Code settings.",
302 });
303 },
304);
305
306export function buildCheckedAcpRegistrySnapshot(
307 input: SnapshotIdentity & {
308 readonly settings: AcpRegistrySettings;
309 readonly checkedAt: string;
310 readonly inspection:
311 | AcpRegistrySupport.AcpRegistryInspection
312 | { readonly status: "failed"; readonly message: string };
313 readonly probe?: AcpRegistryConfigurationProbeResult;
314 readonly probeError?: AcpRegistryOperationError;
315 },
316): ServerProvider {
317 const readiness = acpRegistrySnapshotReadiness(input.inspection);
318 const probeFailed = input.probeError !== undefined;
319 const advertisedAuthMethod =
320 input.probeError?.reason === "authentication_failed"
321 ? (input.probeError.authMethods?.find(
322 (method) => method.id === input.settings.authMethodId,
323 ) ?? input.probeError.authMethods?.[0])
324 : undefined;
325 const authenticationMessage = advertisedAuthMethod
326 ? advertisedAuthMethod.type === "terminal" && advertisedAuthMethod.command
327 ? `Sign in in provider settings using "${advertisedAuthMethod.name}". The login terminal runs on this environment.`
328 : advertisedAuthMethod.type === "env_var" &&
329 (advertisedAuthMethod.envVarNames?.length ?? 0) > 0
330 ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.`
331 : `Sign in in provider settings using "${advertisedAuthMethod.name}".`
332 : undefined;
333 return baseSnapshot({
334 ...input,
335 ...(input.inspection.status === "ready" && input.inspection.documentationUrl
336 ? { documentationUrl: input.inspection.documentationUrl }
337 : {}),
338 installed: readiness.installed,
339 version: readiness.version,
340 // A failed discovery probe on a ready installation is a warning, not an
341 // outage: the agent binary is present and a real turn may still work
342 // (for example after the user completes authentication).
343 status: probeFailed ? "warning" : readiness.status,
344 auth: input.probe
345 ? {
346 // Discovery sessions and an empty auth-method list do not prove sign-in.
347 status: "unknown",
348 canLogout: input.probe.probe.sessionManagement.canLogout,
349 }
350 : input.probeError?.reason === "authentication_failed"
351 ? {
352 status: "unauthenticated",
353 ...(advertisedAuthMethod
354 ? { type: advertisedAuthMethod.type, label: advertisedAuthMethod.name }
355 : {}),
356 ...(input.probeError.authAction === undefined
357 ? {}
358 : { action: input.probeError.authAction }),
359 }
360 : { status: "unknown" },
361 ...(input.probeError
362 ? { message: authenticationMessage ?? input.probeError.message }
363 : readiness.message
364 ? { message: readiness.message }
365 : {}),
366 });
367}
368
369export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")(
370 function* <Requirements>(
371 input: SnapshotIdentity & {
372 readonly settings: AcpRegistrySettings;
373 readonly cwd: string;
374 readonly environment: NodeJS.ProcessEnv;
375 },
376 probeConfiguration: AcpRegistryConfigurationProbe<Requirements>,
377 ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog | Requirements> {
378 const checkedAt = DateTime.formatIso(yield* DateTime.now);
379 if (!input.settings.enabled) {
380 return yield* buildInitialAcpRegistrySnapshot(input);
381 }
382 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
383 const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment));
384 if (Result.isFailure(inspected)) {
385 return buildCheckedAcpRegistrySnapshot({
386 ...input,
387 checkedAt,
388 inspection: {
389 status: "failed",
390 message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`,
391 },
392 });
393 }
394 if (inspected.success.status !== "ready") {
395 return buildCheckedAcpRegistrySnapshot({
396 ...input,
397 checkedAt,
398 inspection: inspected.success,
399 });
400 }
401 const probed = yield* Effect.result(
402 probeConfiguration({
403 instanceId: input.instanceId,
404 settings: input.settings,
405 cwd: input.cwd,
406 environment: input.environment,
407 }),
408 );
409 return Result.isFailure(probed)
410 ? buildCheckedAcpRegistrySnapshot({
411 ...input,
412 checkedAt,
413 inspection: inspected.success,
414 probeError: probed.failure,
415 })
416 : buildCheckedAcpRegistrySnapshot({
417 ...input,
418 checkedAt,
419 inspection: inspected.success,
420 probe: probed.success,
421 });
422 },
423);
424
425/** Publishes local executable readiness without starting an ACP process. */
426export const checkAcpRegistryProviderReadiness = Effect.fn(
427 "AcpRegistryDriver.checkProviderReadiness",
428)(function* (
429 input: SnapshotIdentity & {
430 readonly settings: AcpRegistrySettings;
431 readonly environment: NodeJS.ProcessEnv;
432 },
433): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog> {
434 const checkedAt = DateTime.formatIso(yield* DateTime.now);
435 if (!input.settings.enabled) {
436 return yield* buildInitialAcpRegistrySnapshot(input);
437 }
438 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
439 const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment));
440 const snapshot = buildCheckedAcpRegistrySnapshot({
441 ...input,
442 checkedAt,
443 inspection: Result.isFailure(inspected)
444 ? {
445 status: "failed",
446 message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`,
447 }
448 : inspected.success,
449 });
450 return inspected._tag === "Success" && inspected.success.status === "ready"
451 ? {
452 ...snapshot,
453 message: "Checking ACP authentication, models, and commands in the background...",
454 }
455 : snapshot;
456});
457
458export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost;
459
460/** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */
461export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryDriverEnv> = {
462 driverKind: DRIVER_KIND,
463 metadata: {
464 displayName: "ACP Registry",
465 supportsMultipleInstances: true,
466 hasDefaultInstance: false,
467 },
468 configSchema: AcpRegistrySettings,
469 defaultConfig: () => decodeSettings({}),
470 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
471 Effect.gen(function* () {
472 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
473 const runtimeCoordinator = yield* Effect.serviceOption(
474 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
475 );
476 if (Option.isSome(runtimeCoordinator)) {
477 yield* runtimeCoordinator.value.clearAvailableCommands(instanceId);
478 yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId);
479 yield* Effect.addFinalizer(() =>
480 runtimeCoordinator.value
481 .clearAvailableCommands(instanceId)
482 .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))),
483 );
484 }
485 const crypto = yield* Crypto.Crypto;
486 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
487 const hostEnvironment = yield* HostProcessEnvironment;
488 const host = yield* ProviderHost;
489 const continuationIdentity = defaultProviderContinuationIdentity({
490 driverKind: DRIVER_KIND,
491 instanceId,
492 });
493 const identity = {
494 instanceId,
495 displayName,
496 accentColor,
497 continuationKey: continuationIdentity.continuationKey,
498 };
499 const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings;
500 const processEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
501 const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({
502 instanceId,
503 displayName,
504 accentColor,
505 environment,
506 enabled,
507 config,
508 }).pipe(
509 Effect.mapError(
510 (cause) =>
511 new ProviderDriverError({
512 driver: DRIVER_KIND,
513 instanceId,
514 detail: "Failed to build ACP Registry orchestration adapter.",
515 cause,
516 }),
517 ),
518 );
519 const readinessInput = {
520 ...identity,
521 settings: effectiveConfig,
522 environment: processEnvironment,
523 };
524 const confirmedAuthentication =
525 yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({
526 cacheDir: host.paths.providerStatusCacheDir,
527 instanceId,
528 settings: effectiveConfig,
529 environment,
530 processEnvironment,
531 });
532 const withLiveRuntimeState = (input: ServerProvider) =>
533 Effect.gen(function* () {
534 if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false);
535 const provider =
536 input.enabled &&
537 input.installed &&
538 input.auth.status === "unknown" &&
539 (yield* confirmedAuthentication.get)
540 ? { ...input, auth: { ...input.auth, status: "authenticated" as const } }
541 : input;
542 return yield* Option.isNone(runtimeCoordinator)
543 ? Effect.succeed(provider)
544 : Effect.all({
545 commands: runtimeCoordinator.value.getAvailableCommands(instanceId),
546 configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId),
547 authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId),
548 }).pipe(
549 Effect.map(({ commands, configuration, authAction }) => {
550 const withCommands = applyAcpRegistryAvailableCommands(provider, commands);
551 const withConfiguration = Option.match(configuration, {
552 onNone: () => withCommands,
553 onSome: (liveConfiguration) =>
554 applyAcpRegistryLiveConfiguration(
555 withCommands,
556 liveConfiguration,
557 effectiveConfig.customModels,
558 ),
559 });
560 return applyAcpRegistryUrlAuthAction(withConfiguration, authAction);
561 }),
562 );
563 });
564 const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe(
565 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
566 Effect.flatMap(withLiveRuntimeState),
567 );
568 const enrichProvider = checkAcpRegistryProviderStatus(
569 {
570 ...readinessInput,
571 cwd: host.paths.cwd,
572 },
573 probeAcpRegistryConfiguration,
574 ).pipe(
575 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
576 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
577 Effect.provideService(Crypto.Crypto, crypto),
578 );
579 const enrichmentCache = yield* Ref.make<{
580 readonly generation: number;
581 readonly entry: {
582 readonly provider: ServerProvider;
583 readonly expiresAt: number;
584 } | null;
585 }>({ generation: 0, entry: null });
586 const liveSnapshotSemaphore = yield* Semaphore.make(1);
587 const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({
588 generation: current.generation + 1,
589 entry: null,
590 }));
591 const enrichProviderCached = (baseSnapshot: ServerProvider) =>
592 Effect.gen(function* () {
593 const now = yield* Clock.currentTimeMillis;
594 const cacheState = yield* Ref.get(enrichmentCache);
595 const cached = cacheState.entry;
596 if (
597 cached !== null &&
598 cached.expiresAt > now &&
599 cached.provider.version === baseSnapshot.version
600 ) {
601 return {
602 provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt },
603 generation: cacheState.generation,
604 };
605 }
606 const enriched = yield* enrichProvider;
607 if (enriched.status === "ready") {
608 yield* Ref.update(enrichmentCache, (current) =>
609 current.generation === cacheState.generation
610 ? {
611 ...current,
612 entry: {
613 provider: enriched,
614 expiresAt: now + PROBE_SUCCESS_TTL_MS,
615 },
616 }
617 : current,
618 );
619 }
620 return { provider: enriched, generation: cacheState.generation };
621 });
622 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
623 const snapshot = yield* makeManagedServerProvider<
624 ProviderSnapshotSettings<AcpRegistrySettings>
625 >({
626 resolveMaintenance: () => Effect.succeed(MAINTENANCE),
627 getSettings: snapshotSettings.getSettings,
628 streamSettings: snapshotSettings.streamSettings,
629 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
630 initialSnapshot: () => checkProvider,
631 checkProvider,
632 enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => {
633 if (!snapshot.installed) return Effect.void;
634 const publishEnrichment = (
635 Option.isSome(runtimeCoordinator)
636 ? runtimeCoordinator.value.runBackgroundProbe(
637 effectiveConfig.source === "local"
638 ? `local:${instanceId}`
639 : effectiveConfig.agentId,
640 enrichProviderCached(snapshot),
641 )
642 : enrichProviderCached(snapshot).pipe(Effect.map(Option.some))
643 ).pipe(
644 Effect.flatMap(
645 Option.match({
646 onNone: () => Effect.void,
647 onSome: ({ provider, generation }) =>
648 liveSnapshotSemaphore.withPermit(
649 Ref.get(enrichmentCache).pipe(
650 Effect.flatMap((current) =>
651 current.generation === generation
652 ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot))
653 : Effect.void,
654 ),
655 ),
656 ),
657 }),
658 ),
659 );
660 if (Option.isNone(runtimeCoordinator)) return publishEnrichment;
661
662 const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands(
663 instanceId,
664 ({ slashCommands, skills }) =>
665 liveSnapshotSemaphore.withPermit(
666 getSnapshot.pipe(
667 Effect.flatMap((current) =>
668 publishSnapshot({
669 ...current,
670 slashCommands,
671 skills,
672 }),
673 ),
674 ),
675 ),
676 );
677 const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration(
678 instanceId,
679 (configuration) =>
680 liveSnapshotSemaphore.withPermit(
681 getSnapshot.pipe(
682 Effect.flatMap((current) =>
683 publishSnapshot(
684 applyAcpRegistryLiveConfiguration(
685 current,
686 configuration,
687 effectiveConfig.customModels,
688 ),
689 ),
690 ),
691 ),
692 ),
693 );
694 const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction(
695 instanceId,
696 (action) =>
697 liveSnapshotSemaphore.withPermit(
698 getSnapshot.pipe(
699 Effect.flatMap((current) =>
700 publishSnapshot(
701 applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)),
702 ),
703 ),
704 ),
705 ),
706 );
707 return Effect.all(
708 [
709 publishEnrichment,
710 publishLiveCommands,
711 publishLiveConfiguration,
712 publishUrlAuthAction,
713 ],
714 {
715 concurrency: "unbounded",
716 discard: true,
717 },
718 );
719 },
720 }).pipe(
721 Effect.mapError(
722 (cause) =>
723 new ProviderDriverError({
724 driver: DRIVER_KIND,
725 instanceId,
726 detail: "Failed to build the ACP Registry provider snapshot.",
727 cause,
728 }),
729 ),
730 );
731
732 // The management probes read the coordinator via serviceOption, so it is
733 // provided only when this driver instance actually has one.
734 const provideAcpManagementServices = <A, E>(
735 effect: Effect.Effect<
736 A,
737 E,
738 | AcpRegistrySupport.AcpRegistryCatalog
739 | ChildProcessSpawner.ChildProcessSpawner
740 | Crypto.Crypto
741 >,
742 ): Effect.Effect<A, E> => {
743 const provided = effect.pipe(
744 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
745 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
746 Effect.provideService(Crypto.Crypto, crypto),
747 );
748 return Option.isSome(runtimeCoordinator)
749 ? provided.pipe(
750 Effect.provideService(
751 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
752 runtimeCoordinator.value,
753 ),
754 )
755 : provided;
756 };
757
758 const clearLiveState = Option.isSome(runtimeCoordinator)
759 ? runtimeCoordinator.value
760 .clearLiveConfiguration(instanceId)
761 .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId)))
762 : Effect.void;
763 const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({
764 instanceId,
765 settings: effectiveConfig,
766 cwd: host.paths.cwd,
767 environment: processEnvironment,
768 onChanged: (authenticated) =>
769 confirmedAuthentication
770 .set(authenticated)
771 .pipe(
772 Effect.andThen(authenticated ? Effect.void : clearLiveState),
773 Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)),
774 Effect.andThen(snapshot.refresh),
775 Effect.asVoid,
776 ),
777 }).pipe(
778 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
779 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
780 Effect.provideService(Crypto.Crypto, crypto),
781 );
782 const auth = {
783 ...controller,
784 invalidate: (controller.invalidate ?? Effect.void).pipe(
785 Effect.andThen(confirmedAuthentication.set(false)),
786 Effect.andThen(clearLiveState),
787 Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)),
788 Effect.andThen(snapshot.refresh),
789 Effect.asVoid,
790 ),
791 };
792
793 return {
794 instanceId,
795 driverKind: DRIVER_KIND,
796 continuationIdentity,
797 displayName,
798 accentColor,
799 enabled,
800 auth,
801 snapshot: {
802 ...snapshot,
803 refresh: snapshot.refresh.pipe(
804 Effect.tap(() => controller.refreshMethods ?? Effect.void),
805 ),
806 },
807 orchestrationAdapter,
808 textGeneration: makeUnsupportedTextGeneration(),
809 acpSessionManagement: {
810 listSessions: ({ cwd, cursor }) =>
811 provideAcpManagementServices(
812 listAcpRegistrySessions({
813 instanceId,
814 settings: effectiveConfig,
815 cwd,
816 environment: processEnvironment,
817 ...(cursor === undefined ? {} : { cursor }),
818 }),
819 ),
820 deleteSession: ({ cwd, sessionId }) =>
821 provideAcpManagementServices(
822 deleteAcpRegistrySession({
823 instanceId,
824 settings: effectiveConfig,
825 cwd,
826 environment: processEnvironment,
827 sessionId,
828 }),
829 ),
830 listProviders: (cwd) =>
831 provideAcpManagementServices(
832 listAcpRegistryProviders({
833 instanceId,
834 settings: effectiveConfig,
835 cwd,
836 environment: processEnvironment,
837 }),
838 ),
839 setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) =>
840 provideAcpManagementServices(
841 setAcpRegistryProvider({
842 instanceId,
843 settings: effectiveConfig,
844 cwd,
845 environment: processEnvironment,
846 providerId,
847 apiType,
848 baseUrl,
849 ...(headers === undefined ? {} : { headers }),
850 }),
851 ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))),
852 disableProvider: ({ cwd, providerId }) =>
853 provideAcpManagementServices(
854 disableAcpRegistryProvider({
855 instanceId,
856 settings: effectiveConfig,
857 cwd,
858 environment: processEnvironment,
859 providerId,
860 }),
861 ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))),
862 logout: (cwd) => {
863 const logout = logoutAcpRegistry({
864 instanceId,
865 settings: effectiveConfig,
866 cwd,
867 environment: processEnvironment,
868 }).pipe(
869 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
870 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
871 Effect.provideService(Crypto.Crypto, crypto),
872 Effect.tap(() => confirmedAuthentication.set(false)),
873 Effect.tap(() =>
874 liveSnapshotSemaphore.withPermit(
875 invalidateEnrichmentCache.pipe(
876 Effect.andThen(
877 Option.isSome(runtimeCoordinator)
878 ? Effect.all(
879 [
880 runtimeCoordinator.value.clearAvailableCommands(instanceId),
881 runtimeCoordinator.value.clearLiveConfiguration(instanceId),
882 ],
883 { concurrency: "unbounded", discard: true },
884 )
885 : Effect.void,
886 ),
887 ),
888 ),
889 ),
890 );
891 return Option.isSome(runtimeCoordinator)
892 ? logout.pipe(
893 Effect.provideService(
894 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
895 runtimeCoordinator.value,
896 ),
897 )
898 : logout;
899 },
900 },
901 } satisfies ProviderInstance;
902 }),
903};
packages/provider-acp-registry/src/server/probe.ts created+782
......@@ -0,0 +1,782 @@
1import {
2 AcpRegistryListProvidersResult,
3 AcpRegistryOperationError,
4 AcpRegistryListSessionsResult,
5 AcpRegistryProbeResult,
6 type AcpRegistryProbeAuthMethod,
7 type AcpRegistryProbeModel,
8 type AcpRegistryUrlAuthAction,
9 type ProviderInstanceId,
10 type ServerProviderSkill,
11 type ServerProviderSlashCommand,
12} from "@t3tools/contracts";
13import { AcpRegistrySettings } from "../settings.ts";
14import * as Crypto from "effect/Crypto";
15import * as Deferred from "effect/Deferred";
16import * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Ref from "effect/Ref";
21import * as Result from "effect/Result";
22import { ChildProcessSpawner } from "effect/process";
23import type * as EffectAcpErrors from "effect-acp/errors";
24import type * as EffectAcpSchema from "effect-acp/compat";
25
26import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
27import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
28import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel";
29import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig";
30import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
31import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
32
33const MAX_AUTH_METHODS = 32;
34const MAX_MODELS = 256;
35const MAX_ID_LENGTH = 128;
36const MAX_NAME_LENGTH = 160;
37const MAX_DESCRIPTION_LENGTH = 1_024;
38const MAX_COMMANDS = 128;
39const MAX_COMMAND_LINE_LENGTH = 2_048;
40// Covers a cold package command launch and ACP initialization after registry
41// preparation globally installs the exact package.
42const PROBE_TIMEOUT_SECONDS = 60;
43const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`;
44const COMMAND_ADVERTISEMENT_GRACE = "500 millis";
45
46const boundedText = (value: string, maximumLength: number): string =>
47 value.trim().slice(0, maximumLength);
48
49const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined =>
50 value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined;
51
52export function normalizeAcpRegistryWebUrl(value: string): string | undefined {
53 if (value.length > 2_048 || !URL.canParse(value)) return undefined;
54 const url = new URL(value);
55 return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined;
56}
57
58function modelConfigOptions(
59 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
60): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> {
61 return (configOptions ?? []).flatMap((option) => {
62 if (option.category !== "model" || option.type !== "select") return [];
63 return option.options.flatMap((candidate) =>
64 "value" in candidate ? [candidate] : candidate.options,
65 );
66 });
67}
68
69function normalizeModels(
70 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
71): ReadonlyArray<AcpRegistryProbeModel> {
72 // Model discovery is the model config option's base models; ACP has no
73 // other portable model inventory.
74 const candidates = modelConfigOptions(configOptions).map((model) => ({
75 id: model.value,
76 name: model.name,
77 description: model.description ?? null,
78 }));
79 const seen = new Set<string>();
80 const models: Array<AcpRegistryProbeModel> = [];
81 for (const candidate of candidates) {
82 const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH);
83 if (id === undefined || seen.has(id)) continue;
84 seen.add(id);
85 models.push({
86 id,
87 name: boundedText(candidate.name, MAX_NAME_LENGTH) || id,
88 description:
89 candidate.description === null
90 ? null
91 : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null,
92 });
93 if (models.length === MAX_MODELS) break;
94 }
95 return models;
96}
97
98/** Agent spawn recipe used to compose runnable terminal-auth command lines. */
99export interface AcpRegistryAuthSpawnContext {
100 readonly command: string;
101 readonly args: ReadonlyArray<string>;
102}
103
104const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u;
105
106function shellDisplayToken(token: string): string {
107 return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`;
108}
109
110function terminalAuthCommand(
111 method: Extract<EffectAcpSchema.AuthMethod, { readonly type: "terminal" }>,
112 spawn: AcpRegistryAuthSpawnContext,
113): string | undefined {
114 const environmentPrefix = Object.entries(method.env ?? {}).map(
115 ([name, value]) => `${name}=${shellDisplayToken(value)}`,
116 );
117 const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken);
118 const displayCommand = [...environmentPrefix, ...command].join(" ");
119 return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined;
120}
121
122export function normalizeAcpRegistryAuthMethods(
123 methods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
124 spawn?: AcpRegistryAuthSpawnContext,
125): ReadonlyArray<AcpRegistryProbeAuthMethod> {
126 const normalized: Array<AcpRegistryProbeAuthMethod> = [];
127 for (const method of methods ?? []) {
128 const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH);
129 if (id === undefined) continue;
130 const type = method.type ?? "agent";
131 const envVarNames =
132 type === "env_var" && "vars" in method
133 ? method.vars
134 .flatMap((variable) => {
135 const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH);
136 return name === undefined ? [] : [name];
137 })
138 .slice(0, 16)
139 : [];
140 const command =
141 spawn !== undefined && "type" in method && method.type === "terminal"
142 ? terminalAuthCommand(method, spawn)
143 : undefined;
144 const link =
145 type === "env_var" && "link" in method && method.link
146 ? normalizeAcpRegistryWebUrl(method.link)
147 : undefined;
148 normalized.push({
149 id,
150 name: boundedText(method.name, MAX_NAME_LENGTH) || id,
151 description:
152 method.description == null
153 ? null
154 : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null,
155 type,
156 ...(command === undefined ? {} : { command }),
157 ...(envVarNames.length > 0 ? { envVarNames } : {}),
158 ...(link === undefined ? {} : { link }),
159 });
160 if (normalized.length === MAX_AUTH_METHODS) break;
161 }
162 return normalized;
163}
164
165export interface AcpRegistryAvailableCommands {
166 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
167 readonly skills: ReadonlyArray<ServerProviderSkill>;
168}
169
170export interface AcpRegistryLiveConfiguration {
171 readonly models: ReadonlyArray<AcpRegistryProbeModel>;
172 readonly currentModelId: string | null;
173 readonly configOptions: AcpRegistryProbeResult["configOptions"];
174}
175
176/** Normalizes volatile session configuration using the same bounds as discovery probes. */
177export function normalizeAcpRegistryLiveConfiguration(
178 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
179 modeState?: Parameters<typeof acpProviderOptionDescriptors>[0]["modeState"],
180): AcpRegistryLiveConfiguration {
181 const modelOption = configOptions.find(
182 (option) => option.category === "model" && option.type === "select",
183 );
184 const boundedCurrentModelId =
185 modelOption?.type === "select"
186 ? (boundedOpaqueValue(modelOption.currentValue, MAX_ID_LENGTH) ?? null)
187 : null;
188 const models = normalizeModels(configOptions);
189 return {
190 models,
191 currentModelId: models.some((model) => model.id === boundedCurrentModelId)
192 ? boundedCurrentModelId
193 : null,
194 configOptions: acpProviderOptionDescriptors({ configOptions, modeState }),
195 };
196}
197
198const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({
199 slashCommands: [],
200 skills: [],
201});
202
203/** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */
204export function normalizeAcpRegistryCommands(
205 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
206): AcpRegistryAvailableCommands {
207 const seen = new Set<string>();
208 const slashCommands: Array<ServerProviderSlashCommand> = [];
209 const skills: Array<ServerProviderSkill> = [];
210 let accepted = 0;
211 for (const command of commands) {
212 const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH);
213 if (name === undefined) continue;
214 const key = name.toLowerCase();
215 if (seen.has(key)) continue;
216 seen.add(key);
217 const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH);
218 const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : "";
219 if (name.startsWith("$")) {
220 const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH);
221 if (skillName === undefined) continue;
222 skills.push({
223 name: skillName,
224 ...(description ? { description } : {}),
225 path: `acp://skill/${encodeURIComponent(skillName)}`,
226 scope: "agent",
227 enabled: true,
228 });
229 } else {
230 slashCommands.push({
231 name,
232 ...(description ? { description } : {}),
233 ...(hint ? { input: { hint } } : {}),
234 });
235 }
236 accepted += 1;
237 if (accepted === MAX_COMMANDS) break;
238 }
239 return { slashCommands, skills };
240}
241
242/** Builds the bounded wire result from a successfully created disposable ACP session. */
243export function acpRegistryProbeResult(
244 instanceId: ProviderInstanceId,
245 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
246 icon: string | null = null,
247 spawn?: AcpRegistryAuthSpawnContext,
248): AcpRegistryProbeResult {
249 const liveConfiguration = normalizeAcpRegistryLiveConfiguration(
250 started.sessionSetupResult.configOptions ?? [],
251 parseSessionModeState(started.sessionSetupResult),
252 );
253 return AcpRegistryProbeResult.make({
254 instanceId,
255 ready: true,
256 icon,
257 authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn),
258 sessionManagement: {
259 canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null,
260 canLoad: started.initializeResult.agentCapabilities?.loadSession === true,
261 canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null,
262 canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null,
263 canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null,
264 canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null,
265 },
266 ...liveConfiguration,
267 });
268}
269
270export function acpRegistryProbeFailure(
271 error: EffectAcpErrors.AcpError,
272 authMethods: ReadonlyArray<AcpRegistryProbeAuthMethod> = [],
273 authAction?: AcpRegistryUrlAuthAction,
274): AcpRegistryOperationError {
275 const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message;
276 const authenticationFailed =
277 (error._tag === "AcpRequestError" && error.code === -32000) ||
278 /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail);
279 return new AcpRegistryOperationError({
280 reason: authenticationFailed ? "authentication_failed" : "probe_failed",
281 message: authenticationFailed
282 ? "The ACP agent could not complete authentication."
283 : "The ACP agent could not create a test session.",
284 cause: error,
285 ...(authMethods.length > 0 ? { authMethods } : {}),
286 ...(authAction === undefined ? {} : { authAction }),
287 });
288}
289
290export interface AcpRegistryConfigurationProbeInput {
291 readonly instanceId: ProviderInstanceId;
292 readonly settings: AcpRegistrySettings;
293 readonly cwd: string;
294 readonly environment: NodeJS.ProcessEnv;
295}
296
297type AcpRegistryManagementInput = AcpRegistryConfigurationProbeInput;
298
299export interface AcpRegistryConfigurationProbeResult {
300 readonly probe: AcpRegistryProbeResult;
301 readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
302 readonly skills: ReadonlyArray<ServerProviderSkill>;
303}
304
305export type AcpRegistryConfigurationProbe<Requirements = never> = (
306 input: AcpRegistryConfigurationProbeInput,
307) => Effect.Effect<AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, Requirements>;
308
309/** Starts and immediately disposes an ACP session for one already-decoded configuration. */
310export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")(
311 function* (
312 input: AcpRegistryConfigurationProbeInput,
313 ): Effect.fn.Return<
314 AcpRegistryConfigurationProbeResult,
315 AcpRegistryOperationError,
316 AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
317 > {
318 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
319 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
320 const crypto = yield* Crypto.Crypto;
321 const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter);
322
323 const result = yield* Effect.gen(function* () {
324 // Resolution may install a missing registry package. Keep it inside the
325 // probe deadline so a provider refresh cannot inherit the installer's
326 // much longer timeout.
327 const resolved = yield* catalog
328 .resolve(input.settings, input.cwd, input.environment)
329 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
330 const authMethodsRef = yield* Ref.make<ReadonlyArray<AcpRegistryProbeAuthMethod>>([]);
331 const authActionRef = yield* Ref.make<AcpRegistryUrlAuthAction | undefined>(undefined);
332 const runtimeCoordinator = yield* Effect.serviceOption(
333 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
334 );
335 const runtimeContext = yield* Layer.build(
336 AcpSessionRuntime.layer({
337 spawn: resolved.spawn,
338 cwd: input.cwd,
339 clientCapabilities: {
340 auth: { terminal: Option.isSome(pty) },
341 elicitation: { url: {} },
342 fs: { readTextFile: false, writeTextFile: false },
343 terminal: false,
344 },
345 clientInfo: { name: "t3-code-provider-test", version: "0.0.0" },
346 authenticateOnAuthRequired: false,
347 onInitialized: (initializeResult) =>
348 Ref.set(
349 authMethodsRef,
350 normalizeAcpRegistryAuthMethods(initializeResult.authMethods, {
351 command: resolved.spawn.command,
352 args: resolved.spawn.args,
353 }),
354 ),
355 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
356 }).pipe(
357 Layer.provide(
358 Layer.mergeAll(
359 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
360 Layer.succeed(Crypto.Crypto, crypto),
361 ),
362 ),
363 ),
364 );
365 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
366 Effect.provide(runtimeContext),
367 );
368 const commandsRef = yield* Ref.make<AcpRegistryAvailableCommands>(
369 emptyAcpRegistryAvailableCommands(),
370 );
371 const commandsAdvertised = yield* Deferred.make<void>();
372 yield* runtime.handleElicitation((request) =>
373 Effect.gen(function* () {
374 if (
375 request.mode !== "url" ||
376 !("url" in request) ||
377 !("elicitationId" in request) ||
378 typeof request.url !== "string" ||
379 typeof request.elicitationId !== "string"
380 ) {
381 return { action: "decline" } as const;
382 }
383 const url = normalizeAcpRegistryWebUrl(request.url);
384 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
385 if (url === undefined || elicitationId === undefined) {
386 return { action: "decline" } as const;
387 }
388 const action: AcpRegistryUrlAuthAction = {
389 elicitationId,
390 url,
391 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
392 };
393 yield* Ref.set(authActionRef, action);
394 const accepted = yield* Option.match(runtimeCoordinator, {
395 onNone: () => Effect.succeed(false),
396 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
397 });
398 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
399 }),
400 );
401 yield* runtime.handleSessionUpdate((notification) => {
402 const update = notification.update;
403 return update.sessionUpdate === "available_commands_update"
404 ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe(
405 Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)),
406 Effect.asVoid,
407 )
408 : Effect.void;
409 });
410 const startResult = yield* Effect.result(runtime.start());
411 if (Result.isFailure(startResult)) {
412 return yield* acpRegistryProbeFailure(
413 startResult.failure,
414 yield* Ref.get(authMethodsRef),
415 yield* Ref.get(authActionRef),
416 );
417 }
418 const started = startResult.success;
419 yield* Deferred.await(commandsAdvertised).pipe(
420 Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE),
421 );
422 return { resolved, started, commands: yield* Ref.get(commandsRef) };
423 }).pipe(
424 Effect.scoped,
425 Effect.timeoutOrElse({
426 duration: PROBE_TIMEOUT,
427 orElse: () =>
428 Effect.fail(
429 new AcpRegistryOperationError({
430 reason: "probe_failed",
431 message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`,
432 }),
433 ),
434 }),
435 Effect.mapError((error) =>
436 error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error),
437 ),
438 );
439 return {
440 probe: acpRegistryProbeResult(
441 input.instanceId,
442 result.started,
443 result.resolved.agent?.icon ?? null,
444 {
445 command: result.resolved.spawn.command,
446 args: result.resolved.spawn.args,
447 },
448 ),
449 slashCommands: result.commands.slashCommands,
450 skills: result.commands.skills,
451 };
452 },
453);
454
455const MANAGEMENT_TIMEOUT = Duration.seconds(60);
456
457const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")(
458 function* (input: AcpRegistryManagementInput) {
459 const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog;
460 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
461 const crypto = yield* Crypto.Crypto;
462 const runtimeCoordinator = yield* Effect.serviceOption(
463 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
464 );
465 const resolved = yield* catalog
466 .resolve(input.settings, input.cwd, input.environment)
467 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError));
468 const runtimeContext = yield* Layer.build(
469 AcpSessionRuntime.layer({
470 spawn: resolved.spawn,
471 cwd: input.cwd,
472 clientCapabilities: {
473 auth: { terminal: false },
474 elicitation: { url: {} },
475 fs: { readTextFile: false, writeTextFile: false },
476 terminal: false,
477 },
478 clientInfo: { name: "t3-code-session-manager", version: "0.0.0" },
479 authenticateOnAuthRequired: false,
480 ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}),
481 }).pipe(
482 Layer.provide(
483 Layer.mergeAll(
484 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
485 Layer.succeed(Crypto.Crypto, crypto),
486 ),
487 ),
488 ),
489 );
490 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
491 Effect.provide(runtimeContext),
492 );
493 yield* runtime.handleElicitation((request) =>
494 Effect.gen(function* () {
495 if (
496 request.mode !== "url" ||
497 !("url" in request) ||
498 !("elicitationId" in request) ||
499 typeof request.url !== "string" ||
500 typeof request.elicitationId !== "string"
501 ) {
502 return { action: "decline" } as const;
503 }
504 const url = normalizeAcpRegistryWebUrl(request.url);
505 const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH);
506 if (url === undefined || elicitationId === undefined) {
507 return { action: "decline" } as const;
508 }
509 const action: AcpRegistryUrlAuthAction = {
510 elicitationId,
511 url,
512 message: boundedText(request.message, MAX_DESCRIPTION_LENGTH),
513 };
514 const accepted = yield* Option.match(runtimeCoordinator, {
515 onNone: () => Effect.succeed(false),
516 onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action),
517 });
518 return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const);
519 }),
520 );
521 return runtime;
522 },
523);
524
525function managementFailure(
526 operation:
527 | "list"
528 | "delete"
529 | "providers_list"
530 | "providers_set"
531 | "providers_disable"
532 | "logout",
533 error: EffectAcpErrors.AcpError | AcpRegistryOperationError,
534): AcpRegistryOperationError {
535 if (error._tag === "AcpRegistryOperationError") return error;
536 if (error._tag === "AcpRequestError" && error.code === -32601) {
537 if (operation === "list") {
538 return new AcpRegistryOperationError({
539 reason: "session_list_unsupported",
540 message: "The ACP agent does not advertise session listing.",
541 cause: error,
542 });
543 }
544 if (operation === "delete") {
545 return new AcpRegistryOperationError({
546 reason: "session_delete_unsupported",
547 message: "The ACP agent does not advertise session deletion.",
548 cause: error,
549 });
550 }
551 if (operation.startsWith("providers_")) {
552 return new AcpRegistryOperationError({
553 reason: "providers_unsupported",
554 message: "The ACP agent does not advertise provider configuration.",
555 cause: error,
556 });
557 }
558 return new AcpRegistryOperationError({
559 reason: "logout_unsupported",
560 message: "The ACP agent does not advertise logout.",
561 cause: error,
562 });
563 }
564 const classified = acpRegistryProbeFailure(error);
565 if (classified.reason === "authentication_failed") return classified;
566 const failure =
567 operation === "delete"
568 ? { reason: "session_delete_failed" as const, message: "Could not delete the ACP session." }
569 : operation === "providers_list"
570 ? { reason: "providers_list_failed" as const, message: "Could not list ACP providers." }
571 : operation === "providers_set" || operation === "providers_disable"
572 ? {
573 reason: "provider_configuration_failed" as const,
574 message: "Could not update the ACP provider configuration.",
575 }
576 : undefined;
577 return failure === undefined
578 ? classified
579 : new AcpRegistryOperationError({ ...failure, cause: error });
580}
581
582export const listAcpRegistrySessions = Effect.fn("AcpRegistryProbe.listSessions")(
583 function* (input: AcpRegistryManagementInput & { readonly cursor?: string }) {
584 const runtime = yield* makeAcpRegistryManagementRuntime(input);
585 const initialized = yield* runtime.initialize();
586 const listed = yield* runtime.listSessions(input.cursor);
587 const sessions = listed.sessions.slice(0, 256).flatMap((session) => {
588 const sessionId = boundedOpaqueValue(session.sessionId, 1_024);
589 const cwd = boundedOpaqueValue(session.cwd, 4_096);
590 if (sessionId === undefined || cwd === undefined) return [];
591 return [
592 {
593 sessionId,
594 cwd,
595 additionalDirectories: (session.additionalDirectories ?? [])
596 .flatMap((directory) => {
597 const normalized = boundedOpaqueValue(directory, 4_096);
598 return normalized === undefined ? [] : [normalized];
599 })
600 .slice(0, 32),
601 title:
602 session.title === undefined || session.title === null
603 ? null
604 : boundedText(session.title, 1_024) || null,
605 updatedAt:
606 session.updatedAt === undefined || session.updatedAt === null
607 ? null
608 : boundedText(session.updatedAt, 128) || null,
609 importedThreadId: null,
610 },
611 ];
612 });
613 return AcpRegistryListSessionsResult.make({
614 sessions,
615 nextCursor:
616 listed.nextCursor === undefined || listed.nextCursor === null
617 ? null
618 : boundedText(listed.nextCursor, 2_048) || null,
619 canLoad: initialized.agentCapabilities?.loadSession === true,
620 canResume: initialized.agentCapabilities?.sessionCapabilities?.resume != null,
621 canDelete: initialized.agentCapabilities?.sessionCapabilities?.delete != null,
622 });
623 },
624 (effect) =>
625 effect.pipe(
626 Effect.scoped,
627 Effect.timeoutOrElse({
628 duration: MANAGEMENT_TIMEOUT,
629 orElse: () =>
630 Effect.fail(
631 new AcpRegistryOperationError({
632 reason: "probe_failed",
633 message: "The ACP session list request timed out.",
634 }),
635 ),
636 }),
637 Effect.mapError((error) => managementFailure("list", error)),
638 ),
639);
640
641export const deleteAcpRegistrySession = Effect.fn("AcpRegistryProbe.deleteSession")(
642 function* (input: AcpRegistryManagementInput & { readonly sessionId: string }) {
643 const runtime = yield* makeAcpRegistryManagementRuntime(input);
644 yield* runtime.deleteSession(input.sessionId);
645 },
646 (effect) =>
647 effect.pipe(
648 Effect.scoped,
649 Effect.timeoutOrElse({
650 duration: MANAGEMENT_TIMEOUT,
651 orElse: () =>
652 Effect.fail(
653 new AcpRegistryOperationError({
654 reason: "session_delete_failed",
655 message: "The ACP session delete request timed out.",
656 }),
657 ),
658 }),
659 Effect.mapError((error) => managementFailure("delete", error)),
660 ),
661);
662
663export const listAcpRegistryProviders = Effect.fn("AcpRegistryProbe.listProviders")(
664 function* (input: AcpRegistryManagementInput) {
665 const runtime = yield* makeAcpRegistryManagementRuntime(input);
666 const listed = yield* runtime.listProviders;
667 return AcpRegistryListProvidersResult.make({
668 providers: listed.providers.slice(0, 64).flatMap((provider) => {
669 const providerId = boundedOpaqueValue(provider.providerId, 256);
670 if (providerId === undefined) return [];
671 const supported = provider.supported
672 .flatMap((protocol) => {
673 const value = boundedOpaqueValue(protocol, 64);
674 return value === undefined ? [] : [value];
675 })
676 .slice(0, 16);
677 const current = provider.current;
678 const currentApiType =
679 current == null ? undefined : boundedOpaqueValue(current.apiType, 64);
680 const currentBaseUrl =
681 current == null ? undefined : normalizeAcpRegistryWebUrl(current.baseUrl);
682 return [
683 {
684 providerId,
685 supported,
686 required: provider.required,
687 current:
688 currentApiType === undefined || currentBaseUrl === undefined
689 ? null
690 : { apiType: currentApiType, baseUrl: currentBaseUrl },
691 },
692 ];
693 }),
694 });
695 },
696 (effect) =>
697 effect.pipe(
698 Effect.scoped,
699 Effect.timeoutOrElse({
700 duration: MANAGEMENT_TIMEOUT,
701 orElse: () =>
702 Effect.fail(
703 new AcpRegistryOperationError({
704 reason: "providers_list_failed",
705 message: "The ACP provider list request timed out.",
706 }),
707 ),
708 }),
709 Effect.mapError((error) => managementFailure("providers_list", error)),
710 ),
711);
712
713export const setAcpRegistryProvider = Effect.fn("AcpRegistryProbe.setProvider")(
714 function* (input: AcpRegistryManagementInput & EffectAcpSchema.SetProviderRequest) {
715 const runtime = yield* makeAcpRegistryManagementRuntime(input);
716 yield* runtime.setProvider({
717 providerId: input.providerId,
718 apiType: input.apiType,
719 baseUrl: input.baseUrl,
720 ...(input.headers === undefined ? {} : { headers: input.headers }),
721 });
722 },
723 (effect) =>
724 effect.pipe(
725 Effect.scoped,
726 Effect.timeoutOrElse({
727 duration: MANAGEMENT_TIMEOUT,
728 orElse: () =>
729 Effect.fail(
730 new AcpRegistryOperationError({
731 reason: "provider_configuration_failed",
732 message: "The ACP provider configuration request timed out.",
733 }),
734 ),
735 }),
736 Effect.mapError((error) => managementFailure("providers_set", error)),
737 ),
738);
739
740export const disableAcpRegistryProvider = Effect.fn("AcpRegistryProbe.disableProvider")(
741 function* (input: AcpRegistryManagementInput & { readonly providerId: string }) {
742 const runtime = yield* makeAcpRegistryManagementRuntime(input);
743 yield* runtime.disableProvider(input.providerId);
744 },
745 (effect) =>
746 effect.pipe(
747 Effect.scoped,
748 Effect.timeoutOrElse({
749 duration: MANAGEMENT_TIMEOUT,
750 orElse: () =>
751 Effect.fail(
752 new AcpRegistryOperationError({
753 reason: "provider_configuration_failed",
754 message: "The ACP provider disable request timed out.",
755 }),
756 ),
757 }),
758 Effect.mapError((error) => managementFailure("providers_disable", error)),
759 ),
760);
761
762export const logoutAcpRegistry = Effect.fn("AcpRegistryProbe.logout")(
763 function* (input: AcpRegistryManagementInput) {
764 const runtime = yield* makeAcpRegistryManagementRuntime(input);
765 yield* runtime.logout;
766 },
767 (effect) =>
768 effect.pipe(
769 Effect.scoped,
770 Effect.timeoutOrElse({
771 duration: MANAGEMENT_TIMEOUT,
772 orElse: () =>
773 Effect.fail(
774 new AcpRegistryOperationError({
775 reason: "probe_failed",
776 message: "The ACP logout request timed out.",
777 }),
778 ),
779 }),
780 Effect.mapError((error) => managementFailure("logout", error)),
781 ),
782);
packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.npm.fixture.sh created+21
......@@ -0,0 +1,21 @@
1#!/bin/sh
2printf '%s\n' "$*" >> "$FAKE_NPM_LOG"
3prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}"
4if [ "$1" = "root" ] && [ "$2" = "--global" ]; then
5 printf '%s\n' "$prefix/lib/node_modules"
6 exit 0
7fi
8if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then
9 printf '%s\n' "$prefix"
10 exit 0
11fi
12if [ "$1" = "install" ] && [ "$2" = "--global" ]; then
13 package_root="$prefix/lib/node_modules/@example/acp"
14 executable="$prefix/bin/example-agent"
15 mkdir -p "$package_root" "$prefix/bin"
16 printf '%s' "$FAKE_NPM_MANIFEST" > "$package_root/package.json"
17 printf '#!/bin/sh\n' > "$executable"
18 chmod 755 "$executable"
19 exit 0
20fi
21exit 64
packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.uv.fixture.sh created+22
......@@ -0,0 +1,22 @@
1#!/bin/sh
2printf '%s\n' "$*" >> "$FAKE_UV_LOG"
3tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}"
4executable="$tool_bin/fast-agent"
5printf "tool-dir=%s bin-dir=%s\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG"
6if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then
7 printf '%s\n' "$tool_bin"
8 exit 0
9fi
10if [ "$1" = "tool" ] && [ "$2" = "list" ]; then
11 if [ -x "$executable" ]; then
12 printf 'fast-agent-acp v0.10.1\n- fast-agent\n'
13 fi
14 exit 0
15fi
16if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then
17 mkdir -p "$tool_bin"
18 printf '#!/bin/sh\n' > "$executable"
19 chmod 755 "$executable"
20 exit 0
21fi
22exit 64
packages/provider-acp-registry/src/settings.ts created+76
......@@ -0,0 +1,76 @@
1/**
2 * ACP Registry instance settings. Shared by the server driver and the client
3 * settings form, so it holds only browser-safe schema code.
4 *
5 * @module provider-acp-registry/settings
6 */
7import { TrimmedString, makeProviderSettingsSchema } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10
11export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]);
12export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type;
13
14export const AcpRegistrySettings = makeProviderSettingsSchema(
15 {
16 source: Schema.Literals(["registry", "local"]).pipe(
17 Schema.withDecodingDefault(Effect.succeed("registry")),
18 Schema.annotateKey({
19 title: "ACP source",
20 providerSettingsForm: {
21 control: "select",
22 options: [
23 { value: "registry", label: "ACP Registry" },
24 { value: "local", label: "Local command" },
25 ],
26 },
27 }),
28 ),
29 enabled: Schema.Boolean.pipe(
30 Schema.withDecodingDefault(Effect.succeed(true)),
31 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
32 ),
33 agentId: TrimmedString.pipe(
34 Schema.withDecodingDefault(Effect.succeed("")),
35 Schema.annotateKey({
36 title: "Registry agent ID",
37 description: "Agent identifier from the official ACP Registry, for example 'devin'.",
38 providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" },
39 }),
40 ),
41 commandPath: TrimmedString.pipe(
42 Schema.withDecodingDefault(Effect.succeed("")),
43 Schema.annotateKey({
44 title: "Executable override",
45 description:
46 "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.",
47 providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" },
48 }),
49 ),
50 commandArgs: Schema.Array(Schema.String).pipe(
51 Schema.withDecodingDefault(Effect.succeed([])),
52 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
53 ),
54 authMethodId: TrimmedString.pipe(
55 Schema.withDecodingDefault(Effect.succeed("")),
56 Schema.annotateKey({
57 title: "Authentication method",
58 description:
59 "Optional ACP authentication method ID. By default, the first agent-managed method is selected.",
60 providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" },
61 }),
62 ),
63 distribution: AcpRegistryDistributionPreference.pipe(
64 Schema.withDecodingDefault(Effect.succeed("auto")),
65 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
66 ),
67 customModels: Schema.Array(Schema.String).pipe(
68 Schema.withDecodingDefault(Effect.succeed([])),
69 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
70 ),
71 },
72 {
73 order: ["source", "agentId", "commandPath", "authMethodId"],
74 },
75);
76export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;
packages/provider-acp-registry/src/testing.ts created+29
......@@ -0,0 +1,29 @@
1/**
2 * ACP Registry internals the server's replay testkit and ACP adapter tests
3 * drive directly.
4 *
5 * @module provider-acp-registry/testing
6 */
7export {
8 ACP_REGISTRY_DEFAULT_INSTANCE_ID,
9 ACP_REGISTRY_PROVIDER,
10 acpRegistryPromptFailure,
11 makeAcpRegistryAdapterV2,
12 registerMistralVibeAcpExtensions,
13} from "./server/adapter.ts";
14export {
15 extractDevinSubagentUpdate,
16 normalizeDevinSessionUpdate,
17 normalizeDevinToolCall,
18} from "./server/devinAcp.ts";
19export {
20 acpRegistryProbeFailure,
21 acpRegistryProbeResult,
22 listAcpRegistrySessions,
23 logoutAcpRegistry,
24 normalizeAcpRegistryAuthMethods,
25 normalizeAcpRegistryCommands,
26 probeAcpRegistryConfiguration,
27 type AcpRegistryAvailableCommands,
28 type AcpRegistryLiveConfiguration,
29} from "./server/probe.ts";
packages/provider-acp-registry/tsconfig.json created+8
......@@ -0,0 +1,8 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"],
5 "lib": ["ESNext", "esnext.disposable"]
6 },
7 "include": ["src"]
8}
packages/provider-acp/src/server/AcpSessionRuntime.ts+1-11
......@@ -1329,17 +1329,7 @@ export class AcpSessionRuntime extends Context.Service<
13291329 payload: unknown,
13301330 ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
13311331 }
1332>()("@t3tools/provider-acp/server/AcpSessionRuntime") {
1333 static layer(
1334 options: AcpSessionRuntimeOptions,
1335 ): Layer.Layer<
1336 AcpSessionRuntime,
1337 EffectAcpErrors.AcpError,
1338 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
1339 > {
1340 return Layer.effect(AcpSessionRuntime, make(options));
1341 }
1342}
1332>()("@t3tools/provider-acp/server/AcpSessionRuntime") {}
13431333
13441334interface AcpStartedState extends AcpSessionRuntimeStartResult {}
13451335
packages/provider-core/package.json+4
......@@ -99,6 +99,10 @@
9999 "types": "./src/server/ProviderAdapter.ts",
100100 "import": "./src/server/ProviderAdapter.ts"
101101 },
102 "./server/ProviderAuthFlow": {
103 "types": "./src/server/ProviderAuthFlow.ts",
104 "import": "./src/server/ProviderAuthFlow.ts"
105 },
102106 "./server/ProviderEventLoggers": {
103107 "types": "./src/server/ProviderEventLoggers.ts",
104108 "import": "./src/server/ProviderEventLoggers.ts"
packages/provider-core/src/server/ProviderAuthFlow.test.ts created+559
......@@ -0,0 +1,559 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 ProviderInstanceId,
5 ProviderSetupError,
6 type ProviderAuthInteraction,
7 type ProviderAuthResponse,
8 type ProviderAuthState,
9} from "@t3tools/contracts";
10import * as Fiber from "effect/Fiber";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as ProviderAuthFlow from "./ProviderAuthFlow.ts";
20
21const instanceId = ProviderInstanceId.make("auth-flow-test");
22const method = { id: "browser", name: "Browser", description: null, type: "agent" as const };
23
24it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () =>
25 Effect.gen(function* () {
26 const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>();
27 const controller = yield* ProviderAuthFlow.make({
28 instanceId,
29 credentialBinding: { owner: "provider", key: "shared-agent" },
30 methods: Deferred.await(discovered),
31 authenticate: () => Effect.void,
32 logout: Effect.void,
33 });
34 const pending = yield* controller
35 .subscribe("owner")
36 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
37 assert.isUndefined(pending.methods);
38 yield* Deferred.succeed(discovered, []);
39 const ready = yield* controller.subscribe("owner").pipe(
40 Stream.filter((state) => state.methods !== undefined),
41 Stream.runHead,
42 Effect.map(Option.getOrThrow),
43 );
44 assert.deepEqual(ready.methods, []);
45 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
46);
47
48const makeHarness = Effect.gen(function* () {
49 const approved = yield* Deferred.make<void>();
50 const verified = yield* Deferred.make<void>();
51 const started = yield* Deferred.make<void>();
52 let attempts = 0;
53 const controller = yield* ProviderAuthFlow.make({
54 instanceId,
55 credentialBinding: { owner: "provider", key: "shared-agent" },
56 methods: Effect.succeed([method]),
57 authenticate: (_, context) =>
58 Effect.gen(function* () {
59 attempts++;
60 yield* context.setInteraction(
61 {
62 type: "browser",
63 id: "consent",
64 url: "https://example.com/login",
65 requiresConsent: true,
66 },
67 (response) =>
68 response.type === "browser" && response.action === "accept"
69 ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid)
70 : Effect.void,
71 );
72 yield* Deferred.succeed(started, undefined);
73 yield* Deferred.await(approved);
74 yield* context.verifying;
75 yield* Deferred.await(verified);
76 }),
77 logout: Effect.void,
78 });
79 const phase = (phase: ProviderAuthState["phase"], owner = "owner") =>
80 controller.subscribe(owner).pipe(
81 Stream.filter((state) => state.phase === phase),
82 Stream.runHead,
83 Effect.map(Option.getOrThrow),
84 );
85 return { controller, phase, started, verified, attempts: () => attempts };
86});
87
88it.effect("requires owner consent and provider verification before success", () =>
89 Effect.gen(function* () {
90 const { controller, phase, started, verified, attempts } = yield* makeHarness;
91 const state = yield* controller.start("owner");
92 yield* Deferred.await(started);
93 const response = {
94 instanceId,
95 flowId: state.flowId!,
96 interactionId: "consent",
97 response: { type: "browser" as const, action: "accept" as const },
98 };
99 const other = yield* phase("waiting", "other");
100 assert.isNull(other.interaction);
101 assert.isNull(other.flowId);
102 assert.isTrue(
103 (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure",
104 );
105 assert.isTrue(
106 (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe(
107 Effect.result,
108 ))._tag === "Failure",
109 );
110 yield* controller.start("owner");
111 assert.strictEqual(attempts(), 1);
112 yield* controller.respond!("owner", response);
113 yield* phase("verifying");
114 yield* Deferred.succeed(verified, undefined);
115 assert.strictEqual((yield* phase("succeeded")).phase, "succeeded");
116 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
117);
118
119it.effect("cancellation rejects late consent and permits another login", () =>
120 Effect.gen(function* () {
121 const { controller, started, phase } = yield* makeHarness;
122 const state = yield* controller.start("owner");
123 yield* Deferred.await(started);
124 yield* controller.cancel("owner", state.flowId!);
125 assert.strictEqual((yield* phase("cancelled")).phase, "cancelled");
126 assert.isTrue(
127 (yield* controller.respond!("owner", {
128 instanceId,
129 flowId: state.flowId!,
130 interactionId: "consent",
131 response: { type: "browser", action: "accept" },
132 }).pipe(Effect.result))._tag === "Failure",
133 );
134 const next = yield* controller.start("other");
135 assert.notStrictEqual(next.flowId, state.flowId);
136 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
137);
138
139it.effect("expires pending login and rejects its response", () =>
140 Effect.gen(function* () {
141 const { controller, started, phase } = yield* makeHarness;
142 const state = yield* controller.start("owner");
143 yield* Deferred.await(started);
144 yield* TestClock.adjust(300_001);
145 assert.include((yield* phase("failed")).message ?? "", "expired");
146 assert.isTrue(
147 (yield* controller.respond!("owner", {
148 instanceId,
149 flowId: state.flowId!,
150 interactionId: "consent",
151 response: { type: "browser", action: "accept" },
152 }).pipe(Effect.result))._tag === "Failure",
153 );
154 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
155);
156
157it.effect("closes admitted provider processes and blocks new ones while login is pending", () =>
158 Effect.gen(function* () {
159 const { controller, started } = yield* makeHarness;
160 let closed = false;
161 yield* controller.withAccess!(
162 Effect.addFinalizer(() =>
163 Effect.sync(() => {
164 closed = true;
165 }),
166 ),
167 );
168 yield* controller.start("owner");
169 yield* Deferred.await(started);
170 assert.isTrue(closed);
171 assert.isTrue(
172 (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure",
173 );
174 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
175);
176
177it.effect("shared credential invalidation closes admitted processes before permitting reuse", () =>
178 Effect.gen(function* () {
179 const { controller } = yield* makeHarness;
180 let closed = false;
181 yield* controller.withAccess!(
182 Effect.addFinalizer(() =>
183 Effect.sync(() => {
184 closed = true;
185 }),
186 ),
187 );
188 yield* controller.invalidate!;
189 assert.isTrue(closed);
190 assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process");
191 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
192);
193
194it.effect.each([
195 {
196 interaction: { type: "terminal", id: "terminal", output: "Log in" },
197 response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } },
198 },
199 {
200 interaction: {
201 type: "credentials",
202 id: "credentials",
203 fields: [{ name: "token", label: "Token", secret: true }],
204 },
205 response: { type: "credentials", values: { token: "private-token" } },
206 },
207] satisfies ReadonlyArray<{
208 interaction: ProviderAuthInteraction;
209 response: ProviderAuthResponse;
210}>)(
211 "keeps $interaction.type responses private and waits for adapter verification",
212 ({ interaction, response }) =>
213 Effect.gen(function* () {
214 const received = yield* Deferred.make<ProviderAuthResponse>();
215 const controller = yield* ProviderAuthFlow.make({
216 instanceId,
217 credentialBinding: { owner: "t3", key: "binding" },
218 methods: Effect.succeed([method]),
219 authenticate: (_, context) =>
220 Effect.gen(function* () {
221 yield* context.setInteraction(interaction, (response) =>
222 Deferred.succeed(received, response).pipe(Effect.asVoid),
223 );
224 yield* Deferred.await(received);
225 yield* context.verifying;
226 return yield* Effect.never;
227 }),
228 logout: Effect.void,
229 });
230 const state = yield* controller.start("owner");
231 const pending = yield* controller.subscribe("owner").pipe(
232 Stream.filter((state) => state.phase === "waiting"),
233 Stream.runHead,
234 Effect.map(Option.getOrThrow),
235 );
236 assert.deepStrictEqual(pending.interaction, interaction);
237 const hidden = yield* controller
238 .subscribe("other")
239 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
240 assert.isNull(hidden.interaction);
241 yield* controller.respond!("owner", {
242 instanceId,
243 flowId: state.flowId!,
244 interactionId: interaction.id,
245 response,
246 });
247 assert.deepStrictEqual(yield* Deferred.await(received), response);
248 const verifying = yield* controller.subscribe("owner").pipe(
249 Stream.filter((state) => state.phase === "verifying"),
250 Stream.runHead,
251 Effect.map(Option.getOrThrow),
252 );
253 assert.isNull(verifying.interaction);
254 assert.notInclude(verifying.message ?? "", "private-token");
255 yield* controller.cancel("owner", state.flowId!);
256 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
257);
258
259it.effect("shows a device code only to its owner without treating it as authenticated", () =>
260 Effect.gen(function* () {
261 const controller = yield* ProviderAuthFlow.make({
262 instanceId,
263 credentialBinding: { owner: "provider", key: "device" },
264 methods: Effect.succeed([method]),
265 authenticate: (_, context) =>
266 context
267 .setInteraction({
268 type: "deviceCode",
269 id: "code",
270 url: "https://example.com/device",
271 userCode: "ABCD-EFGH",
272 })
273 .pipe(Effect.andThen(Effect.never)),
274 logout: Effect.void,
275 });
276 const start = yield* controller.start("owner");
277 const waiting = yield* controller.subscribe("owner").pipe(
278 Stream.filter((state) => state.phase === "waiting"),
279 Stream.runHead,
280 Effect.map(Option.getOrThrow),
281 );
282 assert.strictEqual(waiting.interaction?.type, "deviceCode");
283 assert.strictEqual(waiting.authorizationUrl, "https://example.com/device");
284 const other = yield* controller
285 .subscribe("other")
286 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
287 assert.isNull(other.interaction);
288 assert.isNull(other.authorizationUrl);
289 yield* controller.cancel("owner", start.flowId!);
290 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
291);
292
293it.effect.each([
294 {
295 failure: Effect.fail(
296 new ProviderSetupError({
297 instanceId,
298 operation: "authenticate",
299 detail: "This account cannot sign in. Choose another account.",
300 }),
301 ),
302 message: "This account cannot sign in. Choose another account.",
303 },
304 {
305 failure: Effect.die(new Error("private-token in native diagnostics")),
306 message: "Sign-in failed. Start again.",
307 },
308])("publishes only safe authentication failure text %#", ({ failure, message }) =>
309 Effect.gen(function* () {
310 const controller = yield* ProviderAuthFlow.make({
311 instanceId,
312 credentialBinding: { owner: "t3", key: "failure" },
313 methods: Effect.succeed([method]),
314 authenticate: () => failure,
315 logout: Effect.void,
316 });
317 yield* controller.start("owner");
318 const failed = yield* controller.subscribe("owner").pipe(
319 Stream.filter((state) => state.phase === "failed"),
320 Stream.runHead,
321 Effect.map(Option.getOrThrow),
322 );
323 assert.strictEqual(failed.message, message);
324 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
325);
326
327const makeBlockingResponseHarness = Effect.gen(function* () {
328 const entered = yield* Deferred.make<void>();
329 const cleanupStarted = yield* Deferred.make<void>();
330 const cleanupReleased = yield* Deferred.make<void>();
331 const cleanupFinished = yield* Deferred.make<void>();
332 const authenticationFinished = yield* Deferred.make<void>();
333 let responses = 0;
334 const controller = yield* ProviderAuthFlow.make({
335 instanceId,
336 credentialBinding: { owner: "t3", key: "blocked-response" },
337 methods: Effect.succeed([method]),
338 authenticate: (_, context) =>
339 Effect.gen(function* () {
340 yield* context.setInteraction(
341 {
342 type: "browser",
343 id: "blocked",
344 url: "https://example.com/login",
345 requiresConsent: true,
346 },
347 () =>
348 Effect.gen(function* () {
349 responses++;
350 yield* Deferred.succeed(entered, undefined);
351 return yield* Effect.never;
352 }).pipe(
353 Effect.ensuring(
354 Effect.gen(function* () {
355 yield* Deferred.succeed(cleanupStarted, undefined);
356 yield* Deferred.await(cleanupReleased);
357 yield* Deferred.succeed(cleanupFinished, undefined);
358 }),
359 ),
360 ),
361 );
362 yield* Deferred.await(authenticationFinished);
363 }),
364 logout: Effect.void,
365 });
366 const start = yield* controller.start("owner");
367 yield* controller.subscribe("owner").pipe(
368 Stream.filter((state) => state.phase === "waiting"),
369 Stream.runHead,
370 );
371 const input = {
372 instanceId,
373 flowId: start.flowId!,
374 interactionId: "blocked",
375 response: { type: "browser" as const, action: "accept" as const },
376 };
377 const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild);
378 yield* Deferred.await(entered);
379 return {
380 controller,
381 input,
382 response,
383 cleanupStarted,
384 cleanupReleased,
385 cleanupFinished,
386 authenticationFinished,
387 responses: () => responses,
388 };
389});
390
391it.effect.each(["cancel", "logout"] as const)(
392 "%s interrupts and awaits a blocked adapter response before admitting another sign-in",
393 (action) =>
394 Effect.gen(function* () {
395 const harness = yield* makeBlockingResponseHarness;
396 const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input));
397 assert.include(duplicate.detail, "already in progress");
398 assert.equal(harness.responses(), 1);
399 const stopping = yield* (
400 action === "cancel"
401 ? harness.controller.cancel("owner", harness.input.flowId)
402 : harness.controller.logout(Effect.void)
403 ).pipe(Effect.forkChild);
404 yield* Deferred.await(harness.cleanupStarted);
405 assert.isTrue(yield* harness.controller.isChangingCredentials!);
406 assert.isUndefined(stopping.pollUnsafe());
407 const premature = yield* Effect.flip(harness.controller.start("other"));
408 assert.include(premature.detail, "in progress");
409 yield* Deferred.succeed(harness.cleanupReleased, undefined);
410 yield* Fiber.join(stopping);
411 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
412 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
413 assert.isFalse(yield* harness.controller.isChangingCredentials!);
414 assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId);
415 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
416);
417
418it.effect("timeout interrupts and drains a blocked response before publishing failure", () =>
419 Effect.gen(function* () {
420 const harness = yield* makeBlockingResponseHarness;
421 yield* TestClock.adjust(300_001);
422 yield* Deferred.await(harness.cleanupStarted);
423 assert.isTrue(yield* harness.controller.isChangingCredentials!);
424 const duringCleanup = yield* harness.controller
425 .subscribe("owner")
426 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
427 assert.notEqual(duringCleanup.phase, "failed");
428 yield* Deferred.succeed(harness.cleanupReleased, undefined);
429 const failed = yield* harness.controller.subscribe("owner").pipe(
430 Stream.filter((state) => state.phase === "failed"),
431 Stream.runHead,
432 Effect.map(Option.getOrThrow),
433 );
434 assert.include(failed.message ?? "", "expired");
435 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
436 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
437 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
438);
439
440it.effect(
441 "successful authentication drains a still-running response before admitting provider access",
442 () =>
443 Effect.gen(function* () {
444 const harness = yield* makeBlockingResponseHarness;
445 yield* Deferred.succeed(harness.authenticationFinished, undefined);
446 yield* Deferred.await(harness.cleanupStarted);
447 const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void));
448 assert.include(denied.detail, "changing");
449 yield* Deferred.succeed(harness.cleanupReleased, undefined);
450 const succeeded = yield* harness.controller.subscribe("owner").pipe(
451 Stream.filter((state) => state.phase === "succeeded"),
452 Stream.runHead,
453 Effect.map(Option.getOrThrow),
454 );
455 assert.equal(succeeded.phase, "succeeded");
456 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
457 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
458 yield* harness.controller.withAccess!(Effect.void);
459 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
460);
461
462it.effect("closing the controller scope interrupts and drains its adapter response", () =>
463 Effect.gen(function* () {
464 const scope = yield* Scope.make();
465 const harness = yield* makeBlockingResponseHarness.pipe(
466 Effect.provideService(Scope.Scope, scope),
467 );
468 const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild);
469 yield* Deferred.await(harness.cleanupStarted);
470 assert.isUndefined(closing.pollUnsafe());
471 yield* Deferred.succeed(harness.cleanupReleased, undefined);
472 yield* Fiber.join(closing);
473 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
474 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
475 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
476);
477
478it.effect("a failed method refresh keeps the last discovered methods", () =>
479 Effect.gen(function* () {
480 let fail = false;
481 const controller = yield* ProviderAuthFlow.make({
482 instanceId,
483 credentialBinding: { owner: "provider", key: "shared-agent" },
484 methods: Effect.suspend(() =>
485 fail
486 ? Effect.fail(
487 new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }),
488 )
489 : Effect.succeed([method]),
490 ),
491 authenticate: () => Effect.void,
492 logout: Effect.void,
493 });
494 yield* controller.refreshMethods!;
495 fail = true;
496 yield* controller.refreshMethods!;
497 const state = yield* controller
498 .subscribe("owner")
499 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
500 assert.deepEqual(state.methods, [method]);
501 assert.strictEqual(state.message, "interrupted");
502 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
503);
504
505it.effect("rebuilding a controller leaves sessions it admitted to their owners", () =>
506 Effect.gen(function* () {
507 const scope = yield* Scope.make();
508 const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope));
509 let closed = false;
510 yield* controller.withAccess!(
511 Effect.addFinalizer(() =>
512 Effect.sync(() => {
513 closed = true;
514 }),
515 ),
516 );
517 yield* Scope.close(scope, Exit.void);
518 assert.isFalse(closed);
519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
520);
521
522it.effect("profile import stops owned sessions and gates access until credentials are saved", () =>
523 Effect.gen(function* () {
524 const controller = yield* ProviderAuthFlow.make({
525 instanceId,
526 credentialBinding: { owner: "t3", key: "handoff-binding" },
527 methods: Effect.succeed([method]),
528 authenticate: () => Effect.void,
529 logout: Effect.void,
530 });
531 let closed = false;
532 yield* controller.withAccess!(
533 Effect.addFinalizer(() =>
534 Effect.sync(() => {
535 closed = true;
536 }),
537 ),
538 );
539 const writing = yield* Deferred.make<void>();
540 const release = yield* Deferred.make<void>();
541 const imported = yield* controller.adoptCredentials!(
542 Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
543 Effect.sync(() => {
544 assert.isTrue(closed);
545 }),
546 ).pipe(Effect.forkChild);
547 yield* Deferred.await(writing);
548 const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe(
549 Effect.result,
550 );
551 assert.strictEqual(denied._tag, "Failure");
552 yield* Deferred.succeed(release, undefined);
553 assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded");
554 assert.strictEqual(
555 yield* controller.withAccess!(Effect.succeed("new credential process")),
556 "new credential process",
557 );
558 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
559);
packages/provider-core/src/server/ProviderAuthFlow.ts created+525
......@@ -0,0 +1,525 @@
1import {
2 ProviderSetupError,
3 type ProviderAuthInteraction,
4 type ProviderAuthMethod,
5 type ProviderAuthResponse,
6 type ProviderAuthState,
7 type ProviderInstanceId,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Clock from "effect/Clock";
13import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Deferred from "effect/Deferred";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as Scope from "effect/Scope";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";
23
24import type * as ProviderAuth from "./auth.ts";
25
26export interface ProviderAuthFlowContext {
27 readonly flowId: string;
28 /** The lifetime timeout interrupts authenticate before publishing its expired result. */
29 readonly expiresAt: number;
30 readonly returnUrl?: string;
31 readonly callbackMode?: "server" | "client";
32 readonly setInteraction: (
33 interaction: ProviderAuthInteraction,
34 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,
35 complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>,
36 ) => Effect.Effect<void>;
37 readonly verifying: Effect.Effect<void>;
38}
39
40const isSetupError = Schema.is(ProviderSetupError);
41
42const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => {
43 const error = Cause.findErrorOption(cause);
44 return Option.isSome(error) && isSetupError(error.value)
45 ? error.value.detail
46 : "Sign-in failed. Start again.";
47};
48
49interface Flow {
50 readonly id: string;
51 readonly owner: string;
52 readonly expiresAt: number;
53 complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined;
54 fiber?: Fiber.Fiber<void>;
55 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;
56 respond:
57 | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>)
58 | undefined;
59}
60
61/** Adapters do login and credential handling; this owns client consent and flow lifetime. */
62export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
63 readonly instanceId: ProviderInstanceId;
64 readonly credentialBinding: NonNullable<ProviderAuth.ProviderAuthController["credentialBinding"]>;
65 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;
66 readonly defaultMethodId?: string;
67 /** Stored account profiles can change the advertised methods after auth/logout. */
68 readonly refreshMethodsAfterAuth?: boolean;
69 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */
70 readonly authenticate: (
71 methodId: string,
72 context: ProviderAuthFlowContext,
73 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;
74 readonly logout: Effect.Effect<void | string, ProviderSetupError>;
75 readonly timeoutMs?: number;
76}) {
77 const scope = yield* Scope.Scope;
78 const crypto = yield* Crypto.Crypto;
79 const lock = yield* Semaphore.make(1);
80 const timeoutMs = options.timeoutMs ?? 300_000;
81 const empty: ProviderAuthState = {
82 instanceId: options.instanceId,
83 phase: "idle",
84 flowId: null,
85 authorizationUrl: null,
86 expiresAt: null,
87 message: null,
88 interaction: null,
89 credentialOwner: options.credentialBinding.owner,
90 };
91 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty });
92 let active: Flow | undefined;
93 let operation: "idle" | "auth" | "stopping" | "closed" = "idle";
94 const sessions = new Set<Scope.Closeable>();
95 const stopOwnedSessions = Effect.suspend(() =>
96 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
97 discard: true,
98 concurrency: "unbounded",
99 }),
100 );
101 const publish = (flow: Flow, patch: Partial<ProviderAuthState>) =>
102 Effect.suspend(() =>
103 active === flow
104 ? SubscriptionRef.update(snapshot, (current) => ({
105 owner: flow.owner,
106 state: { ...current.state, ...patch },
107 }))
108 : Effect.void,
109 );
110
111 const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) {
112 if (
113 operation !== "auth" ||
114 !active ||
115 active.owner !== owner ||
116 active.id !== id ||
117 (yield* Clock.currentTimeMillis) >= active.expiresAt
118 ) {
119 return yield* new ProviderSetupError({
120 instanceId: options.instanceId,
121 operation: "respond",
122 detail: "This sign-in is no longer active in this client.",
123 });
124 }
125 return active;
126 });
127
128 // Method discovery does not start a sign-in flow.
129 const refreshMethods = options.methods.pipe(
130 Effect.flatMap((methods) =>
131 SubscriptionRef.update(snapshot, (current) => ({
132 ...current,
133 state: { ...current.state, methods },
134 })),
135 ),
136 // Keep the last known methods when a refresh fails or is interrupted.
137 Effect.catch((error) =>
138 SubscriptionRef.update(snapshot, (current) => ({
139 ...current,
140 state: { ...current.state, methods: current.state.methods ?? [], message: error.detail },
141 })),
142 ),
143 );
144 yield* refreshMethods.pipe(Effect.forkIn(scope));
145
146 const controller: ProviderAuth.ProviderAuthController = {
147 credentialBinding: options.credentialBinding,
148 adoptCredentials: (update, stopSessions) =>
149 Effect.gen(function* () {
150 yield* lock.withPermit(
151 Effect.gen(function* () {
152 if (operation !== "idle")
153 return yield* new ProviderSetupError({
154 instanceId: options.instanceId,
155 operation: "import",
156 detail: "Finish or cancel the existing sign-in first.",
157 });
158 operation = "stopping";
159 }),
160 );
161 const result = yield* Effect.gen(function* () {
162 yield* stopOwnedSessions;
163 yield* stopSessions;
164 yield* update;
165 yield* refreshMethods;
166 }).pipe(Effect.exit);
167 const state: ProviderAuthState = {
168 ...empty,
169 methods: snapshot.value.state.methods ?? [],
170 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
171 message: Exit.isSuccess(result) ? null : failureMessage(result.cause),
172 };
173 yield* lock.withPermit(
174 Effect.gen(function* () {
175 yield* SubscriptionRef.set(snapshot, { owner: null, state });
176 operation = "idle";
177 }),
178 );
179 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
180 return state;
181 }).pipe(Effect.uninterruptible),
182 refreshMethods,
183 invalidate: lock.withPermit(
184 Effect.gen(function* () {
185 if (operation !== "idle") return;
186 yield* stopOwnedSessions;
187 yield* SubscriptionRef.set(snapshot, {
188 owner: null,
189 state: {
190 ...empty,
191 methods: snapshot.value.state.methods ?? [],
192 message: "This provider's shared sign-in changed.",
193 },
194 });
195 }),
196 ),
197 isChangingCredentials: Effect.sync(() => operation !== "idle"),
198 withAccess: (task) =>
199 Effect.uninterruptibleMask((restore) =>
200 Effect.gen(function* () {
201 const parent = yield* Scope.Scope;
202 const child = yield* lock.withPermit(
203 Effect.gen(function* () {
204 if (operation !== "idle")
205 return yield* new ProviderSetupError({
206 instanceId: options.instanceId,
207 operation: "session",
208 detail: "Provider sign-in is changing. Try again after it finishes.",
209 });
210 const child = yield* Scope.make();
211 sessions.add(child);
212 yield* Scope.addFinalizer(
213 child,
214 Effect.sync(() => {
215 sessions.delete(child);
216 }),
217 );
218 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
219 return child;
220 }),
221 );
222 const fiber = yield* restore(task).pipe(
223 Effect.provideService(Scope.Scope, child),
224 Effect.forkIn(child),
225 );
226 return yield* restore(Fiber.await(fiber)).pipe(
227 Effect.flatMap((result) => result),
228 Effect.onExit((result) =>
229 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
230 ),
231 );
232 }),
233 ),
234 start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) =>
235 lock.withPermit(
236 Effect.gen(function* () {
237 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;
238 if (operation !== "idle")
239 return yield* new ProviderSetupError({
240 instanceId: options.instanceId,
241 operation: "start",
242 detail: "Provider setup is already in progress.",
243 });
244 const id = yield* crypto.randomUUIDv4.pipe(
245 Effect.mapError(
246 () =>
247 new ProviderSetupError({
248 instanceId: options.instanceId,
249 operation: "start",
250 detail: "Could not start sign-in. Try again.",
251 }),
252 ),
253 );
254 const flow: Flow = {
255 id,
256 owner,
257 expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs,
258 respond: undefined,
259 };
260 active = flow;
261 operation = "auth";
262 const state: ProviderAuthState = {
263 ...empty,
264 methods: snapshot.value.state.methods ?? [],
265 phase: "starting",
266 flowId: id,
267 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)),
268 message: "Starting sign-in.",
269 };
270 yield* SubscriptionRef.set(snapshot, { owner, state });
271 flow.fiber = yield* Effect.gen(function* () {
272 const methods = yield* options.methods;
273 yield* publish(flow, { methods });
274 const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id;
275 if (!methodId || !methods.some((method) => method.id === methodId))
276 return yield* new ProviderSetupError({
277 instanceId: options.instanceId,
278 operation: "start",
279 detail: "The provider did not advertise this sign-in method.",
280 });
281 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
282 yield* options.authenticate(methodId, {
283 flowId: id,
284 expiresAt: flow.expiresAt,
285 ...(returnUrl ? { returnUrl } : {}),
286 ...(callbackMode ? { callbackMode } : {}),
287 setInteraction: (interaction, respond, complete) =>
288 Effect.gen(function* () {
289 if (active !== flow) return;
290 flow.respond = respond;
291 flow.complete = complete;
292 yield* publish(flow, {
293 phase: "waiting",
294 interaction,
295 authorizationUrl:
296 interaction.type === "browser" || interaction.type === "deviceCode"
297 ? interaction.url
298 : null,
299 message: "Complete sign-in to continue.",
300 });
301 }),
302 verifying: Effect.gen(function* () {
303 flow.respond = undefined;
304 flow.complete = undefined;
305 yield* publish(flow, {
306 phase: "verifying",
307 interaction: null,
308 authorizationUrl: null,
309 message: "Checking provider sign-in.",
310 });
311 }),
312 });
313 }).pipe(
314 Effect.scoped,
315 Effect.timeoutOrElse({
316 duration: timeoutMs,
317 orElse: () =>
318 Effect.fail(
319 new ProviderSetupError({
320 instanceId: options.instanceId,
321 operation: "start",
322 detail: "Sign-in expired. Start again.",
323 }),
324 ),
325 }),
326 Effect.exit,
327 Effect.flatMap((result) =>
328 Effect.gen(function* () {
329 const finishing = yield* lock.withPermit(
330 Effect.sync(() => {
331 if (active !== flow) return false;
332 operation = "stopping";
333 return true;
334 }),
335 );
336 if (!finishing) return;
337 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
338 yield* lock.withPermit(
339 Effect.gen(function* () {
340 if (active !== flow) return;
341 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
342 yield* publish(flow, {
343 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
344 interaction: null,
345 authorizationUrl: null,
346 expiresAt: null,
347 message: Exit.isSuccess(result)
348 ? "Sign-in complete."
349 : failureMessage(result.cause),
350 });
351 active = undefined;
352 operation = "idle";
353 }),
354 );
355 }),
356 ),
357 Effect.interruptible,
358 Effect.forkIn(scope),
359 );
360 return state;
361 }).pipe(Effect.uninterruptible),
362 ),
363 respond: (owner, input) =>
364 Effect.uninterruptibleMask((restore) =>
365 Effect.gen(function* () {
366 const { flow, fiber, ready } = yield* lock.withPermit(
367 Effect.gen(function* () {
368 const flow = yield* requireFlow(owner, input.flowId);
369 const interaction = snapshot.value.state.interaction;
370 if (
371 !interaction ||
372 interaction.id !== input.interactionId ||
373 interaction.type !== input.response.type ||
374 !flow.respond
375 )
376 return yield* new ProviderSetupError({
377 instanceId: options.instanceId,
378 operation: "respond",
379 detail: "This sign-in interaction is no longer available.",
380 });
381 if (flow.responseFiber)
382 return yield* new ProviderSetupError({
383 instanceId: options.instanceId,
384 operation: "respond",
385 detail: "A sign-in response is already in progress.",
386 });
387 const ready = yield* Deferred.make<void>();
388 const callback = flow.respond;
389 const fiber = yield* Deferred.await(ready).pipe(
390 Effect.andThen(Effect.suspend(() => callback(input.response))),
391 Effect.interruptible,
392 Effect.forkIn(scope),
393 );
394 flow.responseFiber = fiber;
395 return { flow, fiber, ready };
396 }),
397 );
398 yield* Deferred.succeed(ready, undefined);
399 yield* restore(Fiber.join(fiber)).pipe(
400 Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)),
401 Effect.ensuring(
402 lock.withPermit(
403 Effect.sync(() => {
404 if (flow.responseFiber === fiber) delete flow.responseFiber;
405 }),
406 ),
407 ),
408 );
409 return snapshot.value.state;
410 }),
411 ),
412 complete: (owner, input) =>
413 lock.withPermit(
414 Effect.gen(function* () {
415 const flow = yield* requireFlow(owner, input.flowId);
416 const interaction = snapshot.value.state.interaction;
417 if (
418 snapshot.value.state.phase !== "waiting" ||
419 interaction?.type !== "browser" ||
420 !interaction.acceptsCallback ||
421 !flow.complete
422 )
423 return yield* new ProviderSetupError({
424 instanceId: options.instanceId,
425 operation: "complete",
426 detail: "This sign-in does not accept a redirect URL.",
427 });
428 yield* flow.complete(input.callbackUrl);
429 return snapshot.value.state;
430 }),
431 ),
432 cancel: (owner, id) =>
433 Effect.gen(function* () {
434 const flow = yield* lock.withPermit(
435 Effect.gen(function* () {
436 const flow = yield* requireFlow(owner, id);
437 yield* publish(flow, {
438 phase: "cancelled",
439 interaction: null,
440 authorizationUrl: null,
441 expiresAt: null,
442 message: "Sign-in cancelled.",
443 });
444 active = undefined;
445 operation = "stopping";
446 return flow;
447 }),
448 );
449 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
450 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
451 operation = "idle";
452 return snapshot.value.state;
453 }).pipe(Effect.uninterruptible),
454 logout: (stopSessions) =>
455 Effect.gen(function* () {
456 const flow = yield* lock.withPermit(
457 Effect.gen(function* () {
458 if (operation !== "idle" && operation !== "auth")
459 return yield* new ProviderSetupError({
460 instanceId: options.instanceId,
461 operation: "logout",
462 detail: "Provider setup is already stopping.",
463 });
464 operation = "stopping";
465 const flow = active;
466 active = undefined;
467 return flow;
468 }),
469 );
470 const result = yield* Effect.gen(function* () {
471 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
472 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
473 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
474 const message = yield* options.logout;
475 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
476 return message;
477 }).pipe(Effect.exit);
478 const state: ProviderAuthState = {
479 ...empty,
480 methods: snapshot.value.state.methods ?? [],
481 phase: Exit.isSuccess(result) ? "idle" : "failed",
482 message: Exit.isSuccess(result)
483 ? (result.value ?? "Signed out.")
484 : "Could not sign out. Try again.",
485 };
486 yield* lock.withPermit(
487 Effect.gen(function* () {
488 yield* SubscriptionRef.set(snapshot, { owner: null, state });
489 operation = "idle";
490 }),
491 );
492 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
493 return state;
494 }).pipe(Effect.uninterruptible),
495 subscribe: (owner) =>
496 SubscriptionRef.changes(snapshot).pipe(
497 Stream.map((current) =>
498 current.owner === null || current.owner === owner
499 ? current.state
500 : {
501 ...current.state,
502 flowId: null,
503 authorizationUrl: null,
504 interaction: null,
505 expiresAt: null,
506 message: active
507 ? "Sign-in is in progress in another client."
508 : current.state.message,
509 },
510 ),
511 ),
512 };
513 yield* Effect.addFinalizer(() =>
514 Effect.gen(function* () {
515 operation = "closed";
516 const flow = active;
517 active = undefined;
518 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
519 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
520 // Settings edits rebuild instances; admitted sessions (including those of
521 // peers sharing this binding) end with their own scopes, not this one.
522 }),
523 );
524 return controller;
525});
packages/provider-core/src/server/ProviderHost.ts+4
......@@ -49,6 +49,10 @@ interface ProviderHostService {
4949 readonly paths: ProviderHostPaths;
5050 readonly settings: {
5151 readonly get: Effect.Effect<ServerSettings, ServerSettingsError>;
52 /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */
53 readonly withSnapshot: <A, E, R>(
54 use: (settings: ServerSettings) => Effect.Effect<A, E, R>,
55 ) => Effect.Effect<A, E | ServerSettingsError, R>;
5256 /** Every settings change after subscription, starting with the next one. */
5357 readonly changes: Stream.Stream<ServerSettings>;
5458 /** Changes buffered from the moment the scoped subscription is acquired. */
packages/provider-core/src/server/managedProvider.test.ts+2
......@@ -113,6 +113,7 @@ function layerProviderHost(input: {
113113 },
114114 settings: input.settings ?? {
115115 get: Effect.succeed(DEFAULT_SERVER_SETTINGS),
116 withSnapshot: (use) => use(DEFAULT_SERVER_SETTINGS),
116117 changes: Stream.empty,
117118 subscribe: Effect.succeed(Stream.empty),
118119 },
......@@ -285,6 +286,7 @@ describe("makeManagedServerProvider", () => {
285286 const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>();
286287 const hostSettings: ProviderHost.ProviderHost["Service"]["settings"] = {
287288 get: Ref.get(serverSettingsRef),
289 withSnapshot: (use) => Ref.get(serverSettingsRef).pipe(Effect.flatMap(use)),
288290 changes: Stream.empty,
289291 subscribe: PubSub.subscribe(serverSettingsChanges).pipe(
290292 Effect.map((subscription) => Stream.fromSubscription(subscription)),
packages/provider-opencode/src/server/OpenCodeRuntime.ts+6-6
......@@ -105,10 +105,10 @@ export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERRO
105105 readonly operation: string;
106106 readonly cause?: unknown;
107107 readonly detail: string;
108}> {
109 static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
110 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
111}
108}> {}
109
110export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError =>
111 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
112112
113113function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
114114 const result = encodeUnknownJsonStringExit(input);
......@@ -116,7 +116,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
116116}
117117
118118export function openCodeRuntimeErrorDetail(cause: unknown): string {
119 if (OpenCodeRuntimeError.is(cause)) return cause.detail;
119 if (isOpenCodeRuntimeError(cause)) return cause.detail;
120120 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
121121 if (cause && typeof cause === "object") {
122122 // SDK v2 throws { response, request, error? } shapes — extract what's useful
......@@ -583,7 +583,7 @@ function ensureRuntimeError(
583583 detail: string,
584584 cause: unknown,
585585): OpenCodeRuntimeError {
586 return OpenCodeRuntimeError.is(cause)
586 return isOpenCodeRuntimeError(cause)
587587 ? cause
588588 : new OpenCodeRuntimeError({ operation, detail, cause });
589589}
packages/provider-opencode/src/server/v2/OpenCode2Server.ts+1-1
......@@ -123,7 +123,7 @@ export const verifyServer = (client: OpenCodeClient) =>
123123 ),
124124 }),
125125 Effect.mapError((cause) =>
126 OpenCodeRuntime.OpenCodeRuntimeError.is(cause)
126 OpenCodeRuntime.isOpenCodeRuntimeError(cause)
127127 ? cause
128128 : new OpenCodeRuntime.OpenCodeRuntimeError({
129129 operation: "server.info",
packages/provider-testing/src/host.ts+24-6
......@@ -7,26 +7,38 @@
77 */
88import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
99import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
10import * as Context from "effect/Context";
1011import * as Effect from "effect/Effect";
1112import * as FileSystem from "effect/FileSystem";
1213import * as Layer from "effect/Layer";
1314import * as Option from "effect/Option";
1415import * as Path from "effect/Path";
16import * as Ref from "effect/Ref";
1517import * as Stream from "effect/Stream";
1618
1719export interface TestProviderHostOptions {
1820 /** Session fallback cwd. Defaults to the test process cwd. */
1921 readonly cwd?: string;
22 /** Initial settings. A test changes them through `TestProviderHostSettings`. */
2023 readonly settings?: ServerSettings;
2124 /** Whether background work such as status probes may run. Defaults to `true`. */
2225 readonly runBackgroundWork?: boolean;
2326}
2427
28/** Lets a test change the settings its `layerTestProviderHost` reports. */
29export class TestProviderHostSettings extends Context.Service<
30 TestProviderHostSettings,
31 { readonly set: (settings: ServerSettings) => Effect.Effect<void> }
32>()("@t3tools/provider-testing/host/TestProviderHostSettings") {}
33
2534export const layerTestProviderHost = (
2635 options: TestProviderHostOptions = {},
27): Layer.Layer<ProviderHost.ProviderHost, never, FileSystem.FileSystem | Path.Path> =>
28 Layer.effect(
29 ProviderHost.ProviderHost,
36): Layer.Layer<
37 ProviderHost.ProviderHost | TestProviderHostSettings,
38 never,
39 FileSystem.FileSystem | Path.Path
40> =>
41 Layer.effectContext(
3042 Effect.gen(function* () {
3143 const fileSystem = yield* FileSystem.FileSystem;
3244 const path = yield* Path.Path;
......@@ -39,9 +51,9 @@ export const layerTestProviderHost = (
3951 for (const directory of [stateDir, providerStatusCacheDir, attachmentsDir]) {
4052 yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie);
4153 }
42 const settings = options.settings ?? DEFAULT_SERVER_SETTINGS;
54 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
4355 const credentials = new Map<string, Uint8Array>();
44 return ProviderHost.ProviderHost.of({
56 const host = ProviderHost.ProviderHost.of({
4557 paths: {
4658 cwd: options.cwd ?? process.cwd(),
4759 baseDir,
......@@ -50,7 +62,8 @@ export const layerTestProviderHost = (
5062 attachmentsDir,
5163 },
5264 settings: {
53 get: Effect.succeed(settings),
65 get: Ref.get(settings),
66 withSnapshot: (use) => Ref.get(settings).pipe(Effect.flatMap(use)),
5467 changes: Stream.empty,
5568 subscribe: Effect.succeed(Stream.empty),
5669 },
......@@ -69,5 +82,10 @@ export const layerTestProviderHost = (
6982 };
7083 }),
7184 });
85 return Context.make(ProviderHost.ProviderHost, host).pipe(
86 Context.add(TestProviderHostSettings, {
87 set: (next) => Ref.set(settings, next),
88 }),
89 );
7290 }),
7391 );
packages/shared/package.json+4
......@@ -470,6 +470,10 @@
470470 "./mcpResponsePayloads": {
471471 "types": "./src/mcpResponsePayloads.ts",
472472 "import": "./src/mcpResponsePayloads.ts"
473 },
474 "./PtyAdapter": {
475 "types": "./src/PtyAdapter.ts",
476 "import": "./src/PtyAdapter.ts"
473477 }
474478 },
475479 "scripts": {
packages/shared/src/PtyAdapter.ts created+66
......@@ -0,0 +1,66 @@
1/**
2 * PtyAdapter - Terminal PTY adapter service contract.
3 *
4 * Defines the process primitives required by terminal session management
5 * without binding to a specific PTY implementation.
6 *
7 * @module PtyAdapter
8 */
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12
13/**
14 * PtySpawnError - Error type for PTY spawn failures.
15 */
16export class PtySpawnError extends Schema.TaggedError<PtySpawnError>()("PtySpawnError", {
17 adapter: Schema.String,
18 shell: Schema.optional(Schema.String),
19 attemptedShells: Schema.optional(Schema.Array(Schema.String)),
20 cause: Schema.optional(Schema.Defect()),
21}) {
22 override get message(): string {
23 const shell = this.shell === undefined ? "" : ` '${this.shell}'`;
24 const attemptedShells =
25 this.attemptedShells === undefined || this.attemptedShells.length === 0
26 ? ""
27 : ` Tried shells: ${this.attemptedShells.join(", ")}.`;
28 return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`;
29 }
30}
31
32export interface PtyExitEvent {
33 exitCode: number;
34 signal: number | null;
35}
36
37export interface PtyProcess {
38 readonly pid: number;
39 write(data: string): void;
40 resize(cols: number, rows: number): void;
41 kill(signal?: string): void;
42 onData(callback: (data: string) => void): () => void;
43 onExit(callback: (event: PtyExitEvent) => void): () => void;
44}
45
46export interface PtySpawnInput {
47 shell: string;
48 args?: string[];
49 cwd: string;
50 cols: number;
51 rows: number;
52 env: NodeJS.ProcessEnv;
53}
54
55/**
56 * PtyAdapter - Service tag for PTY process integration.
57 */
58export class PtyAdapter extends Context.Service<
59 PtyAdapter,
60 {
61 /**
62 * Spawn a PTY process for a terminal session.
63 */
64 readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>;
65 }
66>()("@t3tools/shared/PtyAdapter") {}
pnpm-lock.yaml+49-3
......@@ -353,6 +353,9 @@ importers:
353353 '@t3tools/mobile-terminal-native':
354354 specifier: file:./modules/t3-terminal
355355 version: file:apps/mobile/modules/t3-terminal
356 '@t3tools/provider-acp-registry':
357 specifier: workspace:*
358 version: link:../../packages/provider-acp-registry
356359 '@t3tools/provider-core':
357360 specifier: workspace:*
358361 version: link:../../packages/provider-core
......@@ -597,15 +600,15 @@ importers:
597600 '@napi-rs/keyring':
598601 specifier: ^1.3.0
599602 version: 1.3.0
600 '@noble/hashes':
601 specifier: 'catalog:'
602 version: 1.8.0
603603 '@opencode-ai/sdk':
604604 specifier: ^1.3.15
605605 version: 1.15.13
606606 '@t3tools/provider-acp':
607607 specifier: workspace:*
608608 version: link:../../packages/provider-acp
609 '@t3tools/provider-acp-registry':
610 specifier: workspace:*
611 version: link:../../packages/provider-acp-registry
609612 '@t3tools/provider-core':
610613 specifier: workspace:*
611614 version: link:../../packages/provider-core
......@@ -748,6 +751,9 @@ importers:
748751 '@t3tools/contracts':
749752 specifier: workspace:*
750753 version: link:../../packages/contracts
754 '@t3tools/provider-acp-registry':
755 specifier: workspace:*
756 version: link:../../packages/provider-acp-registry
751757 '@t3tools/provider-core':
752758 specifier: workspace:*
753759 version: link:../../packages/provider-core
......@@ -1138,6 +1144,46 @@ importers:
11381144 specifier: 1.0.0
11391145 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11401146
1147 packages/provider-acp-registry:
1148 dependencies:
1149 '@noble/hashes':
1150 specifier: 'catalog:'
1151 version: 1.8.0
1152 '@t3tools/contracts':
1153 specifier: workspace:*
1154 version: link:../contracts
1155 '@t3tools/provider-acp':
1156 specifier: workspace:*
1157 version: link:../provider-acp
1158 '@t3tools/provider-core':
1159 specifier: workspace:*
1160 version: link:../provider-core
1161 '@t3tools/shared':
1162 specifier: workspace:*
1163 version: link:../shared
1164 effect:
1165 specifier: 4.0.1
1166 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1167 devDependencies:
1168 '@effect/platform-node':
1169 specifier: 4.0.1
1170 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6)
1171 '@effect/vitest':
1172 specifier: 4.0.1
1173 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1174 '@t3tools/provider-testing':
1175 specifier: workspace:*
1176 version: link:../provider-testing
1177 '@types/node':
1178 specifier: 24.12.4
1179 version: 24.12.4
1180 effect-acp:
1181 specifier: workspace:*
1182 version: link:../effect-acp
1183 vite-plus:
1184 specifier: 1.0.0
1185 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1186
11411187 packages/provider-core:
11421188 dependencies:
11431189 '@t3tools/contracts':
scripts/release-smoke.ts+1
......@@ -32,6 +32,7 @@ const workspaceFiles = [
3232 "packages/provider-testing/package.json",
3333 "packages/provider-pi/package.json",
3434 "packages/provider-acp/package.json",
35 "packages/provider-acp-registry/package.json",
3536 "packages/provider-cursor/package.json",
3637 "packages/provider-grok/package.json",
3738 "packages/provider-muse/package.json",