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 * as HostProcess from "@t3tools/shared/HostProcess";
16import {
17 mergePathEntries,
18 resolveSpawnCommand,
19 SpawnExecutableResolution,
20} from "@t3tools/shared/shell";
21import * as Clock from "effect/Clock";
22import * as Context from "effect/Context";
23import * as Duration from "effect/Duration";
24import * as Crypto from "effect/Crypto";
25import * as Effect from "effect/Effect";
26import * as Hex from "effect/encoding/Hex";
27import * as FileSystem from "effect/FileSystem";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
31import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
32import * as PlatformError from "effect/PlatformError";
33import * as Ref from "effect/Ref";
34import * as Schema from "effect/Schema";
35import * as Semaphore from "effect/Semaphore";
36import * as Stream from "effect/Stream";
37import { sha256 } from "@noble/hashes/sha2";
38import * as HttpClient from "effect/http/HttpClient";
39import * as HttpClientRequest from "effect/http/HttpClientRequest";
40import * as HttpClientResponse from "effect/http/HttpClientResponse";
41import * as ChildProcess from "effect/process/ChildProcess";
42import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
43
44import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
45import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
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 = Effect.fn("acpRegistryManagedBinaryDirectories")(
311 function* (input: {
312 readonly cacheDir: string;
313 readonly toolsDir: string;
314 readonly platform: NodeJS.Platform;
315 readonly architecture: NodeJS.Architecture;
316 }): Effect.fn.Return<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path> {
317 const fileSystem = yield* FileSystem.FileSystem;
318 const path = yield* Path.Path;
319 const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture);
320 const registryDirectory = path.join(input.cacheDir, "acp-registry");
321 const installsDirectory = input.toolsDir;
322 const packageReceiptsDirectory = path.join(registryDirectory, "package-installs");
323 const listDirectories = (directory: string) =>
324 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* fileSystem
329 .readFileString(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 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 path.dirname(receipt.value.executablePath) === receipt.value.binDirectory &&
345 (yield* 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* fileSystem
355 .readFileString(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(path.join(installsDirectory, agent))).toSorted(
375 (left, right) => right.localeCompare(left, undefined, { numeric: true }),
376 );
377 for (const version of versions) {
378 const installRoot = path.join(installsDirectory, agent, version, target);
379 if (yield* 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 : path.join(installRoot, ...commandSegments.slice(0, -1));
388 if (yield* fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) {
389 directories.push(directory);
390 }
391 }
392 }
393 }
394 return Array.from(new Set(directories));
395 },
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* HostProcess.Platform;
637 const architecture = yield* HostProcess.Architecture;
638 const hostEnvironment = yield* HostProcess.Environment;
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});