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 * as HostProcess from "@t3tools/shared/HostProcess";
10import { SpawnExecutableResolution } from "@t3tools/shared/shell";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as Fiber from "effect/Fiber";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18import * as HttpClient from "effect/http/HttpClient";
19import * as HttpClientResponse from "effect/http/HttpClientResponse";
20import * as TestClock from "effect/testing/TestClock";
21import * as ChildProcess from "effect/process/ChildProcess";
22import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
23
24import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
25import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
26
27const registryUrl = "https://registry.test/registry.json";
28const archiveUrl = "https://registry.test/example-agent.bin";
29const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings);
30const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
31
32function makeAgent(
33 distribution: AcpRegistrySupport.AcpRegistryAgent["distribution"],
34): AcpRegistrySupport.AcpRegistryAgent {
35 return {
36 id: "example-agent",
37 name: "Example Agent",
38 version: "1.2.3",
39 description: "ACP Registry test agent",
40 distribution,
41 };
42}
43
44function makeRegistry(agent: AcpRegistrySupport.AcpRegistryAgent): string {
45 return JSON.stringify({ version: "1.0.0", agents: [agent] });
46}
47
48function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings {
49 return decodeAcpRegistrySettings({
50 agentId: "example-agent",
51 ...input,
52 });
53}
54
55function layerResolver(
56 execute: Parameters<typeof HttpClient.make>[0],
57 environment: NodeJS.ProcessEnv = process.env,
58) {
59 return Layer.mergeAll(
60 NodeServices.layer,
61 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
62 Layer.succeed(HostProcess.Platform, "linux"),
63 Layer.succeed(HostProcess.Architecture, "x64"),
64 Layer.succeed(HostProcess.Environment, environment),
65 Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)),
66 );
67}
68
69const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* (
70 rootDirectory: string,
71) {
72 const fileSystem = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const toolchainBin = path.join(rootDirectory, "fake-node", "bin");
75 const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm");
76 const globalBin = path.join(globalPrefix, "bin");
77 const executablePath = path.join(globalBin, "example-agent");
78 const npmPath = path.join(toolchainBin, "npm");
79 const logPath = path.join(rootDirectory, "npm.log");
80 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
81 yield* fileSystem.makeDirectory(globalPrefix, { recursive: true });
82 yield* fileSystem.writeFileString(logPath, "");
83 yield* fileSystem.copyFile(
84 yield* path.fromFileUrl(
85 new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url),
86 ),
87 npmPath,
88 );
89 yield* fileSystem.chmod(npmPath, 0o755);
90 return {
91 environment: {
92 ...process.env,
93 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
94 FAKE_NPM_LOG: logPath,
95 FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"),
96 FAKE_NPM_MANIFEST: encodeUnknownJson({
97 name: "@example/acp",
98 version: "1.2.3",
99 bin: { "example-agent": "dist/cli.js" },
100 }),
101 } satisfies NodeJS.ProcessEnv,
102 executablePath,
103 globalBin,
104 logPath,
105 npmPath,
106 };
107});
108
109const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* (
110 rootDirectory: string,
111) {
112 const fileSystem = yield* FileSystem.FileSystem;
113 const path = yield* Path.Path;
114 const toolchainBin = path.join(rootDirectory, "fake-uv", "bin");
115 const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin");
116 const executablePath = path.join(globalBin, "fast-agent");
117 const uvPath = path.join(toolchainBin, "uv");
118 const logPath = path.join(rootDirectory, "uv.log");
119 yield* fileSystem.makeDirectory(toolchainBin, { recursive: true });
120 yield* fileSystem.writeFileString(logPath, "");
121 yield* fileSystem.copyFile(
122 yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)),
123 uvPath,
124 );
125 yield* fileSystem.chmod(uvPath, 0o755);
126 return {
127 environment: {
128 ...process.env,
129 PATH: `${toolchainBin}:${process.env.PATH ?? ""}`,
130 FAKE_UV_LOG: logPath,
131 FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"),
132 FAKE_UV_EXECUTABLE: executablePath,
133 } satisfies NodeJS.ProcessEnv,
134 executablePath,
135 globalBin,
136 logPath,
137 uvPath,
138 };
139});
140
141describe("AcpRegistrySupport", () => {
142 it("preserves the registry failure when translating it for clients", () => {
143 const cause = new Error("registry unavailable");
144 const failure = new AcpRegistrySupport.AcpRegistryError({
145 reason: "registry_unavailable",
146 detail: "Could not load the ACP Registry.",
147 cause,
148 });
149
150 expect(AcpRegistrySupport.toAcpRegistryOperationError(failure)).toMatchObject({
151 reason: "registry_unavailable",
152 message: "Could not load the ACP Registry.",
153 cause: failure,
154 });
155 });
156
157 it("maps supported Node platforms to ACP Registry target keys", () => {
158 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe(
159 "darwin-aarch64",
160 );
161 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "x64")).toBe(
162 "linux-x86_64",
163 );
164 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe(
165 "windows-aarch64",
166 );
167 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined();
168 expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined();
169 });
170
171 it("selects the preferred compatible distribution", () => {
172 const agent = makeAgent({
173 binary: {
174 "linux-x86_64": {
175 archive: archiveUrl,
176 cmd: "./bin/example-agent",
177 args: ["acp"],
178 },
179 },
180 npx: {
181 package: "@example/acp@1.2.3",
182 args: ["--stdio"],
183 },
184 });
185
186 expect(
187 AcpRegistrySupport.resolveAcpRegistryDistribution({
188 agent,
189 preference: "auto",
190 platformTarget: "linux-x86_64",
191 }),
192 ).toMatchObject({ kind: "binary", args: ["acp"] });
193 expect(
194 AcpRegistrySupport.resolveAcpRegistryDistribution({
195 agent,
196 preference: "npx",
197 platformTarget: "linux-x86_64",
198 }),
199 ).toEqual({
200 kind: "npx",
201 packageName: "@example/acp@1.2.3",
202 args: ["--stdio"],
203 env: {},
204 });
205 expect(
206 AcpRegistrySupport.resolveAcpRegistryDistribution({
207 agent,
208 preference: "binary",
209 platformTarget: "darwin-aarch64",
210 }),
211 ).toBeUndefined();
212 });
213
214 it.effect("resolves a local command on the selected environment without the registry", () => {
215 const requests: Array<string> = [];
216 return Effect.gen(function* () {
217 const fileSystem = yield* FileSystem.FileSystem;
218 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" });
219 const commandPath = `${cacheDir}/dsh`;
220 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
221 yield* fileSystem.chmod(commandPath, 0o755);
222 const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" };
223 const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" };
224 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
225 cacheDir,
226 toolsDir: `${cacheDir}/tools`,
227 registryUrl,
228 }).pipe(Effect.provideService(HostProcess.Environment, hostEnvironment));
229 const localSettings = decodeAcpRegistrySettings({
230 source: "local",
231 commandPath: "dsh",
232 commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
233 });
234
235 expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" });
236 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
237 status: "ready",
238 agentId: "dsh",
239 version: null,
240 distribution: "local",
241 });
242 const resolved = yield* resolver.resolve(localSettings, "/workspace", environment);
243 expect(resolved).toEqual({
244 distribution: "local",
245 spawn: {
246 command: commandPath,
247 args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"],
248 cwd: "/workspace",
249 env: environment,
250 shell: false,
251 },
252 });
253 expect(requests).toEqual([]);
254 expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false);
255 }).pipe(
256 Effect.scoped,
257 Effect.provide(
258 layerResolver((request) => {
259 requests.push(request.url);
260 return Effect.die("unexpected registry request for a local provider");
261 }),
262 ),
263 );
264 });
265
266 it.effect("resolves a local executable path and inherits the host environment", () =>
267 Effect.gen(function* () {
268 const fileSystem = yield* FileSystem.FileSystem;
269 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" });
270 const commandPath = `${cacheDir}/dsh wrapper`;
271 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
272 yield* fileSystem.chmod(commandPath, 0o755);
273 const environment = { PATH: "/unused", INHERITED: "host" };
274 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
275 cacheDir,
276 toolsDir: `${cacheDir}/tools`,
277 registryUrl,
278 }).pipe(Effect.provideService(HostProcess.Environment, environment));
279 const resolved = yield* resolver.resolve(
280 decodeAcpRegistrySettings({ source: "local", commandPath }),
281 "/workspace",
282 );
283
284 expect(resolved.spawn).toEqual({
285 command: commandPath,
286 args: [],
287 cwd: "/workspace",
288 env: environment,
289 shell: false,
290 });
291 expect(resolved.agent).toBeUndefined();
292
293 for (const extension of [".cmd", ".BAT"]) {
294 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
295 cacheDir,
296 toolsDir: `${cacheDir}/tools`,
297 registryUrl,
298 }).pipe(
299 Effect.provideService(HostProcess.Platform, "win32"),
300 Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`),
301 );
302 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" });
303 for (const failure of [
304 yield* windowsResolver.inspect(localSettings).pipe(Effect.flip),
305 yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip),
306 ]) {
307 expect(failure).toMatchObject({
308 reason: "runner_unavailable",
309 detail: expect.stringContaining("node.exe"),
310 });
311 }
312 }
313 }).pipe(
314 Effect.scoped,
315 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
316 ),
317 );
318
319 it.effect("reports missing or non-executable local commands without registry fallback", () =>
320 Effect.gen(function* () {
321 const fileSystem = yield* FileSystem.FileSystem;
322 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
323 prefix: "t3-acp-local-missing-",
324 });
325 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
326 cacheDir,
327 toolsDir: `${cacheDir}/tools`,
328 registryUrl,
329 });
330 const environment = { PATH: cacheDir };
331 const unconfigured = decodeAcpRegistrySettings({ source: "local" });
332 expect(yield* resolver.inspect(unconfigured, environment)).toEqual({
333 status: "unconfigured",
334 });
335 expect(
336 yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip),
337 ).toMatchObject({ reason: "agent_not_configured" });
338
339 const nonExecutable = `${cacheDir}/non-executable`;
340 yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n");
341 yield* fileSystem.chmod(nonExecutable, 0o644);
342 for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) {
343 const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath });
344 expect(yield* resolver.inspect(localSettings, environment)).toEqual({
345 status: "missing_runner",
346 version: null,
347 distribution: "local",
348 });
349 expect(
350 yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip),
351 ).toMatchObject({
352 reason: "runner_unavailable",
353 detail: "Local ACP executable is not available on this environment's PATH.",
354 });
355 }
356 }).pipe(
357 Effect.scoped,
358 Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))),
359 ),
360 );
361
362 it.effect("resolves command overrides while preserving registry args and environment", () => {
363 const agent = makeAgent({
364 binary: {
365 "linux-x86_64": {
366 archive: archiveUrl,
367 cmd: "./bin/example-agent",
368 args: ["acp", "--stdio"],
369 env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" },
370 },
371 },
372 });
373 const requests: Array<string> = [];
374 return Effect.gen(function* () {
375 const fileSystem = yield* FileSystem.FileSystem;
376 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
377 prefix: "t3-acp-registry-override-",
378 });
379 const commandPath = `${cacheDir}/example-agent`;
380 yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n");
381 yield* fileSystem.chmod(commandPath, 0o755);
382 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
383 cacheDir,
384 toolsDir: `${cacheDir}/tools`,
385 registryUrl,
386 });
387 const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", {
388 HOST_VALUE: "host",
389 OVERRIDE_ME: "host",
390 });
391
392 expect(resolved.distribution).toBe("binary");
393 expect(resolved.spawn).toEqual({
394 command: commandPath,
395 args: ["acp", "--stdio"],
396 cwd: "/workspace",
397 env: {
398 HOST_VALUE: "host",
399 OVERRIDE_ME: "registry",
400 REGISTRY_VALUE: "registry",
401 },
402 });
403 expect(requests).toEqual([registryUrl]);
404 }).pipe(
405 Effect.scoped,
406 Effect.provide(
407 layerResolver((request) => {
408 requests.push(request.url);
409 return Effect.succeed(
410 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
411 );
412 }),
413 ),
414 );
415 });
416
417 it.effect("installs into T3 home a package and launches its exposed command", () => {
418 const agent = makeAgent({
419 npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] },
420 });
421 return Effect.gen(function* () {
422 const fileSystem = yield* FileSystem.FileSystem;
423 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
424 prefix: "t3-acp-registry-global-package-",
425 });
426 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
427 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
428 cacheDir,
429 toolsDir: `${cacheDir}/tools`,
430 registryUrl,
431 }).pipe(Effect.provideService(HostProcess.Environment, toolchain.environment));
432
433 const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
434 const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
435
436 expect(first.spawn).toMatchObject({
437 command: toolchain.executablePath,
438 args: ["--stdio"],
439 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
440 });
441 expect(second.spawn.command).toBe(toolchain.executablePath);
442 const npmCommands = yield* fileSystem.readFileString(toolchain.logPath);
443 expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1);
444 expect(npmCommands).toContain("install --global @example/acp@V1.2.3");
445 }).pipe(
446 Effect.scoped,
447 Effect.provide(
448 layerResolver((request) =>
449 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
450 ),
451 ),
452 );
453 });
454
455 it.effect("uses its managed prefix despite a system-owned npm prefix", () => {
456 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
457 return Effect.gen(function* () {
458 const fileSystem = yield* FileSystem.FileSystem;
459 const path = yield* Path.Path;
460 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
461 prefix: "t3-acp-registry-user-global-package-",
462 });
463 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
464 const systemPrefix = path.join(cacheDir, "system-global");
465 yield* fileSystem.makeDirectory(systemPrefix);
466 yield* fileSystem.chmod(systemPrefix, 0o555);
467 const userHome = path.join(cacheDir, "home");
468 const environment = {
469 ...toolchain.environment,
470 HOME: userHome,
471 FAKE_NPM_PREFIX: systemPrefix,
472 };
473 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
474 cacheDir,
475 toolsDir: `${cacheDir}/tools`,
476 registryUrl,
477 }).pipe(Effect.provideService(HostProcess.Environment, environment));
478
479 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
480 const userGlobalBin = toolchain.globalBin;
481 expect(resolved.spawn).toMatchObject({
482 command: path.join(userGlobalBin, "example-agent"),
483 env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) },
484 });
485 }).pipe(
486 Effect.scoped,
487 Effect.provide(
488 layerResolver((request) =>
489 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
490 ),
491 ),
492 );
493 });
494
495 it.effect("installs into T3 home a uv tool and launches its exposed command", () => {
496 const agent = makeAgent({
497 uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] },
498 });
499 return Effect.gen(function* () {
500 const fileSystem = yield* FileSystem.FileSystem;
501 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
502 prefix: "t3-acp-registry-global-uv-tool-",
503 });
504 const toolchain = yield* makeFakeUvToolchain(cacheDir);
505 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
506 cacheDir,
507 toolsDir: `${cacheDir}/tools`,
508 registryUrl,
509 });
510
511 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
512
513 expect(resolved.spawn).toMatchObject({
514 command: toolchain.executablePath,
515 args: ["--acp"],
516 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
517 });
518 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
519 "tool install --force fast-agent-acp==V0.10.1",
520 );
521 }).pipe(
522 Effect.scoped,
523 Effect.provide(
524 layerResolver((request) =>
525 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
526 ),
527 ),
528 );
529 });
530
531 it.effect("honors an exact Windows PATH override when launching a global package", () => {
532 const agent = makeAgent({
533 uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] },
534 });
535 return Effect.gen(function* () {
536 const fileSystem = yield* FileSystem.FileSystem;
537 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
538 prefix: "t3-acp-registry-windows-package-path-",
539 });
540 const toolchain = yield* makeFakeUvToolchain(cacheDir);
541 const linuxResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
542 cacheDir,
543 toolsDir: `${cacheDir}/tools`,
544 registryUrl,
545 });
546 yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment);
547
548 const windowsEnvironment = {
549 ...toolchain.environment,
550 Path: "C:\\host\\bin",
551 PATH: "C:\\provider\\bin",
552 };
553 const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
554 cacheDir,
555 toolsDir: `${cacheDir}/tools`,
556 registryUrl,
557 }).pipe(
558 Effect.provideService(HostProcess.Platform, "win32"),
559 Effect.provideService(SpawnExecutableResolution, (command) => {
560 if (command === "uv") return toolchain.uvPath;
561 if (command === "fast-agent") return toolchain.executablePath;
562 return undefined;
563 }),
564 );
565 const resolved = yield* windowsResolver.resolve(
566 settings(),
567 "C:\\workspace",
568 windowsEnvironment,
569 );
570
571 expect(resolved.spawn).toMatchObject({
572 env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` },
573 });
574 }).pipe(
575 Effect.scoped,
576 Effect.provide(
577 layerResolver((request) =>
578 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
579 ),
580 ),
581 );
582 });
583
584 it.effect("installs and reuses a registry binary in the managed cache", () => {
585 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
586 const agent = makeAgent({
587 binary: {
588 "linux-x86_64": {
589 archive: archiveUrl,
590 cmd: "./bin/example-agent",
591 args: ["acp"],
592 // SHA-256 of binaryBytes.
593 sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7",
594 },
595 },
596 });
597 const requests: Array<string> = [];
598 return Effect.gen(function* () {
599 const fileSystem = yield* FileSystem.FileSystem;
600 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
601 prefix: "t3-acp-registry-install-",
602 });
603 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
604 cacheDir,
605 toolsDir: `${cacheDir}/tools`,
606 registryUrl,
607 });
608 const first = yield* resolver.resolve(settings(), "/workspace");
609 const second = yield* resolver.resolve(settings(), "/workspace");
610
611 expect(first.spawn.command).toBe(second.spawn.command);
612 expect(first.spawn.command).toContain(
613 "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent",
614 );
615 expect(yield* fileSystem.readFileString(first.spawn.command)).toBe(
616 "#!/bin/sh\necho example\n",
617 );
618 expect(requests).toEqual([registryUrl, archiveUrl]);
619 }).pipe(
620 Effect.scoped,
621 Effect.provide(
622 layerResolver((request) => {
623 requests.push(request.url);
624 const response =
625 request.url === registryUrl
626 ? new Response(makeRegistry(agent))
627 : new Response(
628 new ReadableStream<Uint8Array>({
629 start(controller) {
630 controller.enqueue(binaryBytes.slice(0, 8));
631 controller.enqueue(binaryBytes.slice(8));
632 controller.close();
633 },
634 }),
635 );
636 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
637 }),
638 ),
639 );
640 });
641
642 it.effect(
643 "prepares the registry version despite an older PATH binary and permits explicit overrides",
644 () => {
645 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n");
646 const agent = makeAgent({
647 binary: {
648 "linux-x86_64": {
649 archive: archiveUrl,
650 cmd: "./bin/example-agent",
651 args: ["acp"],
652 // SHA-256 of binaryBytes.
653 sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31",
654 },
655 },
656 });
657 const requests: Array<string> = [];
658 return Effect.gen(function* () {
659 const fileSystem = yield* FileSystem.FileSystem;
660 const path = yield* Path.Path;
661 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
662 prefix: "t3-acp-registry-system-",
663 });
664 const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({
665 prefix: "t3-acp-registry-system-bin-",
666 });
667 const systemBinary = path.join(systemBinDir, "example-agent");
668 yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n");
669 yield* fileSystem.chmod(systemBinary, 0o755);
670 const environment = { PATH: systemBinDir };
671
672 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
673 cacheDir,
674 toolsDir: `${cacheDir}/tools`,
675 registryUrl,
676 }).pipe(Effect.provideService(HostProcess.Environment, environment));
677 yield* resolver.search({ query: "example" });
678 expect(yield* resolver.inspect(settings(), environment)).toMatchObject({
679 status: "unprepared",
680 });
681
682 const overridden = settings({ commandPath: systemBinary });
683 expect(yield* resolver.inspect(overridden, environment)).toMatchObject({
684 status: "ready",
685 version: null,
686 });
687 const custom = yield* resolver.resolve(overridden, "/workspace", environment);
688 expect(custom.spawn.command).toBe(systemBinary);
689 expect(custom.spawn.args).toEqual(["acp"]);
690 expect(requests).toEqual([registryUrl]);
691
692 yield* resolver.prepare({ agentId: "example-agent" });
693 const resolved = yield* resolver.resolve(settings(), "/workspace", environment);
694 expect(resolved.spawn.command).not.toBe(systemBinary);
695 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
696 "#!/bin/sh\necho managed\n",
697 );
698 expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n");
699
700 const inspection = yield* resolver.inspect(settings(), environment);
701 expect(inspection).toMatchObject({
702 status: "ready",
703 distribution: "binary",
704 version: "1.2.3",
705 });
706
707 expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]);
708 }).pipe(
709 Effect.scoped,
710 Effect.provide(
711 layerResolver((request) => {
712 requests.push(request.url);
713 return Effect.succeed(
714 HttpClientResponse.fromWeb(
715 request,
716 request.url === registryUrl
717 ? new Response(makeRegistry(agent))
718 : new Response(binaryBytes.buffer as ArrayBuffer),
719 ),
720 );
721 }),
722 ),
723 );
724 },
725 );
726
727 it.effect("installs a tar archive containing a root directory entry", () => {
728 const downloadUrl = "https://registry.test/agent.tar.gz";
729 const agent = makeAgent({
730 binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } },
731 });
732 return Effect.gen(function* () {
733 const fileSystem = yield* FileSystem.FileSystem;
734 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" });
735 const source = `${cacheDir}/source`;
736 yield* fileSystem.makeDirectory(source);
737 yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n");
738 const archivePath = `${cacheDir}/agent.tar.gz`;
739 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
740 const tar = yield* spawner.spawn(
741 ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]),
742 );
743 expect(yield* tar.exitCode).toBe(0);
744 const archive = yield* fileSystem.readFile(archivePath);
745 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
746 cacheDir,
747 toolsDir: `${cacheDir}/tools`,
748 registryUrl,
749 }).pipe(
750 Effect.provideService(
751 HttpClient.HttpClient,
752 HttpClient.make((request) =>
753 Effect.succeed(
754 HttpClientResponse.fromWeb(
755 request,
756 request.url === registryUrl
757 ? new Response(makeRegistry(agent))
758 : new Response(archive),
759 ),
760 ),
761 ),
762 ),
763 );
764 yield* resolver.prepare({ agentId: agent.id });
765 const resolved = yield* resolver.resolve(settings(), "/workspace", {});
766 expect(resolved.spawn.command).toBe(
767 yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`),
768 );
769 expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe(
770 "#!/bin/sh\necho managed\n",
771 );
772 }).pipe(
773 Effect.scoped,
774 Effect.provide(
775 Layer.mergeAll(
776 NodeServices.layer,
777 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
778 ),
779 ),
780 Effect.provideService(HostProcess.Platform, "linux"),
781 Effect.provideService(HostProcess.Architecture, "x64"),
782 );
783 });
784
785 it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => {
786 const exact = {
787 ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }),
788 id: "codex-acp",
789 name: "Codex",
790 authors: ["OpenAI", "Zed Industries"],
791 license: "Apache-2.0",
792 website: "https://example.test/codex",
793 repository: "https://example.test/codex/source",
794 icon: "https://example.test/codex.svg",
795 } satisfies AcpRegistrySupport.AcpRegistryAgent;
796 const descriptionMatch = {
797 ...makeAgent({ npx: { package: "other-agent@1.2.3" } }),
798 id: "other-agent",
799 name: "Other Agent",
800 description: "An adapter for Codex workflows",
801 } satisfies AcpRegistrySupport.AcpRegistryAgent;
802 const incompatible = {
803 ...makeAgent({
804 binary: {
805 "darwin-aarch64": { archive: archiveUrl, cmd: "agent" },
806 },
807 }),
808 id: "darwin-only",
809 name: "Codex Darwin",
810 } satisfies AcpRegistrySupport.AcpRegistryAgent;
811
812 return Effect.gen(function* () {
813 const fileSystem = yield* FileSystem.FileSystem;
814 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
815 prefix: "t3-acp-registry-search-",
816 });
817 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
818 cacheDir,
819 toolsDir: `${cacheDir}/tools`,
820 registryUrl,
821 });
822 const result = yield* resolver.search({ query: "codex" });
823
824 expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]);
825 expect(result.agents[0]).toMatchObject({
826 authors: ["OpenAI", "Zed Industries"],
827 distribution: "npx",
828 integrity: "registry",
829 license: "Apache-2.0",
830 });
831 }).pipe(
832 Effect.scoped,
833 Effect.provide(
834 layerResolver((request) =>
835 Effect.succeed(
836 HttpClientResponse.fromWeb(
837 request,
838 new Response(
839 JSON.stringify({
840 version: "1.0.0",
841 agents: [descriptionMatch, incompatible, exact],
842 }),
843 ),
844 ),
845 ),
846 ),
847 ),
848 );
849 });
850
851 it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => {
852 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
853 let requests = 0;
854 return Effect.gen(function* () {
855 const fileSystem = yield* FileSystem.FileSystem;
856 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
857 prefix: "t3-acp-registry-refresh-",
858 });
859 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
860 cacheDir,
861 toolsDir: `${cacheDir}/tools`,
862 registryUrl,
863 });
864 yield* Effect.all(
865 [resolver.search({ query: "example" }), resolver.search({ query: "example" })],
866 { concurrency: "unbounded" },
867 );
868 expect(requests).toBe(1);
869
870 yield* resolver.search({ query: "example" });
871 expect(requests).toBe(2);
872 }).pipe(
873 Effect.scoped,
874 Effect.provide(
875 layerResolver((request) => {
876 requests += 1;
877 return Effect.yieldNow.pipe(
878 Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
879 );
880 }),
881 ),
882 );
883 });
884
885 it.effect("filters runner recipes that the environment cannot prepare", () => {
886 const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
887 const binaryAgent = {
888 ...makeAgent({
889 binary: {
890 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
891 },
892 }),
893 id: "binary-agent",
894 name: "Binary Agent",
895 } satisfies AcpRegistrySupport.AcpRegistryAgent;
896 return Effect.gen(function* () {
897 const fileSystem = yield* FileSystem.FileSystem;
898 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
899 prefix: "t3-acp-registry-runner-filter-",
900 });
901 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
902 cacheDir,
903 toolsDir: `${cacheDir}/tools`,
904 registryUrl,
905 });
906 const result = yield* resolver.search({ query: "" });
907
908 expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]);
909 }).pipe(
910 Effect.scoped,
911 Effect.provide(
912 layerResolver(
913 (request) =>
914 Effect.succeed(
915 HttpClientResponse.fromWeb(
916 request,
917 new Response(
918 JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }),
919 ),
920 ),
921 ),
922 { PATH: "" },
923 ),
924 ),
925 );
926 });
927
928 it.effect("discards registry agents with blank names or unsafe versions", () => {
929 const distribution = {
930 binary: {
931 "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" },
932 },
933 } satisfies AcpRegistrySupport.AcpRegistryAgent["distribution"];
934 const valid = makeAgent(distribution);
935 const blankName = { ...valid, id: "blank-name", name: " " };
936 const blankVersion = { ...valid, id: "blank-version", version: "\t" };
937 const parentVersion = { ...valid, id: "parent-version", version: ".." };
938 return Effect.gen(function* () {
939 const fileSystem = yield* FileSystem.FileSystem;
940 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
941 prefix: "t3-acp-registry-blank-fields-",
942 });
943 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
944 cacheDir,
945 toolsDir: `${cacheDir}/tools`,
946 registryUrl,
947 });
948 const result = yield* resolver.search({ query: "" });
949
950 expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]);
951 }).pipe(
952 Effect.scoped,
953 Effect.provide(
954 layerResolver((request) =>
955 Effect.succeed(
956 HttpClientResponse.fromWeb(
957 request,
958 new Response(
959 JSON.stringify({
960 version: "1.0.0",
961 agents: [blankName, blankVersion, parentVersion, valid],
962 }),
963 ),
964 ),
965 ),
966 ),
967 ),
968 );
969 });
970
971 it.effect("ignores unpinned runner recipes from the registry", () => {
972 const agent = makeAgent({ npx: { package: "@example/acp@latest" } });
973 return Effect.gen(function* () {
974 const fileSystem = yield* FileSystem.FileSystem;
975 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
976 prefix: "t3-acp-registry-unpinned-",
977 });
978 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
979 cacheDir,
980 toolsDir: `${cacheDir}/tools`,
981 registryUrl,
982 });
983 const result = yield* resolver.search({ query: "" });
984
985 expect(result.agents).toEqual([]);
986 }).pipe(
987 Effect.scoped,
988 Effect.provide(
989 layerResolver((request) =>
990 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
991 ),
992 ),
993 );
994 });
995
996 it.effect("rejects package syntax for the wrong runner", () => {
997 const invalidAgents = [
998 { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" },
999 ];
1000 const validAgents = [
1001 { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" },
1002 { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" },
1003 ];
1004 return Effect.gen(function* () {
1005 const fileSystem = yield* FileSystem.FileSystem;
1006 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1007 prefix: "t3-acp-registry-runner-syntax-",
1008 });
1009 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1010 cacheDir,
1011 toolsDir: `${cacheDir}/tools`,
1012 registryUrl,
1013 });
1014 const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip);
1015 const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip);
1016 const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip);
1017
1018 expect(invalid.reason).toBe("agent_not_found");
1019 expect(validAt.reason).toBe("runner_unavailable");
1020 expect(validEquals.reason).toBe("runner_unavailable");
1021 }).pipe(
1022 Effect.scoped,
1023 Effect.provide(
1024 layerResolver(
1025 (request) =>
1026 Effect.succeed(
1027 HttpClientResponse.fromWeb(
1028 request,
1029 new Response(
1030 JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }),
1031 ),
1032 ),
1033 ),
1034 { PATH: "" },
1035 ),
1036 ),
1037 );
1038 });
1039
1040 it.effect("verifies declared SHA-256 before installing a binary", () => {
1041 const agent = makeAgent({
1042 binary: {
1043 "linux-x86_64": {
1044 archive: archiveUrl,
1045 cmd: "example-agent",
1046 sha256: "0".repeat(64),
1047 },
1048 },
1049 });
1050 const binaryBytes = new TextEncoder().encode("not the declared binary");
1051
1052 return Effect.gen(function* () {
1053 const fileSystem = yield* FileSystem.FileSystem;
1054 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1055 prefix: "t3-acp-registry-checksum-",
1056 });
1057 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1058 cacheDir,
1059 toolsDir: `${cacheDir}/tools`,
1060 registryUrl,
1061 });
1062 const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip);
1063
1064 expect(error.reason).toBe("checksum_mismatch");
1065 }).pipe(
1066 Effect.scoped,
1067 Effect.provide(
1068 layerResolver((request) =>
1069 Effect.succeed(
1070 HttpClientResponse.fromWeb(
1071 request,
1072 request.url === registryUrl
1073 ? new Response(makeRegistry(agent))
1074 : new Response(binaryBytes.buffer as ArrayBuffer),
1075 ),
1076 ),
1077 ),
1078 ),
1079 );
1080 });
1081
1082 it.effect("installs into T3 home package recipes during preparation", () => {
1083 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } });
1084 const requests: string[] = [];
1085
1086 return Effect.gen(function* () {
1087 const fileSystem = yield* FileSystem.FileSystem;
1088 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1089 prefix: "t3-acp-registry-runner-",
1090 });
1091 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1092 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1093 cacheDir,
1094 toolsDir: `${cacheDir}/tools`,
1095 registryUrl,
1096 }).pipe(Effect.provideService(HostProcess.Environment, toolchain.environment));
1097 const prepared = yield* resolver.prepare({ agentId: agent.id });
1098 expect(prepared).toEqual({
1099 agentId: "example-agent",
1100 version: "1.2.3",
1101 distribution: "npx",
1102 prepared: true,
1103 });
1104 expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true);
1105 expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false);
1106 expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain(
1107 "install --global @example/acp@1.2.3",
1108 );
1109 expect(requests).toEqual([registryUrl]);
1110 }).pipe(
1111 Effect.scoped,
1112 Effect.provide(
1113 layerResolver((request) => {
1114 requests.push(request.url);
1115 return Effect.succeed(
1116 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1117 );
1118 }, process.env),
1119 ),
1120 );
1121 });
1122
1123 it.effect("falls back to a valid cached registry index when refresh fails", () => {
1124 const agent = makeAgent({
1125 npx: {
1126 package: "@example/acp@1.2.3",
1127 args: ["--stdio"],
1128 },
1129 });
1130 return Effect.gen(function* () {
1131 const fileSystem = yield* FileSystem.FileSystem;
1132 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1133 prefix: "t3-acp-registry-cache-",
1134 });
1135 const registryDirectory = `${cacheDir}/acp-registry`;
1136 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1137 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1138 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1139 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1140 cacheDir,
1141 toolsDir: `${cacheDir}/tools`,
1142 registryUrl,
1143 });
1144 const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment);
1145
1146 expect(resolved.spawn).toMatchObject({
1147 command: toolchain.executablePath,
1148 args: ["--stdio"],
1149 });
1150 }).pipe(
1151 Effect.scoped,
1152 Effect.provide(
1153 layerResolver((request) =>
1154 Effect.succeed(
1155 HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })),
1156 ),
1157 ),
1158 ),
1159 );
1160 });
1161
1162 it.effect("rejects unsafe command paths before downloading an archive", () => {
1163 const agent = makeAgent({
1164 binary: {
1165 "linux-x86_64": {
1166 archive: archiveUrl,
1167 cmd: "../outside",
1168 },
1169 },
1170 });
1171 const requests: Array<string> = [];
1172 return Effect.gen(function* () {
1173 const fileSystem = yield* FileSystem.FileSystem;
1174 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1175 prefix: "t3-acp-registry-invalid-",
1176 });
1177 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1178 cacheDir,
1179 toolsDir: `${cacheDir}/tools`,
1180 registryUrl,
1181 });
1182 const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip);
1183
1184 expect(error.reason).toBe("archive_invalid");
1185 expect(requests).toEqual([registryUrl]);
1186 }).pipe(
1187 Effect.scoped,
1188 Effect.provide(
1189 layerResolver((request) => {
1190 requests.push(request.url);
1191 return Effect.succeed(
1192 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1193 );
1194 }),
1195 ),
1196 );
1197 });
1198
1199 it.effect("inspects from disk without waiting for a registry refresh", () => {
1200 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1201 let requests = 0;
1202 return Effect.gen(function* () {
1203 const fileSystem = yield* FileSystem.FileSystem;
1204 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1205 prefix: "t3-acp-registry-cold-inspect-",
1206 });
1207 const registryDirectory = `${cacheDir}/acp-registry`;
1208 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1209 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1210
1211 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1212 cacheDir,
1213 toolsDir: `${cacheDir}/tools`,
1214 registryUrl,
1215 });
1216 const inspection = yield* resolver.inspect(settings());
1217
1218 expect(inspection).toMatchObject({ status: "ready", agentId: agent.id });
1219 expect(requests).toBe(0);
1220 }).pipe(
1221 Effect.scoped,
1222 Effect.provide(
1223 layerResolver((request) => {
1224 requests += 1;
1225 return Effect.succeed(
1226 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1227 );
1228 }),
1229 ),
1230 );
1231 });
1232
1233 it.effect("fails a cold inspection immediately when no local registry is available", () => {
1234 let requests = 0;
1235 return Effect.gen(function* () {
1236 const fileSystem = yield* FileSystem.FileSystem;
1237 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1238 prefix: "t3-acp-registry-empty-inspect-",
1239 });
1240 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1241 cacheDir,
1242 toolsDir: `${cacheDir}/tools`,
1243 registryUrl,
1244 });
1245 const error = yield* resolver.inspect(settings()).pipe(Effect.flip);
1246
1247 expect(error.reason).toBe("registry_unavailable");
1248 expect(requests).toBe(0);
1249 }).pipe(
1250 Effect.scoped,
1251 Effect.provide(
1252 layerResolver((request) => {
1253 requests += 1;
1254 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused")));
1255 }),
1256 ),
1257 );
1258 });
1259
1260 it.effect("uses the effective provider environment for inspection and resolution", () => {
1261 const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } });
1262 return Effect.gen(function* () {
1263 const fileSystem = yield* FileSystem.FileSystem;
1264 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1265 prefix: "t3-acp-registry-effective-env-",
1266 });
1267 const registryDirectory = `${cacheDir}/acp-registry`;
1268 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1269 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1270 const toolchain = yield* makeFakeNpmToolchain(cacheDir);
1271
1272 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1273 cacheDir,
1274 toolsDir: `${cacheDir}/tools`,
1275 registryUrl,
1276 });
1277 const hostInspection = yield* resolver.inspect(settings());
1278 const providerEnvironment = toolchain.environment;
1279 const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment);
1280 const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment);
1281
1282 expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" });
1283 expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" });
1284 expect(resolved.spawn).toMatchObject({
1285 command: toolchain.executablePath,
1286 env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) },
1287 });
1288 }).pipe(
1289 Effect.scoped,
1290 Effect.provide(
1291 layerResolver(
1292 (request) =>
1293 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1294 { PATH: "" },
1295 ),
1296 ),
1297 );
1298 });
1299
1300 it.effect("requires a regular executable file in the managed binary cache", () => {
1301 const agent = makeAgent({
1302 binary: {
1303 "linux-x86_64": {
1304 archive: archiveUrl,
1305 cmd: "bin/example-agent",
1306 },
1307 },
1308 });
1309 return Effect.gen(function* () {
1310 const fileSystem = yield* FileSystem.FileSystem;
1311 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1312 prefix: "t3-acp-registry-non-file-",
1313 });
1314 const registryDirectory = `${cacheDir}/acp-registry`;
1315 const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`;
1316 yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true });
1317 yield* fileSystem.makeDirectory(registryDirectory, { recursive: true });
1318 yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent));
1319
1320 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1321 cacheDir,
1322 toolsDir: `${cacheDir}/tools`,
1323 registryUrl,
1324 });
1325 const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1326
1327 expect(directoryError.reason).toBe("archive_invalid");
1328
1329 yield* fileSystem.remove(fakeExecutable, { recursive: true });
1330 yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n");
1331 yield* fileSystem.chmod(fakeExecutable, 0o644);
1332 const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip);
1333
1334 expect(modeError.reason).toBe("archive_invalid");
1335
1336 yield* fileSystem.chmod(fakeExecutable, 0o755);
1337 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1338 }).pipe(
1339 Effect.scoped,
1340 Effect.provide(
1341 layerResolver((request) =>
1342 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))),
1343 ),
1344 ),
1345 );
1346 });
1347
1348 it.effect.each([
1349 { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true },
1350 { name: "registry", source: "registry", agentId: "example-agent", referenced: true },
1351 { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true },
1352 { name: "local command", source: "local", agentId: "example-agent", referenced: false },
1353 ] as const)(
1354 "checks $name references before uninstalling only managed binaries",
1355 ({ source, agentId, referenced }) => {
1356 return Effect.gen(function* () {
1357 const fileSystem = yield* FileSystem.FileSystem;
1358 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1359 prefix: "t3-acp-registry-uninstall-",
1360 });
1361 const agentRoot = `${cacheDir}/tools/example-agent`;
1362 const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`;
1363 yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true });
1364 yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary");
1365 yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, {
1366 recursive: true,
1367 });
1368 yield* fileSystem.writeFileString(runnerCache, "{}");
1369
1370 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1371 cacheDir,
1372 toolsDir: `${cacheDir}/tools`,
1373 registryUrl,
1374 });
1375 const hostSettings = yield* TestProviderHost.TestProviderHostSettings;
1376 const instanceId = ProviderInstanceId.make("uninstall-reference");
1377 yield* hostSettings.set({
1378 ...DEFAULT_SERVER_SETTINGS,
1379 providerInstances: {
1380 [instanceId]: {
1381 driver: ProviderDriverKind.make("acpRegistry"),
1382 displayName: "Uninstall reference",
1383 enabled: true,
1384 config: {
1385 agentId,
1386 commandPath: "/local/agent",
1387 ...(source ? { source } : {}),
1388 },
1389 },
1390 },
1391 });
1392 const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1393 expect(first).toEqual({ agentId: "example-agent", removed: !referenced });
1394 expect(yield* fileSystem.exists(agentRoot)).toBe(referenced);
1395 yield* hostSettings.set(DEFAULT_SERVER_SETTINGS);
1396 const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1397
1398 expect(second).toEqual({ agentId: "example-agent", removed: referenced });
1399 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1400 agentId: "example-agent",
1401 removed: false,
1402 });
1403 expect(yield* fileSystem.exists(agentRoot)).toBe(false);
1404 expect(yield* fileSystem.exists(runnerCache)).toBe(true);
1405 }).pipe(
1406 Effect.scoped,
1407 Effect.provide(
1408 layerResolver((request) =>
1409 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))),
1410 ),
1411 ),
1412 );
1413 },
1414 );
1415
1416 it.effect("keeps managed package installs when removing the same agent's binaries", () =>
1417 Effect.gen(function* () {
1418 const fileSystem = yield* FileSystem.FileSystem;
1419 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1420 prefix: "t3-acp-uninstall-packages-",
1421 });
1422 const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`;
1423 yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true });
1424 yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary");
1425 yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true });
1426 yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command");
1427 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1428 cacheDir,
1429 toolsDir: `${cacheDir}/tools`,
1430 registryUrl,
1431 });
1432 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1433 agentId: "example-agent",
1434 removed: true,
1435 });
1436 expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false);
1437 expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe(
1438 "package command",
1439 );
1440 expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({
1441 agentId: "example-agent",
1442 removed: false,
1443 });
1444 }).pipe(
1445 Effect.scoped,
1446 Effect.provide(layerResolver(() => Effect.die("unexpected HTTP request"))),
1447 ),
1448 );
1449
1450 it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => {
1451 const agent = makeAgent({
1452 binary: {
1453 "linux-x86_64": {
1454 archive: archiveUrl,
1455 cmd: "./bin/example-agent",
1456 },
1457 },
1458 });
1459 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1460 return Effect.gen(function* () {
1461 const downloadStarted = yield* Deferred.make<void>();
1462 const releaseDownload = yield* Deferred.make<void>();
1463 return yield* Effect.gen(function* () {
1464 const fileSystem = yield* FileSystem.FileSystem;
1465 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1466 prefix: "t3-acp-registry-uninstall-race-",
1467 });
1468 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1469 cacheDir,
1470 toolsDir: `${cacheDir}/tools`,
1471 registryUrl,
1472 });
1473
1474 const prepareFiber = yield* resolver
1475 .prepare({ agentId: agent.id })
1476 .pipe(Effect.forkChild({ startImmediately: true }));
1477 yield* Deferred.await(downloadStarted);
1478 const uninstallFiber = yield* resolver
1479 .uninstallManagedBinary({ agentId: agent.id })
1480 .pipe(Effect.forkChild({ startImmediately: true }));
1481
1482 expect(uninstallFiber.pollUnsafe()).toBeUndefined();
1483 yield* Deferred.succeed(releaseDownload, undefined);
1484
1485 expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true });
1486 expect(yield* Fiber.join(uninstallFiber)).toEqual({
1487 agentId: agent.id,
1488 removed: false,
1489 });
1490 const agentRoot = `${cacheDir}/tools/${agent.id}`;
1491 expect(yield* fileSystem.exists(agentRoot)).toBe(true);
1492
1493 const hostSettings = yield* TestProviderHost.TestProviderHostSettings;
1494 const instanceId = ProviderInstanceId.make("registry-reference");
1495 yield* hostSettings.set({
1496 ...DEFAULT_SERVER_SETTINGS,
1497 providerInstances: {
1498 [instanceId]: {
1499 driver: ProviderDriverKind.make("acpRegistry"),
1500 displayName: "Registry reference",
1501 enabled: true,
1502 config: { agentId: agent.id },
1503 },
1504 },
1505 });
1506 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1507 agentId: agent.id,
1508 removed: false,
1509 });
1510 yield* hostSettings.set(DEFAULT_SERVER_SETTINGS);
1511 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1512 agentId: agent.id,
1513 removed: true,
1514 });
1515 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1516 agentId: agent.id,
1517 removed: false,
1518 });
1519 }).pipe(
1520 Effect.scoped,
1521 Effect.provide(
1522 layerResolver((request) => {
1523 if (request.url === registryUrl) {
1524 return Effect.succeed(
1525 HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))),
1526 );
1527 }
1528 return Deferred.succeed(downloadStarted, undefined).pipe(
1529 Effect.andThen(Deferred.await(releaseDownload)),
1530 Effect.as(
1531 HttpClientResponse.fromWeb(
1532 request,
1533 new Response(binaryBytes.buffer as ArrayBuffer),
1534 ),
1535 ),
1536 );
1537 }),
1538 ),
1539 );
1540 });
1541 });
1542
1543 it.effect("reserves a prepared binary until a configured instance inspects it", () => {
1544 const agent = makeAgent({
1545 binary: {
1546 "linux-x86_64": {
1547 archive: archiveUrl,
1548 cmd: "./bin/example-agent",
1549 },
1550 },
1551 });
1552 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1553 return Effect.gen(function* () {
1554 const fileSystem = yield* FileSystem.FileSystem;
1555 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1556 prefix: "t3-acp-registry-uninstall-reservation-",
1557 });
1558 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1559 cacheDir,
1560 toolsDir: `${cacheDir}/tools`,
1561 registryUrl,
1562 });
1563
1564 yield* resolver.prepare({ agentId: agent.id });
1565 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1566 agentId: agent.id,
1567 removed: false,
1568 });
1569 expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" });
1570 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1571 agentId: agent.id,
1572 removed: true,
1573 });
1574 }).pipe(
1575 Effect.scoped,
1576 Effect.provide(
1577 layerResolver((request) =>
1578 Effect.succeed(
1579 HttpClientResponse.fromWeb(
1580 request,
1581 request.url === registryUrl
1582 ? new Response(makeRegistry(agent))
1583 : new Response(binaryBytes.buffer as ArrayBuffer),
1584 ),
1585 ),
1586 ),
1587 ),
1588 );
1589 });
1590
1591 it.effect("expires an abandoned prepared-binary reservation", () => {
1592 const agent = makeAgent({
1593 binary: {
1594 "linux-x86_64": {
1595 archive: archiveUrl,
1596 cmd: "./bin/example-agent",
1597 },
1598 },
1599 });
1600 const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n");
1601 return Effect.gen(function* () {
1602 const fileSystem = yield* FileSystem.FileSystem;
1603 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1604 prefix: "t3-acp-registry-uninstall-expiry-",
1605 });
1606 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1607 cacheDir,
1608 toolsDir: `${cacheDir}/tools`,
1609 registryUrl,
1610 });
1611
1612 yield* resolver.prepare({ agentId: agent.id });
1613 yield* TestClock.adjust("31 seconds");
1614 expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({
1615 agentId: agent.id,
1616 removed: true,
1617 });
1618 }).pipe(
1619 Effect.scoped,
1620 Effect.provide(
1621 layerResolver((request) =>
1622 Effect.succeed(
1623 HttpClientResponse.fromWeb(
1624 request,
1625 request.url === registryUrl
1626 ? new Response(makeRegistry(agent))
1627 : new Response(binaryBytes.buffer as ArrayBuffer),
1628 ),
1629 ),
1630 ),
1631 ),
1632 );
1633 });
1634});
1635
1636describe("acpRegistryManagedBinaryDirectories", () => {
1637 it.effect("lists managed package and binary command directories", () =>
1638 Effect.gen(function* () {
1639 const fileSystem = yield* FileSystem.FileSystem;
1640 const path = yield* Path.Path;
1641 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1642 prefix: "t3-acp-registry-bins-",
1643 });
1644 const install = (agent: string, version: string, target: string, commandDirectory = "") =>
1645 fileSystem.makeDirectory(
1646 path.join(cacheDir, "tools", agent, version, target, commandDirectory),
1647 { recursive: true },
1648 );
1649 yield* install("kimi", "1.49.0", "linux-x86_64", "bin");
1650 yield* install("kimi", "1.50.0", "linux-x86_64", "cmd");
1651 yield* install("kimi", "1.50.0", "darwin-aarch64");
1652 yield* install("other-agent", "0.2.0", "darwin-aarch64");
1653 const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin");
1654 const geminiCommand = path.join(globalBin, "gemini");
1655 const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs");
1656 yield* fileSystem.makeDirectory(globalBin, { recursive: true });
1657 yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true });
1658 yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n");
1659 yield* fileSystem.chmod(geminiCommand, 0o755);
1660 yield* fileSystem.writeFileString(
1661 path.join(receiptsDirectory, "gemini.json"),
1662 encodeUnknownJson({
1663 agentId: "gemini",
1664 agentVersion: "0.56.0",
1665 distribution: "npx",
1666 packageSpec: "@google/gemini-cli@0.56.0",
1667 managerPath: "/usr/bin/npm",
1668 binDirectory: globalBin,
1669 executablePath: geminiCommand,
1670 packageRoot: path.join(
1671 cacheDir,
1672 "tools",
1673 "gemini",
1674 "0.56.0",
1675 "npm",
1676 "lib",
1677 "node_modules",
1678 "@google",
1679 "gemini-cli",
1680 ),
1681 packageVersion: "0.56.0",
1682 }),
1683 );
1684 yield* fileSystem.writeFileString(
1685 path.join(cacheDir, "acp-registry", "registry.json"),
1686 encodeUnknownJson({
1687 version: "1.0.0",
1688 agents: [
1689 {
1690 ...makeAgent({
1691 binary: {
1692 "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" },
1693 },
1694 }),
1695 id: "kimi",
1696 name: "Kimi",
1697 version: "1.49.0",
1698 },
1699 {
1700 ...makeAgent({
1701 binary: {
1702 "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" },
1703 },
1704 }),
1705 id: "kimi",
1706 name: "Kimi",
1707 version: "1.50.0",
1708 },
1709 ],
1710 }),
1711 );
1712
1713 const directories = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1714 cacheDir,
1715 toolsDir: path.join(cacheDir, "tools"),
1716 platform: "linux",
1717 architecture: "x64",
1718 });
1719 expect(directories).toEqual([
1720 globalBin,
1721 path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"),
1722 path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"),
1723 ]);
1724
1725 const missing = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({
1726 cacheDir: path.join(cacheDir, "does-not-exist"),
1727 toolsDir: path.join(cacheDir, "does-not-exist", "tools"),
1728 platform: "linux",
1729 architecture: "x64",
1730 });
1731 expect(missing).toEqual([]);
1732 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
1733 );
1734});