From 0c16e24ade558a753aeae6e46732931c7fad90b0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 00:43:13 -0700 Subject: [PATCH 01/16] feat(server): add ACP registry agents Users can search the official ACP Registry, add an agent (Devin, Kimi CLI, Gemini CLI, and others), sign in, run it on the V1 orchestrator with T3 MCP, and remove it again. - Port V2's registry infra: contracts, catalog (search, prepare, inspect, resolve, managed-binary uninstall), probe, runtime coordinator, sign-in flow, sign-in state, and the driver. - Add a generic V1 ACP adapter for registry agents. It answers permission requests by the thread's runtime mode, maps form elicitations to questions and URL elicitations to the coordinator, applies stored model, option, mode, and plan picks, and gives the agent T3 MCP through AcpT3Mcp. Devin keeps V2's client terminals. - Web: registry search and prepare in the add-provider dialog, a sign-in step, the shared sign-in section for registry instances, managed-file cleanup on remove, and registry agent icons. - Mobile: registry agent icons in the model pickers and settings. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/components/ProviderIcon.tsx | 57 + .../SettingsEnvironmentDetailRouteScreen.tsx | 6 +- .../features/threads/NewTaskDraftScreen.tsx | 1 + .../src/features/threads/ThreadComposer.tsx | 6 +- .../features/threads/ThreadSettingsSheet.tsx | 6 +- apps/mobile/src/lib/modelOptions.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 4 + .../Drivers/AcpRegistryDriver.test.ts | 435 ++++ .../src/provider/Drivers/AcpRegistryDriver.ts | 758 +++++++ .../Layers/AcpRegistryAdapter.test.ts | 323 +++ .../src/provider/Layers/AcpRegistryAdapter.ts | 1373 ++++++++++++ .../src/provider/Layers/AcpRegistryCatalog.ts | 22 + .../ProviderInstanceRegistryHydration.ts | 12 +- .../ProviderInstanceRegistryLive.test.ts | 2 + .../provider/Layers/ProviderRegistry.test.ts | 76 + .../src/provider/Layers/ProviderRegistry.ts | 13 + .../provider/Services/AcpRegistryAdapter.ts | 16 + .../src/provider/acp/AcpRegistryAuth.test.ts | 342 +++ .../src/provider/acp/AcpRegistryAuth.ts | 370 ++++ .../AcpRegistryAuthenticationState.test.ts | 99 + .../acp/AcpRegistryAuthenticationState.ts | 76 + .../src/provider/acp/AcpRegistryProbe.test.ts | 434 ++++ .../src/provider/acp/AcpRegistryProbe.ts | 446 ++++ .../acp/AcpRegistryRuntimeCoordinator.test.ts | 312 +++ .../acp/AcpRegistryRuntimeCoordinator.ts | 359 ++++ .../provider/acp/AcpRegistrySupport.test.ts | 1584 ++++++++++++++ .../src/provider/acp/AcpRegistrySupport.ts | 1859 +++++++++++++++++ apps/server/src/provider/builtInDrivers.ts | 5 +- .../provider/providerCompatibility.test.ts | 2 + apps/server/src/server.test.ts | 4 + apps/server/src/server.ts | 6 + apps/server/src/ws.ts | 71 + apps/web/src/components/CommandPalette.tsx | 2 + apps/web/src/components/Sidebar.tsx | 4 + .../src/components/ThreadCommandSubtitle.tsx | 4 + apps/web/src/components/chat/ModelListRow.tsx | 14 +- .../components/chat/ModelPickerContent.tsx | 6 + .../components/chat/ModelPickerSidebar.tsx | 2 + .../chat/ProviderInstanceIcon.test.ts | 32 + .../components/chat/ProviderInstanceIcon.tsx | 35 +- .../components/chat/ProviderModelPicker.tsx | 2 + .../src/components/chat/providerIconUtils.ts | 2 + .../settings/AcpRegistryIcon.test.ts | 206 ++ .../components/settings/AcpRegistryIcon.tsx | 170 ++ .../settings/AcpRegistrySearchStep.test.tsx | 328 +++ .../settings/AcpRegistrySearchStep.tsx | 279 +++ ...roviderInstanceDialog.environment.test.tsx | 4 + .../AddProviderInstanceDialog.logic.ts | 107 +- .../AddProviderInstanceDialog.test.ts | 75 +- .../settings/AddProviderInstanceDialog.tsx | 626 ++++-- .../AddProviderInstanceWizardSteps.tsx | 18 +- .../settings/ProviderAuthTerminal.tsx | 80 + .../ProviderAuthenticationSection.tsx | 475 +++++ .../settings/ProviderInstanceCard.tsx | 57 +- .../settings/ProviderSettingsPanel.tsx | 113 +- .../ProviderWizardAuthenticationStep.tsx | 159 ++ .../components/settings/providerDriverMeta.ts | 9 + apps/web/src/providerInstances.ts | 27 +- docs/README.md | 2 +- docs/user/install.md | 4 +- docs/user/permission-modes.md | 3 + docs/user/providers-acp.md | 69 + .../src/state/providerInstanceDisplay.test.ts | 28 + .../src/state/providerInstanceDisplay.ts | 25 +- packages/client-runtime/src/state/server.ts | 33 + packages/contracts/src/acpRegistry.test.ts | 180 ++ packages/contracts/src/acpRegistry.ts | 347 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/model.ts | 3 + packages/contracts/src/rpc.ts | 46 + packages/contracts/src/server.ts | 7 + packages/contracts/src/settings.ts | 50 + 72 files changed, 12489 insertions(+), 226 deletions(-) create mode 100644 apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/AcpRegistryDriver.ts create mode 100644 apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/AcpRegistryAdapter.ts create mode 100644 apps/server/src/provider/Layers/AcpRegistryCatalog.ts create mode 100644 apps/server/src/provider/Services/AcpRegistryAdapter.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryAuth.test.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryAuth.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryProbe.test.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryProbe.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts create mode 100644 apps/server/src/provider/acp/AcpRegistrySupport.test.ts create mode 100644 apps/server/src/provider/acp/AcpRegistrySupport.ts create mode 100644 apps/web/src/components/chat/ProviderInstanceIcon.test.ts create mode 100644 apps/web/src/components/settings/AcpRegistryIcon.test.ts create mode 100644 apps/web/src/components/settings/AcpRegistryIcon.tsx create mode 100644 apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx create mode 100644 apps/web/src/components/settings/AcpRegistrySearchStep.tsx create mode 100644 apps/web/src/components/settings/ProviderAuthTerminal.tsx create mode 100644 apps/web/src/components/settings/ProviderAuthenticationSection.tsx create mode 100644 apps/web/src/components/settings/ProviderWizardAuthenticationStep.tsx create mode 100644 docs/user/providers-acp.md create mode 100644 packages/contracts/src/acpRegistry.test.ts create mode 100644 packages/contracts/src/acpRegistry.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 49de96a8d74c..cf9905896f0d 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -2,14 +2,67 @@ import { Image } from "expo-image"; import { Path, Svg } from "react-native-svg"; import { View } from "react-native"; import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; +import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; +import { useState } from "react"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./AppText"; type ProviderIconProps = { readonly provider: string | null | undefined; + /** ACP Registry agents supply their own glyph from the official registry CDN. */ + readonly iconUrl?: string | null | undefined; readonly size?: number; }; +function AcpRegistryFallbackIcon(props: { readonly color: string; readonly size: number }) { + return ( + + + + ); +} + +function AcpRegistryProviderIcon(props: { + readonly color: string; + readonly iconUrl: string | null | undefined; + readonly size: number; +}) { + const iconUrl = resolveOfficialAcpRegistryIconUrl(props.iconUrl); + const [image, setImage] = useState<{ + readonly iconUrl: string; + readonly status: "loaded" | "failed"; + } | null>(null); + const currentImage = image?.iconUrl === iconUrl ? image : null; + const loaded = currentImage?.status === "loaded"; + + return ( + + {!loaded ? : null} + {iconUrl !== null && currentImage?.status !== "failed" ? ( + setImage({ iconUrl, status: "failed" })} + onLoad={() => setImage({ iconUrl, status: "loaded" })} + /> + ) : null} + + ); +} + export function ProviderIcon(props: ProviderIconProps) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; @@ -26,6 +79,10 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "acpRegistry") { + return ; + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index be334da900ab..4494ae1dcf69 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -276,7 +276,11 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm - + {provider.displayName ?? provider.driver} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..87ee6bd42f85 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1694,6 +1694,7 @@ export function NewTaskDraftScreen(props: { emphasized renderIcon={(size) => ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d131dfaa51f1..bff8827effa4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -962,7 +962,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer accessibilityLabel="Model and reasoning settings" emphasized renderIcon={(size) => ( - + )} label={currentModelOption?.label ?? currentModelSelection.model} maxWidth="100%" diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 97282f6948c4..60ceb222d0a9 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -123,6 +123,7 @@ const FAVORITES_PROVIDER_FILTER = "@favorites"; /** Provider catalog header with its harness logo and disclosure state. */ function ProviderHeader(props: { readonly driver: string | undefined; + readonly iconUrl: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; @@ -131,7 +132,7 @@ function ProviderHeader(props: { }) { const content = ( <> - + {props.label} {props.collapsible ? ( <> @@ -514,6 +515,7 @@ function useThreadSettingsSession() { type ThreadSettingsProviderCatalog = { readonly key: string; readonly driver: string | undefined; + readonly iconUrl: string | undefined; readonly label: string; readonly collapsible: boolean; readonly collapsed: boolean; @@ -582,6 +584,7 @@ function ThreadSettingsProviderListHeader(props: { collapsible={props.provider.collapsible} collapsed={props.provider.collapsed} driver={props.provider.driver} + iconUrl={props.provider.iconUrl} label={props.provider.label} modelCount={props.provider.modelCount} onToggle={onToggle} @@ -643,6 +646,7 @@ function useThreadSettingsCatalogItems( const provider: ThreadSettingsProviderCatalog = { key: group.providerKey, driver, + iconUrl: group.models[0]?.providerIconUrl, label: group.providerLabel, collapsible, collapsed, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4e8295733141..dc25043ab297 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly providerIconUrl?: string | undefined; readonly isDefault: boolean; readonly isLegacy: boolean; readonly isUnavailable?: boolean; @@ -173,6 +174,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..2c637af4548f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, + [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, + [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthOrchestrationOperateScope, + [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts new file mode 100644 index 000000000000..9462d1d5aa32 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -0,0 +1,435 @@ +import { + AcpRegistryOperationError, + AcpRegistrySettings, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { AcpRegistryCatalog, type AcpRegistryInspection } from "../acp/AcpRegistrySupport.ts"; +import { + acpRegistrySnapshotReadiness, + applyAcpRegistryAvailableCommands, + applyAcpRegistryLiveConfiguration, + buildCheckedAcpRegistrySnapshot, + checkAcpRegistryProviderReadiness, + checkAcpRegistryProviderStatus, +} from "./AcpRegistryDriver.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); +const identity = { + instanceId: ProviderInstanceId.make("acpRegistry_test"), + displayName: "Test ACP", + accentColor: undefined, + continuationKey: "acpRegistry:instance:acpRegistry_test", +}; +const noSessionManagement = { + canList: false, + canLoad: false, + canResume: false, + canLogout: false, + canDelete: false, + canConfigureProviders: false, +} as const; + +function catalogWithInspection(inspection: AcpRegistryInspection): AcpRegistryCatalog["Service"] { + return { + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.succeed(inspection), + resolve: () => Effect.die("unused resolve"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + }; +} + +describe("acpRegistrySnapshotReadiness", () => { + it("treats a live empty command advertisement as an authoritative replacement", () => { + const provider = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + documentationUrl: "https://example.test/agent/setup", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [], + currentModelId: null, + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [{ name: "stale" }], + skills: [{ name: "stale-skill", path: "stale", enabled: true }], + }, + }); + + const replaced = applyAcpRegistryAvailableCommands( + provider, + Option.some({ slashCommands: [], skills: [] }), + ); + expect(replaced.slashCommands).toEqual([]); + expect(replaced.skills).toEqual([]); + expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([ + { name: "stale" }, + ]); + expect(provider.iconUrl).toBe( + "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg", + ); + expect(provider.setup).toMatchObject({ + canAuthenticate: false, + documentationUrl: "https://example.test/agent/setup", + }); + }); + + it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "binary", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }], + models: [], + currentModelId: null, + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [], + skills: [], + }, + }); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.setup?.canAuthenticate).toBe(true); + expect( + applyAcpRegistryLiveConfiguration( + snapshot, + { models: [], currentModelId: null, configOptions: [] }, + [], + ).auth.status, + ).toBe("unknown"); + }); + + it("overlays live configuration without dropping probe-owned sign-out support", () => { + const provider = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [{ id: "probe-model", name: "Probe model", description: null }], + currentModelId: "probe-model", + configOptions: [], + sessionManagement: { + canList: true, + canLoad: true, + canResume: true, + canLogout: true, + canDelete: true, + canConfigureProviders: true, + }, + }, + slashCommands: [], + skills: [], + }, + }); + + expect( + applyAcpRegistryLiveConfiguration( + provider, + { + models: [{ id: "live-model", name: "Live model", description: null }], + currentModelId: "live-model", + configOptions: [], + }, + [], + ), + ).toMatchObject({ + auth: { status: "unknown", canLogout: true }, + models: [{ slug: "live-model", isDefault: true }], + }); + }); + + it("maps registry inspection status to provider readiness", () => { + expect( + acpRegistrySnapshotReadiness({ + status: "ready", + agentId: "gemini-cli", + version: "1.2.3", + distribution: "npx", + }), + ).toEqual({ installed: true, version: "1.2.3", status: "ready" }); + + expect( + acpRegistrySnapshotReadiness({ + status: "missing_runner", + agentId: "gemini-cli", + version: "1.2.3", + distribution: "npx", + runner: "npx", + }), + ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); + + expect( + acpRegistrySnapshotReadiness({ + status: "unprepared", + agentId: "zed-agent", + version: "2.0.0", + distribution: "binary", + }), + ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" }); + + expect( + acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }), + ).toEqual({ + installed: false, + version: null, + status: "error", + message: "Registry unavailable.", + }); + }); + + it("projects discovery without claiming authentication when no login methods are advertised", () => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ + agentId: "test-agent", + customModels: [" custom-model ", "gpt-discovered"], + }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }], + currentModelId: "gpt-discovered", + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }], + skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], + }, + }); + + expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false }); + expect(snapshot.supportsTextGeneration).toBe(false); + expect( + snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({ + slug, + name, + isCustom, + isDefault, + })), + ).toEqual([ + { + slug: "gpt-discovered", + name: "GPT Discovered", + isCustom: false, + isDefault: true, + }, + { + slug: "custom-model", + name: "custom-model", + isCustom: true, + isDefault: undefined, + }, + ]); + expect(snapshot.slashCommands).toEqual([ + { name: "plan", description: "Create a plan", input: { hint: "topic" } }, + ]); + expect(snapshot.skills).toEqual([ + { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }, + ]); + }); + + it("keeps a default model only when the agent advertises none", () => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "uvx", + }, + probe: { + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [], + currentModelId: null, + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [], + skills: [], + }, + }); + + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + expect(snapshot.models[0]?.isDefault).toBe(true); + }); + + it("reports failed authentication without hiding successful local inspection", () => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "binary", + }, + probeError: new AcpRegistryOperationError({ + reason: "authentication_failed", + message: "Login required.", + authMethods: [ + { + id: "api-key", + name: "API key", + description: null, + type: "env_var", + }, + { + id: "grok-login", + name: "Log in with Grok", + description: null, + type: "agent", + }, + ], + }), + }); + + expect(snapshot).toMatchObject({ + installed: true, + version: "1.0.0", + status: "warning", + auth: { + status: "unauthenticated", + type: "agent", + label: "Log in with Grok", + }, + message: 'Sign in in provider settings using "Log in with Grok".', + }); + }); + + it.effect("runs the disposable probe only after local inspection is ready", () => + Effect.gen(function* () { + const settings = decodeSettings({ agentId: "test-agent" }); + const environment = { PATH: "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/provider/bin" }; + let receivedEnvironment: NodeJS.ProcessEnv | undefined; + const snapshot = yield* checkAcpRegistryProviderStatus( + { + ...identity, + settings, + cwd: "/workspace", + environment, + }, + (input) => + Effect.sync(() => { + receivedEnvironment = input.environment; + return { + probe: { + instanceId: identity.instanceId, + ready: true as const, + icon: null, + authMethods: [], + models: [{ id: "agent-model", name: "Agent Model", description: null }], + currentModelId: "agent-model", + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [{ name: "review" }], + skills: [], + }; + }), + ).pipe( + Effect.provideService( + AcpRegistryCatalog, + catalogWithInspection({ + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }), + ), + ); + + expect(receivedEnvironment).toBe(environment); + expect(snapshot).toMatchObject({ + auth: { status: "unknown" }, + models: [{ slug: "agent-model" }], + slashCommands: [{ name: "review" }], + skills: [], + }); + }), + ); + + it.effect("publishes concrete local readiness before background ACP discovery", () => + Effect.gen(function* () { + const snapshot = yield* checkAcpRegistryProviderReadiness({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + environment: { PATH: "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/provider/bin" }, + }).pipe( + Effect.provideService( + AcpRegistryCatalog, + catalogWithInspection({ + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }), + ), + ); + + expect(snapshot).toMatchObject({ + installed: true, + status: "ready", + version: "1.0.0", + auth: { status: "unknown" }, + message: "Checking ACP authentication, models, and commands in the background...", + }); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 000000000000..43684782b88b --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,758 @@ +import { + AcpRegistrySettings, + officialAcpRegistryIconUrlForAgentId, + ProviderDriverKind, + resolveOfficialAcpRegistryIconUrl, + TextGenerationError, + type AcpRegistryOperationError, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAcpRegistryAdapter } from "../Layers/AcpRegistryAdapter.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { providerModelsFromSettings } from "../providerSnapshot.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { + type AcpRegistryAvailableCommands, + type AcpRegistryConfigurationProbe, + type AcpRegistryConfigurationProbeResult, + type AcpRegistryLiveConfiguration, + probeAcpRegistryConfiguration, +} from "../acp/AcpRegistryProbe.ts"; +import { AcpRegistryCatalog, type AcpRegistryInspection } from "../acp/AcpRegistrySupport.ts"; +import { AcpRegistryRuntimeCoordinator } from "../acp/AcpRegistryRuntimeCoordinator.ts"; +import * as AcpRegistryAuth from "../acp/AcpRegistryAuth.ts"; +import * as AcpRegistryAuthenticationState from "../acp/AcpRegistryAuthenticationState.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); +// How long a successful discovery probe stays valid. Periodic health +// refreshes reuse it instead of spawning a fresh disposable ACP session; +// failed or unauthenticated probes are never cached so a completed sign-in +// is detected on the next refresh. +const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000; +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "ACP Registry instances do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +function modelsFromDiscovery( + discovery: + | Pick + | undefined, + customModels: ReadonlyArray, +): ReadonlyArray { + const discovered = discovery?.models ?? []; + // Discovered session config options and modes ride on every model so the + // composer's generic option controls can drive them per thread. + const capabilities = + discovery === undefined || discovery.configOptions.length === 0 + ? EMPTY_CAPABILITIES + : createModelCapabilities({ optionDescriptors: discovery.configOptions }); + const builtInModels: ReadonlyArray = + discovered.length === 0 + ? [ + { + slug: "default", + name: "Default", + isCustom: false, + isDefault: true, + capabilities, + }, + ] + : discovered.map((model) => ({ + slug: model.id, + name: model.name, + isCustom: false, + ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}), + capabilities, + })); + return providerModelsFromSettings(builtInModels, customModels, capabilities); +} + +export function acpRegistrySnapshotReadiness( + inspection: AcpRegistryInspection | { readonly status: "failed"; readonly message: string }, +): Pick { + switch (inspection.status) { + case "ready": + return { installed: true, version: inspection.version, status: "ready" }; + case "unconfigured": + return { + installed: false, + version: null, + status: "warning", + message: "Select an ACP Registry agent before starting a thread.", + }; + case "not_found": + return { + installed: false, + version: null, + status: "error", + message: `ACP Registry does not contain agent '${inspection.agentId}'.`, + }; + case "unsupported": + return { + installed: false, + version: inspection.version, + status: "error", + message: `ACP Registry agent '${inspection.agentId}' has no compatible distribution for this environment.`, + }; + case "missing_runner": + return { + installed: false, + version: inspection.version, + status: "error", + message: `ACP Registry agent '${inspection.agentId}' requires '${inspection.runner}' on this environment's PATH.`, + }; + case "unprepared": + return { + installed: false, + version: inspection.version, + status: "warning", + message: `ACP Registry agent '${inspection.agentId}' has not been prepared on this environment.`, + }; + case "failed": + return { + installed: false, + version: null, + status: "error", + message: inspection.message, + }; + } +} + +interface SnapshotIdentity { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationKey: string; +} + +function baseSnapshot( + input: SnapshotIdentity & { + readonly settings: AcpRegistrySettings; + readonly checkedAt: string; + readonly installed: boolean; + readonly version: string | null; + readonly status: ServerProvider["status"]; + readonly auth: ServerProvider["auth"]; + readonly documentationUrl?: string; + readonly message?: string; + readonly probe?: AcpRegistryConfigurationProbeResult; + }, +): ServerProvider { + const iconUrl = + resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? + officialAcpRegistryIconUrlForAgentId(input.settings.agentId); + return { + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + ...(iconUrl ? { iconUrl } : {}), + continuation: { groupKey: input.continuationKey }, + // The registry driver rejects every application text-generation operation, + // so selectors must not offer these instances for commit, PR, branch, or + // title generation. + supportsTextGeneration: false, + // ACP has no portable conversation rewind, so V1 clients hide revert. + supportsConversationRollback: false, + enabled: input.settings.enabled, + installed: input.installed, + version: input.version, + status: input.settings.enabled ? input.status : "disabled", + auth: { ...input.auth, canLogout: input.auth.canLogout ?? false }, + checkedAt: input.checkedAt, + setup: { + canInstall: false, + ...(input.documentationUrl ? { documentationUrl: input.documentationUrl } : {}), + canAuthenticate: + input.installed && + (input.probe + ? input.probe.probe.authMethods.length > 0 + : input.settings.agentId.length > 0), + }, + ...(input.message ? { message: input.message } : {}), + models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), + slashCommands: input.probe?.slashCommands ?? [], + skills: input.probe?.skills ?? [], + }; +} + +export function applyAcpRegistryAvailableCommands( + provider: ServerProvider, + commands: Option.Option, +): ServerProvider { + return Option.match(commands, { + onNone: () => provider, + onSome: ({ slashCommands, skills }) => ({ ...provider, slashCommands, skills }), + }); +} + +export function applyAcpRegistryLiveConfiguration( + provider: ServerProvider, + configuration: AcpRegistryLiveConfiguration, + customModels: ReadonlyArray, +): ServerProvider { + const { message: _staleProbeMessage, ...snapshot } = provider; + return { + ...snapshot, + status: provider.enabled ? "ready" : provider.status, + models: modelsFromDiscovery(configuration, customModels), + }; +} + +function applyAcpRegistryUrlAuthAction( + provider: ServerProvider, + action: Option.Option>, +): ServerProvider { + const { action: _previousAction, ...auth } = provider.auth; + return { + ...provider, + auth: Option.match(action, { + onNone: () => auth, + onSome: (nextAction) => ({ ...auth, action: nextAction }), + }), + }; +} + +const buildInitialAcpRegistrySnapshot = Effect.fn("AcpRegistryDriver.buildInitialSnapshot")( + function* (input: SnapshotIdentity & { readonly settings: AcpRegistrySettings }) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return baseSnapshot({ + ...input, + checkedAt, + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: input.settings.enabled + ? "Checking ACP Registry agent readiness..." + : "ACP Registry is disabled in T3 Code settings.", + }); + }, +); + +export function buildCheckedAcpRegistrySnapshot( + input: SnapshotIdentity & { + readonly settings: AcpRegistrySettings; + readonly checkedAt: string; + readonly inspection: + | AcpRegistryInspection + | { readonly status: "failed"; readonly message: string }; + readonly probe?: AcpRegistryConfigurationProbeResult; + readonly probeError?: AcpRegistryOperationError; + }, +): ServerProvider { + const readiness = acpRegistrySnapshotReadiness(input.inspection); + const probeFailed = input.probeError !== undefined; + const advertisedAuthMethod = + input.probeError?.reason === "authentication_failed" + ? (input.probeError.authMethods?.find( + (method) => method.id === input.settings.authMethodId, + ) ?? input.probeError.authMethods?.[0]) + : undefined; + const authenticationMessage = advertisedAuthMethod + ? advertisedAuthMethod.type === "terminal" && advertisedAuthMethod.command + ? `Sign in in provider settings using "${advertisedAuthMethod.name}". The login terminal runs on this environment.` + : advertisedAuthMethod.type === "env_var" && + (advertisedAuthMethod.envVarNames?.length ?? 0) > 0 + ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.` + : `Sign in in provider settings using "${advertisedAuthMethod.name}".` + : undefined; + return baseSnapshot({ + ...input, + ...(input.inspection.status === "ready" && input.inspection.documentationUrl + ? { documentationUrl: input.inspection.documentationUrl } + : {}), + installed: readiness.installed, + version: readiness.version, + // A failed discovery probe on a ready installation is a warning, not an + // outage: the agent binary is present and a real turn may still work + // (for example after the user completes authentication). + status: probeFailed ? "warning" : readiness.status, + auth: input.probe + ? { + // Discovery sessions and an empty auth-method list do not prove sign-in. + status: "unknown", + canLogout: input.probe.probe.sessionManagement.canLogout, + } + : input.probeError?.reason === "authentication_failed" + ? { + status: "unauthenticated", + ...(advertisedAuthMethod + ? { type: advertisedAuthMethod.type, label: advertisedAuthMethod.name } + : {}), + ...(input.probeError.authAction === undefined + ? {} + : { action: input.probeError.authAction }), + } + : { status: "unknown" }, + ...(input.probeError + ? { message: authenticationMessage ?? input.probeError.message } + : readiness.message + ? { message: readiness.message } + : {}), + }); +} + +export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")( + function* ( + input: SnapshotIdentity & { + readonly settings: AcpRegistrySettings; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }, + probeConfiguration: AcpRegistryConfigurationProbe, + ): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!input.settings.enabled) { + return yield* buildInitialAcpRegistrySnapshot(input); + } + const catalog = yield* AcpRegistryCatalog; + const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); + if (Result.isFailure(inspected)) { + return buildCheckedAcpRegistrySnapshot({ + ...input, + checkedAt, + inspection: { + status: "failed", + message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, + }, + }); + } + if (inspected.success.status !== "ready") { + return buildCheckedAcpRegistrySnapshot({ + ...input, + checkedAt, + inspection: inspected.success, + }); + } + const probed = yield* Effect.result( + probeConfiguration({ + instanceId: input.instanceId, + settings: input.settings, + cwd: input.cwd, + environment: input.environment, + }), + ); + return Result.isFailure(probed) + ? buildCheckedAcpRegistrySnapshot({ + ...input, + checkedAt, + inspection: inspected.success, + probeError: probed.failure, + }) + : buildCheckedAcpRegistrySnapshot({ + ...input, + checkedAt, + inspection: inspected.success, + probe: probed.success, + }); + }, +); + +/** Publishes local executable readiness without starting an ACP process. */ +export const checkAcpRegistryProviderReadiness = Effect.fn( + "AcpRegistryDriver.checkProviderReadiness", +)(function* ( + input: SnapshotIdentity & { + readonly settings: AcpRegistrySettings; + readonly environment: NodeJS.ProcessEnv; + }, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!input.settings.enabled) { + return yield* buildInitialAcpRegistrySnapshot(input); + } + const catalog = yield* AcpRegistryCatalog; + const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...input, + checkedAt, + inspection: Result.isFailure(inspected) + ? { + status: "failed", + message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, + } + : inspected.success, + }); + return inspected._tag === "Success" && inspected.success.status === "ready" + ? { + ...snapshot, + message: "Checking ACP authentication, models, and commands in the background...", + } + : snapshot; +}); + +export type AcpRegistryDriverEnv = + | AcpRegistryCatalog + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +/** One provider instance per ACP Registry agent, backed by the generic V1 ACP adapter. */ +export const AcpRegistryDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ACP Registry", + supportsMultipleInstances: true, + }, + configSchema: AcpRegistrySettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const catalog = yield* AcpRegistryCatalog; + const runtimeCoordinator = yield* Effect.serviceOption(AcpRegistryRuntimeCoordinator); + if (Option.isSome(runtimeCoordinator)) { + yield* runtimeCoordinator.value.clearAvailableCommands(instanceId); + yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId); + yield* Effect.addFinalizer(() => + runtimeCoordinator.value + .clearAvailableCommands(instanceId) + .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))), + ); + } + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostEnvironment = yield* HostProcessEnvironment; + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const identity = { + instanceId, + displayName, + accentColor, + continuationKey: continuationIdentity.continuationKey, + }; + const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; + const processEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); + const loggers = yield* ProviderEventLoggers; + const adapter = yield* makeAcpRegistryAdapter({ + instanceId, + settings: effectiveConfig, + environment: processEnvironment, + nativeEventLogger: loggers.native, + }); + const readinessInput = { + ...identity, + settings: effectiveConfig, + environment: processEnvironment, + }; + const confirmedAuthentication = + yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ + cacheDir: serverConfig.providerStatusCacheDir, + instanceId, + settings: effectiveConfig, + environment, + processEnvironment, + }); + const withLiveRuntimeState = (input: ServerProvider) => + Effect.gen(function* () { + if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false); + const provider = + input.enabled && + input.installed && + input.auth.status === "unknown" && + (yield* confirmedAuthentication.get) + ? { ...input, auth: { ...input.auth, status: "authenticated" as const } } + : input; + return yield* Option.isNone(runtimeCoordinator) + ? Effect.succeed(provider) + : Effect.all({ + commands: runtimeCoordinator.value.getAvailableCommands(instanceId), + configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId), + authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId), + }).pipe( + Effect.map(({ commands, configuration, authAction }) => { + const withCommands = applyAcpRegistryAvailableCommands(provider, commands); + const withConfiguration = Option.match(configuration, { + onNone: () => withCommands, + onSome: (liveConfiguration) => + applyAcpRegistryLiveConfiguration( + withCommands, + liveConfiguration, + effectiveConfig.customModels, + ), + }); + return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); + }), + ); + }); + const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe( + Effect.provideService(AcpRegistryCatalog, catalog), + Effect.flatMap(withLiveRuntimeState), + ); + const enrichProvider = checkAcpRegistryProviderStatus( + { + ...readinessInput, + cwd: serverConfig.cwd, + }, + probeAcpRegistryConfiguration, + ).pipe( + Effect.provideService(AcpRegistryCatalog, catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + ); + const enrichmentCache = yield* Ref.make<{ + readonly generation: number; + readonly entry: { + readonly provider: ServerProvider; + readonly expiresAt: number; + } | null; + }>({ generation: 0, entry: null }); + const liveSnapshotSemaphore = yield* Semaphore.make(1); + const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ + generation: current.generation + 1, + entry: null, + })); + const enrichProviderCached = (baseSnapshot: ServerProvider) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cacheState = yield* Ref.get(enrichmentCache); + const cached = cacheState.entry; + if ( + cached !== null && + cached.expiresAt > now && + cached.provider.version === baseSnapshot.version + ) { + return { + provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt }, + generation: cacheState.generation, + }; + } + const enriched = yield* enrichProvider; + if (enriched.status === "ready") { + yield* Ref.update(enrichmentCache, (current) => + current.generation === cacheState.generation + ? { + ...current, + entry: { + provider: enriched, + expiresAt: now + PROBE_SUCCESS_TTL_MS, + }, + } + : current, + ); + } + return { provider: enriched, generation: cacheState.generation }; + }); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: () => checkProvider, + checkProvider, + enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => { + if (!snapshot.installed) return Effect.void; + const publishEnrichment = ( + Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value.runBackgroundProbe( + effectiveConfig.agentId, + enrichProviderCached(snapshot), + ) + : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) + ).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: ({ provider, generation }) => + liveSnapshotSemaphore.withPermit( + Ref.get(enrichmentCache).pipe( + Effect.flatMap((current) => + current.generation === generation + ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot)) + : Effect.void, + ), + ), + ), + }), + ), + ); + if (Option.isNone(runtimeCoordinator)) return publishEnrichment; + + const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands( + instanceId, + ({ slashCommands, skills }) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot({ + ...current, + slashCommands, + skills, + }), + ), + ), + ), + ); + const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration( + instanceId, + (configuration) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot( + applyAcpRegistryLiveConfiguration( + current, + configuration, + effectiveConfig.customModels, + ), + ), + ), + ), + ), + ); + const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction( + instanceId, + (action) => + liveSnapshotSemaphore.withPermit( + getSnapshot.pipe( + Effect.flatMap((current) => + publishSnapshot( + applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)), + ), + ), + ), + ), + ); + return Effect.all( + [ + publishEnrichment, + publishLiveCommands, + publishLiveConfiguration, + publishUrlAuthAction, + ], + { + concurrency: "unbounded", + discard: true, + }, + ); + }, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the ACP Registry provider snapshot.", + cause, + }), + ), + ); + + const clearLiveState = Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value + .clearLiveConfiguration(instanceId) + .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId))) + : Effect.void; + const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ + instanceId, + settings: effectiveConfig, + cwd: serverConfig.cwd, + environment: processEnvironment, + onChanged: (authenticated) => + confirmedAuthentication + .set(authenticated) + .pipe( + Effect.andThen(authenticated ? Effect.void : clearLiveState), + Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), + Effect.andThen(snapshot.refresh), + Effect.asVoid, + ), + }).pipe( + Effect.provideService(AcpRegistryCatalog, catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Crypto.Crypto, crypto), + ); + const auth = { + ...controller, + invalidate: (controller.invalidate ?? Effect.void).pipe( + Effect.andThen(confirmedAuthentication.set(false)), + Effect.andThen(clearLiveState), + Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), + Effect.andThen(snapshot.refresh), + Effect.asVoid, + ), + }; + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + auth, + snapshot: { + ...snapshot, + refresh: snapshot.refresh.pipe( + Effect.tap(() => controller.refreshMethods ?? Effect.void), + ), + }, + adapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts new file mode 100644 index 000000000000..a77a89ff0e3f --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { + AcpRegistrySettings, + ApprovalRequestId, + EnvironmentId, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { AcpRegistryRuntimeCoordinator } from "../acp/AcpRegistryRuntimeCoordinator.ts"; +import { AcpRegistryCatalog } from "../acp/AcpRegistrySupport.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "../acp/AcpSessionConfig.ts"; +import { makeAcpRegistryAdapter } from "./AcpRegistryAdapter.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const instanceId = ProviderInstanceId.make("acp-registry-test"); +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +const testLayer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-acp-registry-adapter-test-" }), + AcpRegistryRuntimeCoordinator.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); + +interface JsonRpcLine { + readonly method?: string; + readonly params?: Record; + readonly result?: unknown; +} + +const readRequestLog = (filePath: string) => + Effect.promise(async () => + (await NodeFSP.readFile(filePath, "utf8")) + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as JsonRpcLine), + ); + +type AcpMockWire = "v1" | "v2"; + +/** A registry agent whose catalog entry resolves to the ACP mock agent. */ +const makeHarness = Effect.fn("makeAcpRegistryAdapterHarness")(function* (input: { + readonly wire: AcpMockWire; + readonly agentId?: string; + readonly env?: Record; +}) { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "acp-registry-adapter-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const catalog = AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: (_settings, cwd, environment) => + Effect.succeed({ + agent: { + id: input.agentId ?? "mock-agent", + name: "Mock Agent", + version: "1.0.0", + description: "ACP registry adapter test agent", + distribution: { npx: { package: "mock-agent@1.0.0" } }, + }, + distribution: "npx", + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd, + env: { + ...environment, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_WIRE: input.wire, + ...input.env, + }, + }, + }), + }); + const adapter = yield* makeAcpRegistryAdapter({ + instanceId, + settings: decodeSettings({ agentId: input.agentId ?? "mock-agent" }), + environment: process.env, + }).pipe(Effect.provideService(AcpRegistryCatalog, catalog)); + const events: Array = []; + // The first approval or question the agent asks the user. + const interaction = yield* Deferred.make(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "request.opened" || event.type === "user-input.requested" + ? Deferred.succeed(interaction, event) + : Effect.void, + ), + ), + ).pipe(Effect.forkScoped); + return { adapter, events, interaction, requestLogPath }; +}); + +const acpRegistryAdapterLive = it.layer(testLayer); + +// Most registry agents still speak the ACP v1 message shape; v2 covers the upgrade path. +for (const wire of ["v1", "v2"] as const) { + acpRegistryAdapterLive(`AcpRegistryAdapter (ACP ${wire})`, (it) => + acpRegistryAdapterTests(it, wire), + ); +} + +function acpRegistryAdapterTests( + it: Parameters[1]>[0], + wire: AcpMockWire, +) { + it.effect("runs a turn on the agent the registry resolves and applies the stored picks", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-turn"); + const { adapter, events, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "0" }, + }); + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const session = yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId, + model: "composer-2", + options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }], + }, + }); + const turn = yield* adapter.sendTurn({ threadId, input: "Say hello" }); + yield* adapter.stopSession(threadId); + + assert.equal(session.provider, "acpRegistry"); + assert.deepEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "mock-session-1" }); + const turnEvents = events.filter((event) => event.turnId === turn.turnId); + expect(turnEvents.map((event) => event.type)).toContain("turn.started"); + expect( + turnEvents.flatMap((event) => + event.type === "content.delta" ? [event.payload.delta] : [], + ), + ).toContain("hello from mock"); + expect(turnEvents.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { state: "completed", stopReason: "end_turn" }, + }); + + // The stored model and mode picks reach the agent before the prompt. + const requests = yield* readRequestLog(requestLogPath); + const configured = requests + .filter((request) => request.method === "session/set_config_option") + .map((request) => [request.params?.configId, request.params?.value]); + expect(configured).toEqual( + expect.arrayContaining([ + ["model", "composer-2"], + ["mode", "code"], + ]), + ); + expect(requests.find((request) => request.method === "initialize")?.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + }); + + // The provider snapshot follows the live session through the coordinator. + const configuration = yield* coordinator.getLiveConfiguration(instanceId); + assert.equal(Option.getOrThrow(configuration).currentModelId, "composer-2"); + const commands = yield* coordinator.getAvailableCommands(instanceId); + expect(Option.getOrThrow(commands).slashCommands.map((command) => command.name)).toEqual([ + "review", + ]); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + it.effect( + "asks before a command in approval-required mode and answers with the offered option", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-approval"); + const { adapter, interaction, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_TOOL_CALLS: "1" }, + }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Read package.json" }) + .pipe(Effect.forkScoped); + const opened = yield* Deferred.await(interaction); + if (opened.type !== "request.opened" || opened.requestId === undefined) { + return assert.fail(`Expected a permission request, got ${opened.type}`); + } + expect(opened.payload.options?.map((option) => option.decision)).toEqual([ + "accept", + "acceptForSession", + "decline", + "cancel", + ]); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(opened.requestId), + "accept", + ); + yield* Fiber.join(turn); + yield* adapter.stopSession(threadId); + + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ + outcome: { outcome: "selected", optionId: "allow-once" }, + }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + it.effect("turns a form elicitation into questions and answers with typed values", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-elicitation"); + const { adapter, interaction, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_ELICITATION: "1" }, + }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Ask me first" }) + .pipe(Effect.forkScoped); + const requested = yield* Deferred.await(interaction); + if (requested.type !== "user-input.requested" || requested.requestId === undefined) { + return assert.fail(`Expected a question, got ${requested.type}`); + } + expect(requested.payload.questions).toMatchObject([ + { id: "approved", header: "Approved", options: [{ label: "true" }, { label: "false" }] }, + ]); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requested.requestId), { + approved: "true", + }); + yield* Fiber.join(turn); + yield* adapter.stopSession(threadId); + + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ + action: "accept", + content: { approved: true }, + }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + it.effect("approves by policy in full access without asking", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-full-access"); + const { adapter, events, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_TOOL_CALLS: "1" }, + }); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "Read package.json" }); + yield* adapter.stopSession(threadId); + + expect(events.map((event) => event.type)).not.toContain("request.opened"); + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ + outcome: { outcome: "selected", optionId: "allow-always" }, + }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + it.effect("gives the agent T3 MCP and offers client terminals to Devin only", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-devin"); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-test"), + threadId, + providerSessionId: "provider-session", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:9/mcp", + authorizationHeader: "Bearer mcp-secret", + capabilities: new Set(), + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const { adapter, requestLogPath } = yield* makeHarness({ wire, agentId: "devin" }); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.stopSession(threadId); + + const requests = yield* readRequestLog(requestLogPath); + expect(requests.find((request) => request.method === "initialize")?.params).toMatchObject({ + clientCapabilities: { terminal: true }, + }); + expect(requests.find((request) => request.method === "session/new")?.params).toMatchObject({ + mcpServers: [ + { + name: "t3-code", + args: expect.arrayContaining(["acp-mcp-bridge"]), + env: expect.arrayContaining([ + { name: "T3_ACP_MCP_AUTHORIZATION", value: "Bearer mcp-secret" }, + ]), + }, + ], + }); + }).pipe(Effect.scoped, TestClock.withLive), + ); +} diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts new file mode 100644 index 000000000000..ab0f95a575e1 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -0,0 +1,1373 @@ +import { + ApprovalRequestId, + EventId, + ProviderDriverKind, + RuntimeRequestId, + TurnId, + type AcpRegistrySettings, + type ModelSelection, + type ProviderApprovalDecision, + type ProviderApprovalOption, + type ProviderInstanceId, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + type ThreadId, + type TurnCompletedPayload, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + acpClientExecuteDisposition, + acpMcpToolApprovalElicitationDisposition, + acpPermissionDisposition, + makeAcpClientPolicyGrants, + type AcpRuntimePolicy, +} from "../acp/AcpClientPolicy.ts"; +import { + makeAcpClientTerminals, + resolveEmbeddedTerminalContent, + type AcpClientTerminals, +} from "../acp/AcpClientTerminals.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + normalizeAcpRegistryCommands, + normalizeAcpRegistryLiveConfiguration, + normalizeAcpRegistryWebUrl, +} from "../acp/AcpRegistryProbe.ts"; +import { AcpRegistryRuntimeCoordinator } from "../acp/AcpRegistryRuntimeCoordinator.ts"; +import { AcpRegistryCatalog } from "../acp/AcpRegistrySupport.ts"; +import { parsePermissionRequest, type AcpToolCallState } from "../acp/AcpRuntimeModel.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "../acp/AcpSessionConfig.ts"; +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { acpT3McpServers, serveAcpMcpOverAcp } from "../acp/AcpT3Mcp.ts"; +import type { AcpRegistryAdapterShape } from "../Services/AcpRegistryAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("acpRegistry"); +const ResumeCursor = Schema.Struct({ + schemaVersion: Schema.Literal(1), + sessionId: Schema.NonEmptyString, +}); +const decodeResumeCursor = Schema.decodeUnknownOption(ResumeCursor); +const isAcpError = Schema.is(EffectAcpErrors.AcpError); +const CANCELLED = { outcome: { outcome: "cancelled" } } as const; + +type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; +type NativePermission = EffectAcpSchema.RequestPermissionRequest; +type NativePermissionResponse = EffectAcpSchema.RequestPermissionResponse; +type NativeElicitation = EffectAcpSchema.CreateElicitationRequest; +type NativeElicitationResponse = EffectAcpSchema.CreateElicitationResponse; + +export interface AcpRegistryAdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly settings: AcpRegistrySettings; + /** The instance environment merged over the host environment, as chat and sign-in use it. */ + readonly environment: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger | undefined; +} + +interface PendingApproval { + readonly request: NativePermission; + readonly response: Deferred.Deferred<{ + readonly decision: ProviderApprovalDecision; + readonly result: NativePermissionResponse; + }>; +} + +interface PendingQuestion { + readonly response: Deferred.Deferred; +} + +/** Mode and plan-sensitive config values to restore when the thread leaves plan mode. */ +interface BuildConfiguration { + readonly modeId: string | undefined; + readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>; +} + +interface TurnIntent { + readonly turnId: TurnId; + readonly generation: number; + settled: boolean; +} + +interface SessionContext { + readonly threadId: ThreadId; + readonly cwd: string; + readonly nativeSessionId: string; + readonly harness: string; + readonly supportsImages: boolean; + readonly scope: Scope.Closeable; + readonly runtime: Runtime; + readonly promptLock: Semaphore.Semaphore; + readonly stopLock: Semaphore.Semaphore; + readonly approvals: Map; + readonly questions: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + readonly grants: ReturnType; + session: ProviderSession; + buildConfiguration: BuildConfiguration | undefined; + activeTurnId: TurnId | undefined; + promptFiber: Fiber.Fiber | undefined; + generation: number; + stopped: boolean; + closed: boolean; + disconnected: boolean; +} + +function selectOptionId( + request: NativePermission, + kinds: ReadonlyArray, +): string | undefined { + for (const kind of kinds) { + const optionId = request.options.find((option) => option.kind === kind)?.optionId.trim(); + if (optionId) return optionId; + } + return undefined; +} + +function permissionResponse(optionId: string | undefined): NativePermissionResponse { + return optionId === undefined ? CANCELLED : { outcome: { outcome: "selected", optionId } }; +} + +/** The choices shown to the user are the ones the agent offered. */ +function acpRegistryApprovalOptions( + request: NativePermission, +): ReadonlyArray { + const offered = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.some((option) => option.kind === kind && option.optionId.trim()); + return [ + ...(offered("allow_once") ? [{ decision: "accept" as const, label: "Allow once" }] : []), + ...(offered("allow_always") + ? [{ decision: "acceptForSession" as const, label: "Allow for this thread" }] + : []), + ...(offered("reject_once") || offered("reject_always") + ? [{ decision: "decline" as const, label: "Deny" }] + : []), + { decision: "cancel", label: "Cancel" }, + ]; +} + +function optionIdForDecision(request: NativePermission, decision: ProviderApprovalDecision) { + switch (decision) { + case "accept": + return selectOptionId(request, ["allow_once"]); + case "acceptForSession": + return selectOptionId(request, ["allow_always"]); + case "decline": + return selectOptionId(request, ["reject_once", "reject_always"]); + case "cancel": + return undefined; + } +} + +function unknownRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function nonEmptyText(value: unknown, fallback: string): string { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +/** Maps a form elicitation's flat schema onto T3 user-input questions. */ +function acpRegistryElicitationQuestions(request: { + readonly message: string; + readonly requestedSchema: unknown; +}): ReadonlyArray { + const properties = unknownRecord(unknownRecord(request.requestedSchema)?.properties) ?? {}; + return Object.entries(properties).map(([id, property], index) => { + const record = unknownRecord(property); + const enumValues = Array.isArray(record?.enum) + ? record.enum.filter((value): value is string => typeof value === "string") + : []; + const options = + enumValues.length > 0 + ? enumValues.map((value) => ({ label: value, description: value })) + : record?.type === "boolean" + ? [ + { label: "true", description: "Yes" }, + { label: "false", description: "No" }, + ] + : []; + return { + id, + header: nonEmptyText(record?.title, `Question ${index + 1}`), + question: nonEmptyText(record?.description, nonEmptyText(request.message, "Answer")), + options, + ...(options.length === 0 ? { allowCustomAnswer: true } : {}), + multiSelect: false, + }; + }); +} + +/** The JSON schema type of each requested form field, keyed by field name. */ +function elicitationFieldTypes(requestedSchema: unknown): ReadonlyMap { + const properties = unknownRecord(unknownRecord(requestedSchema)?.properties) ?? {}; + return new Map( + Object.entries(properties).map(([key, property]) => [key, unknownRecord(property)?.type]), + ); +} + +/** Answers arrive as picked labels or typed text; the agent's schema decides the value type. */ +function elicitationContent( + answers: ProviderUserInputAnswers, + fieldTypes: ReadonlyMap, +): Record { + const content: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (!fieldTypes.has(key)) continue; + const type = fieldTypes.get(key); + if (type === "boolean" && (value === "true" || value === "false")) { + content[key] = value === "true"; + } else if ( + (type === "number" || type === "integer") && + typeof value === "string" && + value.trim() !== "" && + Number.isFinite(Number(value)) + ) { + content[key] = Number(value); + } else if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + content[key] = value; + } else if (Array.isArray(value)) { + content[key] = value.filter((entry): entry is string => typeof entry === "string"); + } + } + return content; +} + +function selectChoices(option: EffectAcpSchema.SessionConfigOption): ReadonlyArray { + return option.type === "select" + ? option.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), + ) + : []; +} + +// Per-agent exception ported from V2's AcpRegistryAdapterV2: Devin names its +// tools in metadata. Do not add more agent branches to this adapter. An agent +// that needs its own behavior gets a dedicated driver. +function normalizeDevinToolCall(tool: AcpToolCallState): AcpToolCallState { + const name = unknownRecord(tool.data.meta)?.["cognition.ai/inferenceToolName"]; + return typeof name === "string" && (!tool.title || tool.title === "Tool") + ? { ...tool, title: name } + : tool; +} + +/** + * Runs any ACP Registry agent on the V1 orchestrator through the plain ACP + * spec: one agent process per thread, T3 MCP through `AcpT3Mcp`, and the + * thread's runtime mode answering permission requests. + */ +export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(function* ( + options: AcpRegistryAdapterOptions, +) { + const catalog = yield* AcpRegistryCatalog; + const coordinator = yield* Effect.serviceOption(AcpRegistryRuntimeCoordinator); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const ownerScope = yield* Effect.scope; + const makeNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const selfInvocation = yield* resolveSelfInvocation(); + const { instanceId, settings } = options; + // Per-agent exception ported from V2 (see normalizeDevinToolCall): Devin + // runs commands through client terminals and has no ask mode over ACP to + // fall back on. Every other registry agent runs its own tools. + const isDevin = settings.agentId === "devin"; + const sessions = new Map(); + const locks = yield* SynchronizedRef.make(new Map()); + const events = yield* PubSub.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomId = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Could not create an ACP event ID.", + cause, + }), + ), + ); + const stamp = Effect.all({ + eventId: Effect.map(randomId, EventId.make), + createdAt: nowIso, + }); + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); + const policyFor = (context: SessionContext): AcpRuntimePolicy => ({ + runtimeMode: context.session.runtimeMode, + cwd: context.cwd, + }); + + const mapError = (threadId: ThreadId, method: string, cause: EffectAcpErrors.AcpError) => + cause._tag === "AcpRequestError" && cause.code === -32000 + ? new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `${settings.agentId || "The ACP agent"} requires sign-in. Sign in from provider settings, then try again.`, + cause, + }) + : mapAcpToAdapterError(PROVIDER, threadId, method, cause); + + const withThreadLock = (threadId: ThreadId, task: Effect.Effect) => + SynchronizedRef.modifyEffect(locks, (current) => { + const existing = current.get(threadId); + if (existing) return Effect.succeed([existing, current] as const); + return Semaphore.make(1).pipe( + Effect.map((lock) => [lock, new Map(current).set(threadId, lock)] as const), + ); + }).pipe(Effect.flatMap((lock) => lock.withPermit(task))); + + const requireSession = (threadId: ThreadId) => { + const context = sessions.get(threadId); + return context && !context.stopped + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + + const cancelRequests = Effect.fn("AcpRegistryAdapter.cancelRequests")(function* ( + context: SessionContext, + ) { + for (const pending of context.approvals.values()) { + yield* Deferred.succeed(pending.response, { decision: "cancel", result: CANCELLED }); + } + for (const pending of context.questions.values()) { + yield* Deferred.succeed(pending.response, undefined); + } + }); + + const stopContext = (context: SessionContext) => + context.stopLock + .withPermit( + Effect.gen(function* () { + if (context.closed) return; + context.stopped = true; + yield* Effect.gen(function* () { + yield* cancelRequests(context); + if (context.promptFiber && !context.disconnected) { + yield* Effect.ignore(context.runtime.cancel); + } + }).pipe(Effect.ensuring(Scope.close(context.scope, Exit.void))); + context.closed = true; + if (sessions.get(context.threadId) === context) sessions.delete(context.threadId); + yield* emit({ + type: "session.exited", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + payload: { + exitKind: context.disconnected ? "error" : "graceful", + ...(context.disconnected ? { reason: "The ACP agent process stopped." } : {}), + }, + }); + }), + ) + .pipe(Effect.uninterruptible); + + const handlePermission = Effect.fn("AcpRegistryAdapter.handlePermission")(function* ( + context: SessionContext, + request: NativePermission, + ): Effect.fn.Return { + if (context.stopped || request.sessionId !== context.nativeSessionId) return CANCELLED; + const disposition = acpPermissionDisposition(policyFor(context), request); + if (disposition === "allow") { + return permissionResponse(selectOptionId(request, ["allow_always", "allow_once"])); + } + if (disposition === "deny") { + return permissionResponse(selectOptionId(request, ["reject_once", "reject_always"])); + } + const requestId = ApprovalRequestId.make(yield* randomId); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const turnId = context.activeTurnId; + const response = yield* Deferred.make<{ + decision: ProviderApprovalDecision; + result: NativePermissionResponse; + }>(); + context.approvals.set(requestId, { request, response }); + const parsed = parsePermissionRequest(request); + const toolCall = + parsed.toolCall && isDevin ? normalizeDevinToolCall(parsed.toolCall) : parsed.toolCall; + const permissionRequest = { + ...parsed, + ...(toolCall ? { toolCall } : {}), + detail: + toolCall?.command ?? + toolCall?.detail ?? + toolCall?.title ?? + "The agent requests permission.", + }; + return yield* Effect.gen(function* () { + yield* emit( + makeAcpRequestOpenedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + approvalOptions: acpRegistryApprovalOptions(request), + detail: permissionRequest.detail, + args: request, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: request, + }), + ); + const answer = yield* Deferred.await(response); + if (answer.decision === "accept" || answer.decision === "acceptForSession") { + context.grants.recordApproval({ + kind: permissionRequest.kind === "execute" ? "command" : "file-change", + scope: answer.decision === "acceptForSession" ? "session" : "turn", + turnKey: String(turnId), + }); + } + yield* emit( + makeAcpRequestResolvedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: answer.decision, + }), + ); + return answer.result; + }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId)))); + }); + + const handleElicitation = Effect.fn("AcpRegistryAdapter.handleElicitation")(function* ( + context: SessionContext, + request: NativeElicitation, + transportRequestId: string, + ): Effect.fn.Return { + if (context.stopped) return { action: "cancel" }; + const mcpDisposition = acpMcpToolApprovalElicitationDisposition( + policyFor(context), + request, + transportRequestId, + ); + if (mcpDisposition === "allow") return { action: "accept", content: {} }; + if (mcpDisposition === "deny") return { action: "decline" }; + if ( + request.mode === "url" && + "url" in request && + typeof request.url === "string" && + "elicitationId" in request && + typeof request.elicitationId === "string" + ) { + const url = normalizeAcpRegistryWebUrl(request.url); + const elicitationId = request.elicitationId.trim(); + if (url === undefined || !elicitationId || Option.isNone(coordinator)) { + return { action: "decline" }; + } + const accepted = yield* coordinator.value.requestUrlAuthentication(instanceId, { + elicitationId: elicitationId.slice(0, 256), + url, + message: request.message.trim().slice(0, 1_024), + }); + return accepted ? { action: "accept" } : { action: "decline" }; + } + // Modes beyond form and url decline rather than guess at their meaning. + if (request.mode !== "form" || !("requestedSchema" in request)) return { action: "decline" }; + const questions = acpRegistryElicitationQuestions({ + message: request.message, + requestedSchema: request.requestedSchema, + }); + if (questions.length === 0) return { action: "decline" }; + const requestId = ApprovalRequestId.make(yield* randomId); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const turnId = context.activeTurnId; + const fieldTypes = elicitationFieldTypes(request.requestedSchema); + const response = yield* Deferred.make(); + context.questions.set(requestId, { response }); + return yield* Effect.gen(function* () { + yield* emit({ + type: "user-input.requested", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions }, + raw: { source: "acp.jsonrpc", method: "elicitation/create", payload: request }, + }); + const answers = yield* Deferred.await(response); + if (answers === undefined) return { action: "cancel" } as const; + yield* emit({ + type: "user-input.resolved", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers }, + }); + return { action: "accept", content: elicitationContent(answers, fieldTypes) } as const; + }).pipe(Effect.ensuring(Effect.sync(() => context.questions.delete(requestId)))); + }); + + const handleEvent = Effect.fn("AcpRegistryAdapter.handleEvent")(function* ( + context: SessionContext, + event: AcpSessionRuntime.AcpSessionRuntimeEvent, + ) { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if (context.stopped) return; + switch (event._tag) { + case "AvailableCommandsUpdated": + if (Option.isSome(coordinator)) { + yield* coordinator.value.publishAvailableCommands( + instanceId, + normalizeAcpRegistryCommands(event.availableCommands), + ); + } + return; + case "ModeChanged": + case "ConfigOptionsUpdated": + yield* publishConfiguration(context.runtime); + return; + case "ConnectionTerminated": + context.stopped = true; + context.disconnected = true; + yield* stopContext(context).pipe(Effect.forkIn(ownerScope)); + return; + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* emit( + makeAcpAssistantItemEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + itemId: event.itemId, + lifecycle: event._tag === "AssistantItemStarted" ? "item.started" : "item.completed", + }), + ); + return; + case "ThoughtDelta": + case "ContentDelta": + yield* emit( + makeAcpContentDeltaEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + ...(event._tag === "ContentDelta" && event.itemId ? { itemId: event.itemId } : {}), + ...(event._tag === "ThoughtDelta" ? { streamKind: "reasoning_text" } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "PlanUpdated": + yield* emit( + makeAcpPlanUpdatedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* emit( + makeAcpToolCallEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + toolCall: isDevin ? normalizeDevinToolCall(event.toolCall) : event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "UsageUpdated": + yield* emit({ + type: "thread.token-usage.updated", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId: context.activeTurnId, + payload: { usage: event.usage }, + }); + return; + case "SessionInfoUpdated": + case "UnknownUpdate": + return; + } + }); + + /** Publishes the agent's live models, options, and modes for the provider snapshot. */ + const publishConfiguration = (runtime: Runtime) => + Option.isNone(coordinator) + ? Effect.void + : Effect.all([runtime.getConfigOptions, runtime.getModeState]).pipe( + Effect.flatMap(([configOptions, modeState]) => + coordinator.value.publishLiveConfiguration( + instanceId, + normalizeAcpRegistryLiveConfiguration(configOptions, modeState), + ), + ), + ); + + /** + * Applies the thread's model, stored agent options, and plan mode. Values + * the live session no longer offers are skipped so a stale pick cannot + * block the turn; the agent's default applies instead. + */ + const configureSession = Effect.fn("AcpRegistryAdapter.configureSession")(function* ( + context: Pick, + modelSelection: ModelSelection | undefined, + interactionMode: ProviderInteractionMode, + ) { + const runtime = context.runtime; + const model = modelSelection?.model; + if (model && model !== "default" && model !== "auto") { + // Custom model ids the agent does not list are still sent; the agent decides. + const modelOption = (yield* runtime.getConfigOptions).find( + (option) => option.category === "model", + ); + if (modelOption?.type === "select" && modelOption.currentValue !== model) { + yield* runtime.setModel(model); + } + } + const selections = modelSelection?.options ?? []; + const configOptions = yield* runtime.getConfigOptions; + const nativeModeOption = configOptions.some( + (option) => option.id === ACP_SESSION_MODE_OPTION_ID, + ); + for (const selection of selections) { + if (selection.id === ACP_SESSION_MODE_OPTION_ID && !nativeModeOption) { + const modeState = yield* runtime.getModeState; + if ( + typeof selection.value === "string" && + modeState?.availableModes.some((mode) => mode.id === selection.value) === true && + modeState.currentModeId !== selection.value + ) { + yield* runtime.setMode(selection.value); + } + continue; + } + const option = configOptions.find((candidate) => candidate.id === selection.id); + if (option === undefined) continue; + if ( + option.type === "select" && + (typeof selection.value !== "string" || !selectChoices(option).includes(selection.value)) + ) { + continue; + } + yield* runtime.setConfigOption(selection.id, selection.value).pipe( + Effect.catchTags({ + AcpRequestError: (error) => + Effect.logWarning("ACP agent rejected a configuration option value", { + optionId: selection.id, + detail: error.message, + }), + }), + ); + } + + const modeState = yield* runtime.getModeState; + const planSensitive = (yield* runtime.getConfigOptions).filter( + (option) => + option.type === "select" && + (option.category === "mode" || option.category === "collaboration_mode"), + ); + if (interactionMode === "plan") { + context.buildConfiguration ??= { + modeId: modeState?.currentModeId, + configOptions: planSensitive.flatMap((option) => + option.type === "select" ? [{ id: option.id, value: option.currentValue }] : [], + ), + }; + const planMode = modeState?.availableModes.find( + (mode) => mode.id === "plan" || mode.id === "architect", + ); + if (planMode && modeState?.currentModeId !== planMode.id) { + yield* runtime.setMode(planMode.id); + } + for (const option of planSensitive) { + const planValue = selectChoices(option).find( + (choice) => choice === "plan" || choice === "architect", + ); + if (option.type === "select" && planValue && option.currentValue !== planValue) { + yield* runtime.setConfigOption(option.id, planValue); + } + } + } else if (context.buildConfiguration) { + const saved = context.buildConfiguration; + context.buildConfiguration = undefined; + if ( + saved.modeId !== undefined && + modeState?.currentModeId !== saved.modeId && + modeState?.availableModes.some((mode) => mode.id === saved.modeId) === true + ) { + yield* runtime.setMode(saved.modeId); + } + for (const value of saved.configOptions) { + const option = planSensitive.find((candidate) => candidate.id === value.id); + if ( + option?.type === "select" && + option.currentValue !== value.value && + selectChoices(option).includes(value.value) + ) { + yield* runtime.setConfigOption(option.id, value.value); + } + } + } + yield* publishConfiguration(runtime); + }); + + const makeRuntime = Effect.fn("AcpRegistryAdapter.makeRuntime")(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly resumeSessionId: string | undefined; + readonly clientTerminals: AcpClientTerminals | undefined; + }) { + const clientTerminals = input.clientTerminals; + const mcp = McpProviderSession.readMcpProviderSession(input.threadId); + const resolved = yield* catalog.resolve(settings, input.cwd, options.environment).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/start", + detail: cause.detail, + cause, + }), + ), + ); + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: { + ...resolved.spawn, + env: McpProviderSession.withAgentDeviceEnvironment(resolved.spawn.env ?? {}, mcp), + }, + cwd: input.cwd, + ...(input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {}), + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: clientTerminals !== undefined, + elicitation: { form: {}, ...(Option.isSome(coordinator) ? { url: {} } : {}) }, + }, + // Client terminal output reaches the thread through the tool call + // that embeds the terminal. + ...(clientTerminals + ? { + transformSessionUpdate: (notification: EffectAcpSchema.SessionNotification) => + resolveEmbeddedTerminalContent(notification, clientTerminals.readOutputSnapshot), + } + : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(settings.authMethodId ? { authMethodId: settings.authMethodId } : {}), + ...(mcp ? acpT3McpServers(mcp, selfInvocation) : {}), + ...makeNativeLoggers({ + nativeEventLogger: options.nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }), + }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(Crypto.Crypto, crypto), + ), + ), + ), + ); + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(context), + ); + if (mcp) { + yield* serveAcpMcpOverAcp(runtime, mcp).pipe(Effect.provideService(Crypto.Crypto, crypto)); + } + return { runtime, harness: resolved.agent.name }; + }); + + const startSession: AcpRegistryAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (!settings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Enable this ACP agent in provider settings before starting a thread.", + }); + } + if ( + (input.provider !== undefined && input.provider !== PROVIDER) || + (input.providerInstanceId !== undefined && input.providerInstanceId !== instanceId) || + (input.modelSelection !== undefined && input.modelSelection.instanceId !== instanceId) + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The ACP provider instance does not match the requested session.", + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The session requires a workspace directory.", + }); + } + const cursor = decodeResumeCursor(input.resumeCursor); + if (input.resumeCursor !== undefined && Option.isNone(cursor)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The saved ACP session is invalid. Start a new thread.", + }); + } + const previous = sessions.get(input.threadId); + if (previous) yield* stopContext(previous); + const cwd = path.resolve(input.cwd); + const sessionScope = yield* Scope.make("sequential"); + let transferred = false; + yield* Effect.addFinalizer(() => { + if (transferred) return Effect.void; + sessions.delete(input.threadId); + return Scope.close(sessionScope, Exit.void); + }); + + const handlers: { + context: SessionContext | undefined; + } = { context: undefined }; + const grants = makeAcpClientPolicyGrants(); + const clientTerminals = isDevin + ? yield* makeAcpClientTerminals({ + spawner, + defaultCwd: cwd, + environment: options.environment, + shellCommands: true, + }) + : undefined; + if (clientTerminals) { + yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); + } + + const startup = Effect.gen(function* () { + const { runtime, harness } = yield* makeRuntime({ + threadId: input.threadId, + cwd, + resumeSessionId: Option.isSome(cursor) ? cursor.value.sessionId : undefined, + clientTerminals, + }); + yield* runtime.handleRequestPermission((request) => + handlers.context + ? handlePermission(handlers.context, request).pipe( + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.internalError( + "Could not process an ACP permission request.", + undefined, + { cause }, + ), + ), + ) + : Effect.succeed(CANCELLED), + ); + yield* runtime.handleElicitation((request, requestContext) => + handlers.context + ? handleElicitation(handlers.context, request, requestContext.requestId).pipe( + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.internalError( + "Could not process an ACP elicitation.", + undefined, + { cause }, + ), + ), + ) + : Effect.succeed({ action: "cancel" } as const), + ); + if (clientTerminals) { + yield* runtime.handleCreateTerminal((request) => { + const context = handlers.context; + const disposition = context + ? acpClientExecuteDisposition(policyFor(context)) + : ("deny" as const); + return disposition === "allow" || + (disposition === "ask" && + context?.grants.allowsExecute(String(context.activeTurnId)) === true) + ? clientTerminals.create(request) + : Effect.fail( + EffectAcpErrors.AcpRequestError.internalError( + disposition === "ask" + ? "The active T3 runtime policy requires approval for terminal/create. Request permission with session/request_permission before retrying." + : "The active T3 runtime policy does not allow terminal/create.", + ), + ); + }); + yield* runtime.handleTerminalOutput(clientTerminals.output); + yield* runtime.handleTerminalWaitForExit(clientTerminals.waitForExit); + yield* runtime.handleTerminalKill(clientTerminals.kill); + yield* runtime.handleTerminalRelease(clientTerminals.release); + } + const started = yield* runtime.start(); + return { runtime, harness, started }; + }); + const { runtime, harness, started } = yield* ( + Option.isSome(coordinator) + ? coordinator.value.withForegroundStartup(settings.agentId, startup) + : startup + ).pipe(Effect.provideService(Scope.Scope, sessionScope)); + + const capabilities = started.initializeResult.agentCapabilities; + const canResume = + capabilities?.loadSession === true || capabilities?.sessionCapabilities?.resume != null; + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + threadId: input.threadId, + cwd, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.modelSelection?.model ? { model: input.modelSelection.model } : {}), + // Only agents that can load or resume a session get a cursor, so a + // restart never asks an agent to reopen a session it cannot. + ...(canResume + ? { resumeCursor: { schemaVersion: 1, sessionId: started.sessionId } } + : {}), + createdAt, + updatedAt: createdAt, + }; + const context: SessionContext = { + threadId: input.threadId, + cwd, + nativeSessionId: started.sessionId, + harness, + supportsImages: capabilities?.promptCapabilities?.image === true, + scope: sessionScope, + runtime, + promptLock: yield* Semaphore.make(1), + stopLock: yield* Semaphore.make(1), + approvals: new Map(), + questions: new Map(), + turns: [], + grants, + session, + buildConfiguration: undefined, + activeTurnId: undefined, + promptFiber: undefined, + generation: 0, + stopped: false, + closed: false, + disconnected: false, + }; + handlers.context = context; + sessions.set(input.threadId, context); + yield* configureSession(context, input.modelSelection, "default"); + yield* Stream.runForEach(runtime.getEvents(), (event) => handleEvent(context, event)).pipe( + Effect.catchCause(() => Effect.logError("Could not process an ACP runtime event.")), + Effect.forkIn(sessionScope), + ); + yield* emit({ + type: "session.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* emit({ + type: "session.state.changed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "ACP session ready" }, + }); + yield* emit({ + type: "thread.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + yield* runtime.drainEvents; + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + transferred = true; + return session; + }).pipe( + Effect.mapError((cause) => + isAcpError(cause) ? mapError(input.threadId, "session/start", cause) : cause, + ), + Effect.scoped, + ), + ); + + const buildPrompt = Effect.fn("AcpRegistryAdapter.buildPrompt")(function* ( + context: SessionContext, + input: Parameters[0], + model: string | undefined, + ) { + const prompt: Array = []; + const text = input.input?.trim(); + if (text) prompt.push({ type: "text", text }); + // ProviderService already put every attachment's path in the text, so an + // agent without image support still reaches the file through its tools. + if (context.supportsImages) { + for (const attachment of input.attachments ?? []) { + if (attachment.type !== "image") continue; + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Could not read attachment '${attachment.name}'.`, + cause, + }), + ), + ); + prompt.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + if (prompt.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + prompt.push({ + type: "text", + text: buildRuntimeInstructions({ harness: context.harness, model }), + }); + return prompt; + }); + + const sendTurn: AcpRegistryAdapterShape["sendTurn"] = Effect.fn("AcpRegistryAdapter.sendTurn")( + function* (input) { + const context = yield* requireSession(input.threadId); + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "The selected model belongs to another provider instance.", + }); + } + const model = input.modelSelection?.model ?? context.session.model; + const prompt = yield* buildPrompt(context, input, model); + let intent: TurnIntent | undefined; + // The caller holds promptLock while it changes or settles the active turn. + const finishTurn = (turn: TurnIntent, payload: TurnCompletedPayload) => + Effect.gen(function* () { + if (turn.settled || context.stopped || context.generation !== turn.generation) return; + turn.settled = true; + context.activeTurnId = undefined; + context.promptFiber = undefined; + context.session = { + ...context.session, + status: payload.state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + ...(payload.errorMessage + ? { lastError: payload.errorMessage } + : { lastError: undefined }), + }; + yield* emit({ + type: "turn.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId: turn.turnId, + payload, + }); + }).pipe(Effect.uninterruptible); + + return yield* Effect.gen(function* () { + const launch = yield* context.promptLock.withPermit( + Effect.gen(function* () { + yield* requireSession(input.threadId); + const turnId = context.activeTurnId ?? TurnId.make(yield* randomId); + const steering = context.activeTurnId !== undefined; + const turn: TurnIntent = { turnId, generation: ++context.generation, settled: false }; + intent = turn; + context.activeTurnId = turnId; + if (!steering) { + yield* emit({ + type: "turn.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: model ? { model } : {}, + }); + } + if (context.promptFiber) { + yield* cancelRequests(context); + yield* context.runtime.cancel; + yield* Fiber.await(context.promptFiber); + } + yield* configureSession( + context, + input.modelSelection, + input.interactionMode ?? "default", + ); + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + ...(model ? { model } : {}), + updatedAt: yield* nowIso, + }; + const dispatched = yield* Deferred.make(); + const fiber = yield* context.runtime + .prompt({ prompt }, { dispatched }) + .pipe(Effect.forkIn(context.scope)); + context.promptFiber = fiber; + // Fiber.join can skip a scope-close waiter when the child is + // interrupted. Unwrap the Exit after Fiber.await returns. + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe( + Effect.flatMap((exit) => exit), + Effect.asVoid, + ), + ); + return { turn, fiber }; + }), + ); + const result = yield* Fiber.await(launch.fiber).pipe(Effect.flatMap((exit) => exit)); + yield* context.runtime.drainEvents; + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + const record = context.turns.find((turn) => turn.id === launch.turn.turnId); + if (record) record.items.push(result); + else context.turns.push({ id: launch.turn.turnId, items: [result] }); + yield* context.promptLock.withPermit( + finishTurn(launch.turn, { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason, + }), + ); + return { + threadId: input.threadId, + turnId: launch.turn.turnId, + resumeCursor: context.session.resumeCursor, + }; + }).pipe( + Effect.mapError((cause) => + isAcpError(cause) ? mapError(input.threadId, "session/prompt", cause) : cause, + ), + Effect.tapError((cause) => + Effect.suspend(() => + intent + ? context.promptLock.withPermit( + finishTurn(intent, { state: "failed", errorMessage: cause.message }), + ) + : Effect.void, + ), + ), + Effect.onInterrupt(() => + context.promptLock.withPermit( + Effect.gen(function* () { + const turn = intent; + if ( + !turn || + turn.settled || + context.stopped || + context.generation !== turn.generation + ) + return; + const promptFiber = context.promptFiber; + yield* cancelRequests(context); + yield* Effect.ignore(context.runtime.cancel); + if (promptFiber) yield* Fiber.interrupt(promptFiber); + yield* finishTurn(turn, { state: "cancelled", stopReason: "cancelled" }); + }), + ), + ), + ); + }, + ); + + const interruptTurn: AcpRegistryAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + yield* context.promptLock + .withPermit( + Effect.gen(function* () { + yield* cancelRequests(context); + yield* context.runtime.cancel; + }), + ) + .pipe(Effect.mapError((cause) => mapError(threadId, "session/cancel", cause))); + }); + + const respondToRequest: AcpRegistryAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.approvals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: "This approval request is no longer pending.", + }); + } + const optionId = optionIdForDecision(pending.request, decision); + if (decision !== "cancel" && optionId === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: + "The agent did not offer this permission choice. Select one of the offered choices.", + }); + } + yield* Deferred.succeed(pending.response, { + decision, + result: permissionResponse(optionId), + }); + }); + + const respondToUserInput: AcpRegistryAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.questions.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "elicitation/create", + detail: "This question is no longer pending.", + }); + } + yield* Deferred.succeed(pending.response, answers); + }); + + const stopSession: AcpRegistryAdapterShape["stopSession"] = (threadId) => + withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopContext)); + const stopAll: AcpRegistryAdapterShape["stopAll"] = () => + Effect.forEach([...sessions.values()], stopContext, { discard: true }); + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.void + : Effect.logError("Could not stop an ACP session."), + ), + Effect.ensuring(PubSub.shutdown(events)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + stopAll, + listSessions: () => + Effect.sync(() => + [...sessions.values()] + .filter((context) => !context.stopped) + .map((context) => ({ ...context.session })), + ), + hasSession: (threadId) => + Effect.sync(() => sessions.has(threadId) && !sessions.get(threadId)?.stopped), + readThread: (threadId) => + Effect.map(requireSession(threadId), (context) => ({ threadId, turns: context.turns })), + rollbackThread: (_threadId: ThreadId, _numTurns: number) => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "ACP agents do not support conversation rewind. Start a new thread instead.", + }), + ), + streamEvents: Stream.fromPubSub(events), + } satisfies AcpRegistryAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/AcpRegistryCatalog.ts b/apps/server/src/provider/Layers/AcpRegistryCatalog.ts new file mode 100644 index 000000000000..7314b9a71f3c --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryCatalog.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../../config.ts"; +import { AcpRegistryCatalog } from "../acp/AcpRegistrySupport.ts"; +import { AcpRegistryRuntimeCoordinator } from "../acp/AcpRegistryRuntimeCoordinator.ts"; + +/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ +export const AcpRegistryCatalogLive = Layer.merge( + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + return AcpRegistryCatalog.layer({ + cacheDir: config.providerStatusCacheDir, + toolsDir: path.join(config.baseDir, "tools"), + }); + }), + ), + AcpRegistryRuntimeCoordinator.layer, +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 31c9fcbe871f..6718c71f2fca 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -56,6 +56,12 @@ import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; +import { AcpRegistryCatalog } from "../acp/AcpRegistrySupport.ts"; +import { AcpRegistryCatalogLive } from "./AcpRegistryCatalog.ts"; + +type ProviderInstanceRegistryHydrationEnv = + | Exclude + | ServerSettingsService; /** * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. @@ -153,7 +159,7 @@ const SettingsWatcherLive = Layer.effectDiscard( export const ProviderInstanceRegistryHydrationLive: Layer.Layer< ProviderInstanceRegistry, never, - BuiltInDriversEnv | ServerSettingsService + ProviderInstanceRegistryHydrationEnv > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -168,8 +174,8 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< const mutableLayer = ProviderInstanceRegistryMutableLayer({ drivers: BUILT_IN_DRIVERS, configMap: initialConfigMap, - }); + }).pipe(Layer.provide(AcpRegistryCatalogLive)); return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); }), -) as Layer.Layer; +) as Layer.Layer; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index c33b1dfc690a..2fdb6e30ecfe 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -59,6 +59,7 @@ import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +import { AcpRegistryCatalogLive } from "./AcpRegistryCatalog.ts"; const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, @@ -596,6 +597,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // `FileSystem` dep while keeping everything else surfaced to the test. const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); const testLayer = AntigravityInstallation.layer.pipe( + Layer.provideMerge(AcpRegistryCatalogLive), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ee1a23573277..887759ea02f1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -707,6 +707,82 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale ACP Registry models missing from a completed discovery probe", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("acpRegistry_codex"), + driver: ProviderDriverKind.make("acpRegistry"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-13T00:00:00.000Z", + version: "1.2.0", + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + { + slug: "gpt-5.6-sol[low]", + name: "GPT-5.6-Sol (low)", + isCustom: false, + capabilities: null, + }, + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-08-13T00:01:00.000Z", + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains ACP Registry models while discovery has not completed", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("acpRegistry_codex"), + driver: ProviderDriverKind.make("acpRegistry"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-13T00:00:00.000Z", + version: "1.2.0", + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const checkingProvider = { + ...previousProvider, + checkedAt: "2026-08-13T00:01:00.000Z", + auth: { status: "unknown" }, + models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], + } satisfies ServerProvider; + const failedProbeProvider = { + ...checkingProvider, + status: "warning", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, checkingProvider).models, [ + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, failedProbeProvider).models, + [ + { slug: "default", name: "Default", isCustom: false, capabilities: null }, + { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, + ], + ); + }); + it("drops stale OpenCode models missing from a successful refresh", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a65bc66edf32..c3cb2cab78ad 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -103,6 +103,19 @@ export function upsertProviderWorkspaceSnapshot( } const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { + if (provider.driver === ProviderDriverKind.make("acpRegistry")) { + // ACP Registry discovery probes return the agent's complete inventory, so + // a completed probe (ready and authenticated) replaces the model list — + // otherwise agents that rename or collapse models leave stale entries + // pinned forever through the snapshot cache. Readiness-only and failed + // probe snapshots only know the "default" placeholder and stay partial. + return !( + provider.installed && + provider.status === "ready" && + provider.auth.status === "authenticated" + ); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { diff --git a/apps/server/src/provider/Services/AcpRegistryAdapter.ts b/apps/server/src/provider/Services/AcpRegistryAdapter.ts new file mode 100644 index 000000000000..347265f699c3 --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryAdapter.ts @@ -0,0 +1,16 @@ +/** + * AcpRegistryAdapter — shape type for the generic ACP Registry adapter. + * + * The driver model ({@link ../Drivers/AcpRegistryDriver}) bundles one adapter + * per instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module AcpRegistryAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * AcpRegistryAdapterShape — per-instance adapter for one ACP Registry agent. + */ +export interface AcpRegistryAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.test.ts b/apps/server/src/provider/acp/AcpRegistryAuth.test.ts new file mode 100644 index 000000000000..5f7281be24f2 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryAuth.test.ts @@ -0,0 +1,342 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + AcpRegistrySettings, + ProviderInstanceId, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as AcpSchema from "effect-acp/compat"; +import { AcpRequestError } from "effect-acp/errors"; + +import { PtyAdapter, type PtyExitEvent, type PtySpawnInput } from "../../terminal/PtyAdapter.ts"; +import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; +import { AcpRegistryCatalog, type ResolvedAcpRegistryAgent } from "./AcpRegistrySupport.ts"; +import type { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); +const instanceId = ProviderInstanceId.make("acp-auth-test"); +const browserMethod = { id: "browser", name: "Browser", type: "agent" as const }; +const terminalMethod = { + id: "terminal", + name: "Terminal", + type: "terminal" as const, + args: ["login"], + env: { LOGIN: "yes", OVERRIDE: "method" }, +}; +const resolved: ResolvedAcpRegistryAgent = { + agent: { + id: "test-agent", + name: "Test", + version: "1.0.0", + description: "Test", + distribution: {}, + }, + distribution: "binary", + spawn: { + command: "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/managed/agent", + args: ["--acp"], + env: { OVERRIDE: "spawn", AGENT_HOME: "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/custom/home" }, + }, +}; +const catalog: AcpRegistryCatalog["Service"] = { + inspect: () => + Effect.succeed({ + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "binary", + }), + resolve: () => Effect.succeed(resolved), + search: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + uninstallManagedBinary: () => Effect.die("unused"), +}; + +const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => + Effect.gen(function* () { + const verify = yield* Deferred.make(); + const changed: boolean[] = []; + const closed: number[] = []; + const authenticated: string[] = []; + const started: number[] = []; + let runtimes = 0; + let version = "1.0.0"; + let terminalSpawn: PtySpawnInput | undefined; + let data: ((data: string) => void) | undefined; + let exit: ((event: PtyExitEvent) => void) | undefined; + let killed = false; + let closedBeforeTerminal = false; + const written: string[] = []; + const sizes: number[][] = []; + const controller = yield* makeAcpRegistryAuth({ + instanceId, + settings: decodeSettings({ agentId: "test-agent" }), + cwd: "/workspace", + environment: { PATH: "/tools", OVERRIDE: "base" }, + onChanged: (value) => + Effect.sync(() => { + changed.push(value); + }), + makeRuntime: () => + Effect.gen(function* () { + const id = ++runtimes; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closed.push(id); + }), + ); + const initialized: AcpSchema.InitializeResponse = { + protocolVersion: 2, + agentCapabilities: {}, + authMethods: [{ ...method, name: `${method.name} ${version}` }], + }; + let elicitation: + | Parameters[0] + | undefined; + return { + initialize: () => Effect.succeed(initialized), + handleElicitation: (handler) => + Effect.sync(() => { + elicitation = handler; + }), + authenticate: (methodId) => + Effect.gen(function* () { + authenticated.push(methodId); + if (elicitation) + yield* elicitation( + { + mode: "url", + url: "https://example.com/login", + elicitationId: "consent", + requestId: "login", + message: "Sign in", + }, + { requestId: "login", method: "elicitation/create" }, + ); + }), + start: () => + Effect.gen(function* () { + started.push(id); + yield* Deferred.await(verify); + if (failVerification) + return yield* new AcpRequestError({ + method: "session/new", + code: -32000, + errorMessage: "Authentication required", + }); + return { + sessionId: "verified", + initializeResult: initialized, + sessionSetupResult: { sessionId: "verified" }, + modelConfigId: undefined, + }; + }), + logout: Effect.succeed({}), + }; + }), + }).pipe( + Effect.provideService(AcpRegistryCatalog, { + ...catalog, + inspect: () => + Effect.sync(() => ({ + status: "ready" as const, + agentId: "test-agent", + version, + distribution: "binary" as const, + })), + }), + Effect.provideService(PtyAdapter, { + spawn: (input) => + Effect.sync(() => { + terminalSpawn = input; + closedBeforeTerminal = closed.includes(runtimes); + return { + pid: 123, + write: (input) => { + written.push(input); + }, + resize: (cols, rows) => { + sizes.push([cols, rows]); + }, + kill: () => { + killed = true; + }, + onData: (callback) => { + data = callback; + return () => { + data = undefined; + }; + }, + onExit: (callback) => { + exit = callback; + return () => { + exit = undefined; + }; + }, + }; + }), + }), + ); + const state = (predicate: (state: ProviderAuthState) => boolean) => + controller + .subscribe("owner") + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + const phase = (phase: ProviderAuthState["phase"]) => state((value) => value.phase === phase); + yield* state((value) => (value.methods?.length ?? 0) > 0); + return { + controller, + verify, + changed, + closed, + authenticated, + started, + phase, + state, + terminalSpawn: () => terminalSpawn, + closedBeforeTerminal: () => closedBeforeTerminal, + data: (value: string) => data?.(value), + exit: (exitCode: number) => exit?.({ exitCode, signal: null }), + killed: () => killed, + written, + sizes, + runtimes: () => runtimes, + setVersion: (value: string) => { + version = value; + }, + }; + }).pipe(Effect.provideService(AcpRegistryCatalog, catalog)); + +it.effect( + "discovers methods without signing in, then waits for browser consent and session verification", + () => + Effect.gen(function* () { + const h = yield* makeHarness(browserMethod); + assert.deepEqual(h.authenticated, []); + assert.deepEqual(h.started, []); + assert.deepEqual(h.closed, [1]); + const flow = yield* h.controller.start("owner", Effect.void, "browser"); + const waiting = yield* h.phase("waiting"); + assert.deepEqual(waiting.interaction, { + type: "browser", + id: "consent", + url: "https://example.com/login", + requiresConsent: true, + }); + yield* h.controller.respond!("owner", { + instanceId, + flowId: flow.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }); + yield* h.phase("verifying"); + assert.deepEqual(h.changed, []); + yield* Deferred.succeed(h.verify, undefined); + yield* h.phase("succeeded"); + assert.deepEqual(h.authenticated, ["browser"]); + assert.deepEqual(h.changed, [true]); + assert.deepEqual(h.closed, [1, 2]); + yield* h.controller.logout(Effect.void); + assert.deepEqual(h.changed, [true, false]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "runs terminal auth directly with the resolved environment and reconnects to verify", + () => + Effect.gen(function* () { + const h = yield* makeHarness(terminalMethod); + const flow = yield* h.controller.start("owner", Effect.void, "terminal"); + yield* h.phase("waiting"); + assert.isTrue(h.closedBeforeTerminal()); + assert.deepEqual(h.terminalSpawn(), { + shell: "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/managed/agent", + args: ["--acp", "login"], + cwd: "/workspace", + cols: 80, + rows: 24, + env: { PATH: "/tools", OVERRIDE: "method", AGENT_HOME: "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/custom/home", LOGIN: "yes" }, + }); + h.data("Enter code: "); + const output = yield* h.state( + (value) => + value.interaction?.type === "terminal" && value.interaction.output === "Enter code: ", + ); + assert.strictEqual( + output.interaction?.type === "terminal" && output.interaction.outputOffset, + 12, + ); + yield* h.controller.respond!("owner", { + instanceId, + flowId: flow.flowId!, + interactionId: "terminal", + response: { type: "terminal", data: "123\r", size: { cols: 100, rows: 30 } }, + }); + assert.deepEqual(h.written, ["123\r"]); + assert.deepEqual(h.sizes, [[100, 30]]); + h.exit(0); + yield* h.phase("verifying"); + assert.isTrue(h.killed()); + assert.deepEqual(h.authenticated, []); + yield* Deferred.succeed(h.verify, undefined); + yield* h.phase("succeeded"); + assert.deepEqual(h.started, [3]); + assert.deepEqual(h.changed, [true]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancels the login PTY and rejects further terminal input", () => + Effect.gen(function* () { + const h = yield* makeHarness(terminalMethod); + const flow = yield* h.controller.start("owner"); + yield* h.phase("waiting"); + yield* h.controller.cancel("owner", flow.flowId!); + assert.isTrue(h.killed()); + assert.deepEqual(h.started, []); + assert.deepEqual(h.changed, []); + assert.strictEqual( + (yield* h.controller.respond!("owner", { + instanceId, + flowId: flow.flowId!, + interactionId: "terminal", + response: { type: "terminal", data: "late\r" }, + }).pipe(Effect.result))._tag, + "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not report authentication when post-login verification fails", () => + Effect.gen(function* () { + const h = yield* makeHarness(terminalMethod, true); + yield* h.controller.start("owner"); + yield* h.phase("waiting"); + h.exit(0); + yield* h.phase("verifying"); + yield* Deferred.succeed(h.verify, undefined); + yield* h.phase("failed"); + assert.deepEqual(h.changed, []); + assert.isTrue(h.killed()); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refreshes advertised methods after a registry version change without logging in", () => + Effect.gen(function* () { + const h = yield* makeHarness(browserMethod); + const count = h.runtimes(); + yield* h.controller.refreshMethods!; + assert.strictEqual(h.runtimes(), count); + h.setVersion("2.0.0"); + yield* h.controller.refreshMethods!; + const state = yield* h.state((value) => value.methods?.[0]?.name === "Browser 2.0.0"); + assert.strictEqual(state.phase, "idle"); + assert.strictEqual(h.runtimes(), count + 1); + assert.deepEqual(h.authenticated, []); + assert.deepEqual(h.started, []); + assert.deepEqual(h.closed, [1, 2]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/apps/server/src/provider/acp/AcpRegistryAuth.ts new file mode 100644 index 000000000000..be4008262958 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryAuth.ts @@ -0,0 +1,370 @@ +import { + ProviderSetupError, + type AcpRegistrySettings, + type ProviderAuthMethod, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as AcpSchema from "effect-acp/compat"; + +import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; +import { make as makeProviderAuthFlow, type ProviderAuthFlowContext } from "../ProviderAuthFlow.ts"; +import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; +import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type Runtime = Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "initialize" | "authenticate" | "start" | "handleElicitation" | "logout" +>; + +/** ACP credentials stay in the agent's own store, using the same launch environment as chat. */ +export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly settings: AcpRegistrySettings; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + readonly onChanged: (authenticated: boolean) => Effect.Effect; + readonly makeRuntime?: ( + spawn: AcpSessionRuntime.AcpSpawnInput, + ) => Effect.Effect; +}) { + const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); + const coordinator = yield* Effect.serviceOption( + AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, + ); + const failure = (operation: string, detail: string, cause?: unknown) => + new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); + const resolve = catalog + .resolve(options.settings, options.cwd, options.environment) + .pipe( + Effect.mapError((cause) => + failure("start", "Could not prepare the selected ACP agent.", cause), + ), + ); + const makeRuntime = + options.makeRuntime ?? + ((spawn) => + Effect.gen(function* () { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn, + cwd: options.cwd, + authenticateOnAuthRequired: false, + clientCapabilities: { + auth: { terminal: Option.isSome(pty) }, + elicitation: { url: {} }, + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code-provider-auth", version: "0.0.0" }, + }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(Crypto.Crypto, crypto), + ), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(context), + ); + }).pipe( + Effect.mapError((cause) => + failure("start", "Could not start the selected ACP agent.", cause), + ), + )); + + let knownMethods: + | { readonly version: string | null; readonly methods: ReadonlyArray } + | undefined; + const discoverMethods = Effect.scoped( + Effect.gen(function* () { + const inspected = yield* catalog + .inspect(options.settings, options.environment) + .pipe( + Effect.mapError((cause) => + failure("methods", "Could not inspect the selected ACP agent.", cause), + ), + ); + if (inspected.status !== "ready") + return yield* failure("methods", "Prepare this ACP agent before signing in."); + // An unversioned command (a path override) can change in place, so only + // registry versions reuse earlier discovery. + if (inspected.version !== null && knownMethods?.version === inspected.version) + return knownMethods.methods; + const resolved = yield* resolve; + const runtime = yield* makeRuntime(resolved.spawn); + const initialized = yield* runtime + .initialize() + .pipe( + Effect.mapError((cause) => + failure("methods", "Could not discover this agent's sign-in methods.", cause), + ), + ); + const advertised = normalizeAcpRegistryAuthMethods(initialized.authMethods) + .filter( + (method) => method.id.length <= 128 && (method.type !== "terminal" || Option.isSome(pty)), + ) + .slice(0, 32) + .map((method) => ({ + id: method.id, + name: method.name, + description: method.description, + type: method.type === "env_var" ? ("credentials" as const) : method.type, + })); + knownMethods = { version: inspected.version, methods: advertised }; + return advertised; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => + Effect.fail(failure("methods", "The ACP agent did not advertise sign-in methods in time.")), + }), + ); + + const methods = Option.isSome(coordinator) + ? coordinator.value + .runBackgroundProbe(options.settings.agentId, discoverMethods) + .pipe( + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.fail( + failure( + "methods", + "Sign-in discovery was interrupted by an active provider session. Try again.", + ), + ), + ), + ) + : discoverMethods; + + const runTerminal = Effect.fnUntraced(function* ( + resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent, + method: Extract, + context: ProviderAuthFlowContext, + ) { + if (Option.isNone(pty)) + return yield* failure( + "start", + "Interactive provider login is unavailable on this environment.", + ); + const exited = yield* Deferred.make(); + const output = yield* Queue.sliding(64); + const process = yield* pty.value + .spawn({ + shell: resolved.spawn.command, + args: [...resolved.spawn.args, ...(method.args ?? [])], + cwd: options.cwd, + cols: 80, + rows: 24, + env: { ...options.environment, ...resolved.spawn.env, ...method.env }, + }) + .pipe( + Effect.mapError((cause) => + failure("start", "Could not open the provider sign-in terminal.", cause), + ), + ); + const detachData = process.onData((data) => { + Queue.offerUnsafe(output, data.slice(-16_384)); + }); + const detachExit = process.onExit(({ exitCode }) => { + Deferred.doneUnsafe(exited, Effect.succeed(exitCode)); + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + detachData(); + detachExit(); + try { + process.kill(); + } catch { + /* The login process may already have exited. */ + } + }), + ); + let transcript = ""; + let outputOffset = 0; + const update = () => + context.setInteraction( + { type: "terminal", id: "terminal", output: transcript, outputOffset }, + (response) => + response.type === "terminal" + ? Effect.try({ + try: () => { + if (response.size) process.resize(response.size.cols, response.size.rows); + if (response.data) process.write(response.data); + }, + catch: () => + failure("respond", "The provider sign-in terminal is no longer available."), + }) + : Effect.void, + ); + yield* update(); + yield* Queue.take(output).pipe( + Effect.flatMap((data) => { + outputOffset += data.length; + transcript = (transcript + data).slice(-16_384); + return update(); + }), + Effect.forever, + Effect.forkScoped, + ); + if ((yield* Deferred.await(exited)) !== 0) + return yield* failure("start", "The provider login command did not finish successfully."); + }); + + const authenticate = (methodId: string, context: ProviderAuthFlowContext) => + Effect.gen(function* () { + if (!options.settings.enabled) + return yield* failure("start", "Enable this provider before signing in."); + const login = Effect.scoped( + Effect.gen(function* () { + const resolved = yield* resolve; + const runtimeScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); + const runtime = yield* makeRuntime(resolved.spawn).pipe( + Effect.provideService(Scope.Scope, runtimeScope), + ); + yield* runtime.handleElicitation((request) => + Effect.gen(function* () { + if ( + request.mode !== "url" || + !("url" in request) || + !("elicitationId" in request) || + typeof request.url !== "string" || + typeof request.elicitationId !== "string" + ) + return { action: "decline" } as const; + const url = normalizeAcpRegistryWebUrl(request.url); + const id = request.elicitationId.trim(); + if (!url || !id || id.length > 128) return { action: "decline" } as const; + const consent = yield* Deferred.make(); + yield* context.setInteraction( + { type: "browser", id, url, requiresConsent: true }, + (response) => + response.type === "browser" + ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid) + : Effect.void, + ); + return (yield* Deferred.await(consent)) + ? ({ action: "accept" } as const) + : ({ action: "decline" } as const); + }), + ); + const initialized = yield* runtime + .initialize() + .pipe( + Effect.mapError((cause) => + failure("start", "Could not initialize the selected ACP agent.", cause), + ), + ); + const method = initialized.authMethods?.find((method) => method.id === methodId); + if (!method) + return yield* failure("start", "The agent no longer advertises this sign-in method."); + if (method.type === "terminal") { + yield* Scope.close(runtimeScope, Exit.void); + yield* runTerminal(resolved, method, context).pipe(Effect.scoped); + // Terminal login is a separate invocation. Reconnect after it exits so + // the ACP process reads the credentials its login command persisted. + const verifiedRuntime = yield* makeRuntime(resolved.spawn); + yield* context.verifying; + yield* verifiedRuntime + .start() + .pipe( + Effect.mapError((cause) => + failure( + "verify", + "The provider could not create a session after terminal sign-in.", + cause, + ), + ), + ); + } else { + if (method.type === undefined || method.type === "agent") { + if (!runtime.authenticate) + return yield* failure( + "start", + "This runtime does not support explicit ACP sign-in.", + ); + yield* runtime + .authenticate(methodId) + .pipe( + Effect.mapError((cause) => + failure("start", "The ACP agent could not complete sign-in.", cause), + ), + ); + } + // Legacy env_var methods use the secret-backed environment fields in + // provider settings. Never send their ID to authenticate. + yield* context.verifying; + yield* runtime + .start() + .pipe( + Effect.mapError((cause) => + failure( + "verify", + "The provider could not create a session after sign-in.", + cause, + ), + ), + ); + } + }), + ); + yield* Option.isSome(coordinator) + ? coordinator.value.withForegroundStartup(options.settings.agentId, login) + : login; + yield* options.onChanged(true); + }); + const logout = Effect.scoped( + Effect.gen(function* () { + const resolved = yield* resolve; + const runtime = yield* makeRuntime(resolved.spawn); + yield* runtime.logout.pipe( + Effect.mapError((cause) => + failure( + "logout", + "This ACP agent could not sign out. It may not advertise logout support.", + cause, + ), + ), + ); + }), + ).pipe( + Effect.timeoutOrElse({ + duration: "60 seconds", + orElse: () => + Effect.fail(failure("logout", "The ACP agent did not finish signing out in time.")), + }), + ); + const signOut = ( + Option.isSome(coordinator) + ? coordinator.value.withForegroundStartup(options.settings.agentId, logout) + : logout + ).pipe(Effect.andThen(options.onChanged(false))); + return yield* makeProviderAuthFlow({ + instanceId: options.instanceId, + // ACP doesn't advertise its credential scope. Conservatively treat all + // instances of the same agent on this environment as sharing credentials. + credentialBinding: { owner: "provider", key: `acp:${options.settings.agentId}` }, + methods, + ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), + authenticate, + logout: signOut, + }); +}); diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts new file mode 100644 index 000000000000..8f5e1ba63c2c --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts @@ -0,0 +1,99 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; + +import { makeAcpRegistryAuthenticationState } from "./AcpRegistryAuthenticationState.ts"; + +const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" }); + +it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => { + it.effect("restores explicit sign-in across driver recreation and server restart", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped(); + const input = { + cacheDir, + instanceId: ProviderInstanceId.make("acp_devin"), + settings, + environment: [], + processEnvironment: { HOME: "/home/test" }, + }; + const first = yield* makeAcpRegistryAuthenticationState(input); + assert.isFalse(yield* first.get); + yield* first.set(true); + const recreated = yield* makeAcpRegistryAuthenticationState({ + ...input, + settings: { ...settings, customModels: ["new-model"] }, + }); + assert.isTrue(yield* recreated.get); + // Both logout and an explicit authentication failure revoke confirmation. + yield* recreated.set(false); + const restarted = yield* makeAcpRegistryAuthenticationState(input); + assert.isFalse(yield* restarted.get); + }), + ); + + it.effect( + "does not carry confirmation to a different agent, method, executable, or credential environment", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped(); + const input = { + cacheDir, + instanceId: ProviderInstanceId.make("acp_devin"), + settings, + environment: [{ name: "DEVIN_TOKEN", value: "test-only-token", sensitive: true }], + processEnvironment: { HOME: "/home/test" }, + }; + for (const change of [ + { settings: { ...settings, agentId: "other-agent" } }, + { settings: { ...settings, authMethodId: "enterprise" } }, + { settings: { ...settings, commandPath: "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/other/devin" } }, + { environment: [{ name: "DEVIN_TOKEN", value: "different-token", sensitive: true }] }, + { processEnvironment: { HOME: "/home/other" } }, + ]) { + const signedIn = yield* makeAcpRegistryAuthenticationState(input); + yield* signedIn.set(true); + const changed = yield* makeAcpRegistryAuthenticationState({ ...input, ...change }); + assert.isFalse(yield* changed.get); + const restoredConfig = yield* makeAcpRegistryAuthenticationState(input); + assert.isFalse(yield* restoredConfig.get); + } + for (const name of yield* fs.readDirectory(cacheDir)) { + const contents = yield* fs.readFileString(`${cacheDir}/${name}`); + assert.isFalse(contents.includes("test-only-token")); + assert.isFalse(contents.includes("different-token")); + } + }), + ); + + it.effect("ignores damaged confirmation and keeps another instance independent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cacheDir = yield* fs.makeTempDirectoryScoped(); + const input = { + cacheDir, + instanceId: ProviderInstanceId.make("acp_devin"), + settings, + environment: [], + processEnvironment: {}, + }; + const first = yield* makeAcpRegistryAuthenticationState(input); + yield* first.set(true); + const other = yield* makeAcpRegistryAuthenticationState({ + ...input, + instanceId: ProviderInstanceId.make("acp_other"), + }); + assert.isFalse(yield* other.get); + for (const name of yield* fs.readDirectory(cacheDir)) { + yield* fs.writeFileString(`${cacheDir}/${name}`, "invalid"); + } + const restored = yield* makeAcpRegistryAuthenticationState(input); + assert.isFalse(yield* restored.get); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts new file mode 100644 index 000000000000..1f4799e65c24 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts @@ -0,0 +1,76 @@ +import * as NodeCrypto from "node:crypto"; +import type { + AcpRegistrySettings, + ProviderInstanceEnvironment, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../../atomicWrite.ts"; + +const decodeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), +); +const hash = (value: unknown) => + NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +/** Remember explicit sign-in success, never discovery success or the agent's credentials. */ +export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")( + function* (input: { + readonly cacheDir: string; + readonly instanceId: ProviderInstanceId; + readonly settings: AcpRegistrySettings; + readonly environment: ProviderInstanceEnvironment; + readonly processEnvironment: NodeJS.ProcessEnv; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(input.cacheDir, `acp-auth-${hash(input.instanceId)}.json`); + // Cosmetic settings and model discovery can rebuild the driver without + // changing the account. Credential overrides and profile paths cannot. + const binding = hash({ + agentId: input.settings.agentId, + commandPath: input.settings.commandPath, + distribution: input.settings.distribution, + authMethodId: input.settings.authMethodId, + environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)), + profiles: [ + "HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "APPDATA", + "LOCALAPPDATA", + ].map((name) => input.processEnvironment[name] ?? null), + }); + const saved = yield* fs.readFileString(filePath).pipe( + Effect.flatMap(decodeState), + Effect.orElseSucceed(() => undefined), + ); + const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated); + const lock = yield* Semaphore.make(1); + const persist = (authenticated: boolean) => + writeFileStringAtomically({ + filePath, + contents: JSON.stringify({ binding, authenticated }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")), + Effect.ignore, + ); + if (saved && saved.binding !== binding) yield* persist(false); + return { + get: Ref.get(confirmed), + set: (authenticated: boolean) => + lock.withPermit( + Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))), + ), + }; + }, +); diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts new file mode 100644 index 000000000000..5451bab0365a --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -0,0 +1,434 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; +import { + acpRegistryProbeFailure, + acpRegistryProbeResult, + normalizeAcpRegistryAuthMethods, + normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, +} from "./AcpRegistryProbe.ts"; +import { AcpRegistryCatalog } from "./AcpRegistrySupport.ts"; + +const instanceId = ProviderInstanceId.make("acpRegistry_codex"); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +describe("ACP Registry probe", () => { + it("returns advertised auth methods and de-duplicated models", () => { + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + auth: { logout: {} }, + sessionCapabilities: { list: {}, resume: {} }, + }, + authMethods: [ + { id: "chatgpt", name: "ChatGPT" }, + { + id: "api-key", + name: "API key", + type: "env_var", + vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }], + }, + { id: "login", name: "Terminal login", type: "terminal" }, + ], + }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5.4", + options: [ + { value: "gpt-5.4", name: "GPT-5.4" }, + { value: "gpt-5.4", name: "Duplicate" }, + ], + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result).toEqual({ + instanceId, + ready: true, + icon: null, + authMethods: [ + { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" }, + { + id: "api-key", + name: "API key", + description: null, + type: "env_var", + envVarNames: ["OPENAI_API_KEY"], + }, + { id: "login", name: "Terminal login", description: null, type: "terminal" }, + ], + models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }], + currentModelId: "gpt-5.4", + configOptions: [], + sessionManagement: { + canList: true, + canLoad: true, + canResume: true, + canLogout: true, + canDelete: false, + canConfigureProviders: false, + }, + }); + }); + + it("composes runnable terminal auth commands from the resolved spawn recipe", () => { + const methods = normalizeAcpRegistryAuthMethods( + [ + { + id: "login", + name: "Terminal login", + type: "terminal", + args: ["auth", "log in"], + env: { FORCE_TTY: "1" }, + }, + ], + { command: "/opt/agents/devin", args: ["--acp"] }, + ); + + expect(methods).toEqual([ + { + id: "login", + name: "Terminal login", + description: null, + type: "terminal", + command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'", + }, + ]); + }); + + it("omits unsafe truncated auth commands instead of publishing broken shell", () => { + const methods = normalizeAcpRegistryAuthMethods( + [ + { + id: "login", + name: "Terminal login", + type: "terminal", + args: ["x".repeat(2_048)], + }, + ], + { command: "/opt/agents/devin", args: ["--acp"] }, + ); + + expect(methods[0]).not.toHaveProperty("command"); + }); + + it("omits overlong opaque auth method and environment variable ids", () => { + const methods = normalizeAcpRegistryAuthMethods([ + { id: "x".repeat(129), name: "Invalid" }, + { + id: "api-key", + name: "API key", + type: "env_var", + vars: [ + { name: "y".repeat(129), label: "Invalid" }, + { name: "OPENAI_API_KEY", label: "OpenAI API key" }, + ], + }, + ]); + + expect(methods).toEqual([ + { + id: "api-key", + name: "API key", + description: null, + type: "env_var", + envVarNames: ["OPENAI_API_KEY"], + }, + ]); + }); + + it("falls back to model-category configuration options", () => { + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "sonnet", + options: [ + { value: "sonnet", name: "Sonnet", description: "Balanced" }, + { value: "haiku", name: "Haiku" }, + ], + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result.models).toEqual([ + { id: "sonnet", name: "Sonnet", description: "Balanced" }, + { id: "haiku", name: "Haiku", description: null }, + ]); + expect(result.currentModelId).toBe("sonnet"); + }); + + it("omits overlong opaque model ids instead of publishing mutated ids", () => { + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "x".repeat(129), + options: [ + { value: "x".repeat(129), name: "Too long" }, + { value: "valid", name: "Valid" }, + ], + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); + expect(result.currentModelId).toBeNull(); + }); + + it("omits a current model that falls outside the bounded model catalog", () => { + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "model-256", + options: Array.from({ length: 257 }, (_, index) => ({ + value: `model-${index}`, + name: `Model ${index}`, + })), + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result.models).toHaveLength(256); + expect(result.currentModelId).toBeNull(); + }); + + it("bounds and de-duplicates advertised commands", () => { + const commands = normalizeAcpRegistryCommands([ + { + name: "create_plan", + description: " Create a plan ", + input: { type: "text", hint: " topic " }, + }, + { name: "CREATE_PLAN", description: "duplicate" }, + ...Array.from({ length: 140 }, (_, index) => ({ + name: `command_${index}`, + description: "", + })), + ]); + + expect(commands.slashCommands).toHaveLength(128); + expect(commands.slashCommands[0]).toEqual({ + name: "create_plan", + description: "Create a plan", + input: { hint: "topic" }, + }); + expect( + commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"), + ).toHaveLength(1); + expect(commands.skills).toEqual([]); + }); + + it("routes dollar-prefixed ACP commands to the provider skill menu", () => { + expect( + normalizeAcpRegistryCommands([ + { name: "$workspace-skill", description: "Run the workspace skill" }, + { name: "$WORKSPACE-SKILL", description: "duplicate" }, + { name: "$", description: "empty" }, + { name: "review", description: "Review changes" }, + ]), + ).toEqual({ + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "workspace-skill", + description: "Run the workspace skill", + path: "acp://skill/workspace-skill", + scope: "agent", + enabled: true, + }, + ], + }); + }); + + it.effect("captures commands advertised just after session creation", () => + Effect.gen(function* () { + const result = yield* probeAcpRegistryConfiguration({ + instanceId, + settings: decodeSettings({ agentId: "mock-agent" }), + cwd: process.cwd(), + environment: process.env, + }); + + expect(result.slashCommands).toEqual([ + { + name: "review", + description: "Review the current changes", + input: { hint: "focus" }, + }, + ]); + expect(result.skills).toEqual([ + { + name: "workspace-skill", + description: "Run the workspace skill", + path: "acp://skill/workspace-skill", + scope: "agent", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + AcpRegistryCatalog, + AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: () => + Effect.succeed({ + agent: { + id: "mock-agent", + name: "Mock Agent", + version: "1.0.0", + description: "ACP probe test agent", + distribution: { npx: { package: "mock-agent@1.0.0" } }, + }, + distribution: "npx", + spawn: { + command: "node", + args: [mockAgentPath], + env: { + ...process.env, + T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25", + }, + }, + }), + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); + + it.effect("includes package resolution in the probe timeout", () => + Effect.gen(function* () { + const resolveStarted = yield* Deferred.make(); + const catalog = AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: () => + Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const probe = yield* probeAcpRegistryConfiguration({ + instanceId, + settings: decodeSettings({ agentId: "slow-agent" }), + cwd: process.cwd(), + environment: process.env, + }).pipe( + Effect.provideService(AcpRegistryCatalog, catalog), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(resolveStarted); + yield* TestClock.adjust("60 seconds"); + + expect(yield* Fiber.join(probe)).toMatchObject({ + reason: "probe_failed", + message: expect.stringContaining("within 60 seconds"), + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it("distinguishes authentication failures from generic probe failures", () => { + const advertised = normalizeAcpRegistryAuthMethods([ + { id: "grok-login", name: "Log in with Grok" }, + ]); + const cause = new EffectAcpErrors.AcpRequestError({ + code: -32000, + errorMessage: "login required", + }); + const authFailure = acpRegistryProbeFailure(cause, advertised); + expect(authFailure.reason).toBe("authentication_failed"); + expect(authFailure.message).toBe("The ACP agent could not complete authentication."); + expect(authFailure.cause).toBe(cause); + expect(authFailure.authMethods).toEqual([ + { + id: "grok-login", + name: "Log in with Grok", + description: null, + type: "agent", + }, + ]); + expect( + acpRegistryProbeFailure( + new EffectAcpErrors.AcpTransportError({ + detail: "Authentication required", + cause: "credentials missing", + }), + ).reason, + ).toBe("authentication_failed"); + const genericCause = new EffectAcpErrors.AcpTransportError({ + detail: "connection closed", + cause: "closed", + }); + const genericFailure = acpRegistryProbeFailure(genericCause); + expect(genericFailure.reason).toBe("probe_failed"); + expect(genericFailure.message).toBe("The ACP agent could not create a test session."); + expect(genericFailure.cause).toBe(genericCause); + expect( + acpRegistryProbeFailure( + new EffectAcpErrors.AcpTransportError({ + detail: "Failed while logging request", + cause: "logging failed", + }), + ).reason, + ).toBe("probe_failed"); + }); +}); diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts new file mode 100644 index 000000000000..11810ac575c1 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -0,0 +1,446 @@ +import { + AcpRegistryOperationError, + AcpRegistryProbeResult, + AcpRegistrySettings, + type AcpRegistryProbeAuthMethod, + type AcpRegistryProbeModel, + type AcpRegistryUrlAuthAction, + type ProviderInstanceId, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { PtyAdapter } from "../../terminal/PtyAdapter.ts"; +import { AcpRegistryCatalog, toAcpRegistryOperationError } from "./AcpRegistrySupport.ts"; +import { parseSessionModeState } from "./AcpRuntimeModel.ts"; +import { acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; +import { AcpRegistryRuntimeCoordinator } from "./AcpRegistryRuntimeCoordinator.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const MAX_AUTH_METHODS = 32; +const MAX_MODELS = 256; +const MAX_ID_LENGTH = 128; +const MAX_NAME_LENGTH = 160; +const MAX_DESCRIPTION_LENGTH = 1_024; +const MAX_COMMANDS = 128; +const MAX_COMMAND_LINE_LENGTH = 2_048; +// Covers a cold package command launch and ACP initialization after registry +// preparation globally installs the exact package. +const PROBE_TIMEOUT_SECONDS = 60; +const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; +const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; + +const boundedText = (value: string, maximumLength: number): string => + value.trim().slice(0, maximumLength); + +const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => + value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; + +export function normalizeAcpRegistryWebUrl(value: string): string | undefined { + if (value.length > 2_048 || !URL.canParse(value)) return undefined; + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; +} + +function modelConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + return (configOptions ?? []).flatMap((option) => { + if (option.category !== "model" || option.type !== "select") return []; + return option.options.flatMap((candidate) => + "value" in candidate ? [candidate] : candidate.options, + ); + }); +} + +function normalizeModels( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + // Model discovery is the model config option's base models; ACP has no + // other portable model inventory. + const candidates = modelConfigOptions(configOptions).map((model) => ({ + id: model.value, + name: model.name, + description: model.description ?? null, + })); + const seen = new Set(); + const models: Array = []; + for (const candidate of candidates) { + const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH); + if (id === undefined || seen.has(id)) continue; + seen.add(id); + models.push({ + id, + name: boundedText(candidate.name, MAX_NAME_LENGTH) || id, + description: + candidate.description === null + ? null + : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null, + }); + if (models.length === MAX_MODELS) break; + } + return models; +} + +/** Agent spawn recipe used to compose runnable terminal-auth command lines. */ +export interface AcpRegistryAuthSpawnContext { + readonly command: string; + readonly args: ReadonlyArray; +} + +const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u; + +function shellDisplayToken(token: string): string { + return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; +} + +function terminalAuthCommand( + method: Extract, + spawn: AcpRegistryAuthSpawnContext, +): string | undefined { + const environmentPrefix = Object.entries(method.env ?? {}).map( + ([name, value]) => `${name}=${shellDisplayToken(value)}`, + ); + const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken); + const displayCommand = [...environmentPrefix, ...command].join(" "); + return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined; +} + +export function normalizeAcpRegistryAuthMethods( + methods: ReadonlyArray | undefined, + spawn?: AcpRegistryAuthSpawnContext, +): ReadonlyArray { + const normalized: Array = []; + for (const method of methods ?? []) { + const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH); + if (id === undefined) continue; + const type = method.type ?? "agent"; + const envVarNames = + type === "env_var" && "vars" in method + ? method.vars + .flatMap((variable) => { + const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH); + return name === undefined ? [] : [name]; + }) + .slice(0, 16) + : []; + const command = + spawn !== undefined && "type" in method && method.type === "terminal" + ? terminalAuthCommand(method, spawn) + : undefined; + const link = + type === "env_var" && "link" in method && method.link + ? normalizeAcpRegistryWebUrl(method.link) + : undefined; + normalized.push({ + id, + name: boundedText(method.name, MAX_NAME_LENGTH) || id, + description: + method.description == null + ? null + : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null, + type, + ...(command === undefined ? {} : { command }), + ...(envVarNames.length > 0 ? { envVarNames } : {}), + ...(link === undefined ? {} : { link }), + }); + if (normalized.length === MAX_AUTH_METHODS) break; + } + return normalized; +} + +export interface AcpRegistryAvailableCommands { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} + +export interface AcpRegistryLiveConfiguration { + readonly models: ReadonlyArray; + readonly currentModelId: string | null; + readonly configOptions: AcpRegistryProbeResult["configOptions"]; +} + +/** Normalizes volatile session configuration using the same bounds as discovery probes. */ +export function normalizeAcpRegistryLiveConfiguration( + configOptions: ReadonlyArray, + modeState?: Parameters[0]["modeState"], +): AcpRegistryLiveConfiguration { + const modelOption = configOptions.find( + (option) => option.category === "model" && option.type === "select", + ); + const boundedCurrentModelId = + modelOption?.type === "select" + ? (boundedOpaqueValue(modelOption.currentValue, MAX_ID_LENGTH) ?? null) + : null; + const models = normalizeModels(configOptions); + return { + models, + currentModelId: models.some((model) => model.id === boundedCurrentModelId) + ? boundedCurrentModelId + : null, + configOptions: acpProviderOptionDescriptors({ configOptions, modeState }), + }; +} + +const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({ + slashCommands: [], + skills: [], +}); + +/** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */ +export function normalizeAcpRegistryCommands( + commands: ReadonlyArray, +): AcpRegistryAvailableCommands { + const seen = new Set(); + const slashCommands: Array = []; + const skills: Array = []; + let accepted = 0; + for (const command of commands) { + const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH); + if (name === undefined) continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH); + const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : ""; + if (name.startsWith("$")) { + const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH); + if (skillName === undefined) continue; + skills.push({ + name: skillName, + ...(description ? { description } : {}), + path: `acp://skill/${encodeURIComponent(skillName)}`, + scope: "agent", + enabled: true, + }); + } else { + slashCommands.push({ + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + accepted += 1; + if (accepted === MAX_COMMANDS) break; + } + return { slashCommands, skills }; +} + +/** Builds the bounded wire result from a successfully created disposable ACP session. */ +export function acpRegistryProbeResult( + instanceId: ProviderInstanceId, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + icon: string | null = null, + spawn?: AcpRegistryAuthSpawnContext, +): AcpRegistryProbeResult { + const liveConfiguration = normalizeAcpRegistryLiveConfiguration( + started.sessionSetupResult.configOptions ?? [], + parseSessionModeState(started.sessionSetupResult), + ); + return AcpRegistryProbeResult.make({ + instanceId, + ready: true, + icon, + authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn), + sessionManagement: { + canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null, + canLoad: started.initializeResult.agentCapabilities?.loadSession === true, + canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null, + canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null, + canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null, + canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null, + }, + ...liveConfiguration, + }); +} + +export function acpRegistryProbeFailure( + error: EffectAcpErrors.AcpError, + authMethods: ReadonlyArray = [], + authAction?: AcpRegistryUrlAuthAction, +): AcpRegistryOperationError { + const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message; + const authenticationFailed = + (error._tag === "AcpRequestError" && error.code === -32000) || + /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail); + return new AcpRegistryOperationError({ + reason: authenticationFailed ? "authentication_failed" : "probe_failed", + message: authenticationFailed + ? "The ACP agent could not complete authentication." + : "The ACP agent could not create a test session.", + cause: error, + ...(authMethods.length > 0 ? { authMethods } : {}), + ...(authAction === undefined ? {} : { authAction }), + }); +} + +export interface AcpRegistryConfigurationProbeInput { + readonly instanceId: ProviderInstanceId; + readonly settings: AcpRegistrySettings; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; +} + +export interface AcpRegistryConfigurationProbeResult { + readonly probe: AcpRegistryProbeResult; + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} + +export type AcpRegistryConfigurationProbe = ( + input: AcpRegistryConfigurationProbeInput, +) => Effect.Effect; + +/** Starts and immediately disposes an ACP session for one already-decoded configuration. */ +export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")( + function* ( + input: AcpRegistryConfigurationProbeInput, + ): Effect.fn.Return< + AcpRegistryConfigurationProbeResult, + AcpRegistryOperationError, + AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + > { + const catalog = yield* AcpRegistryCatalog; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const pty = yield* Effect.serviceOption(PtyAdapter); + + const result = yield* Effect.gen(function* () { + // Resolution may install a missing registry package. Keep it inside the + // probe deadline so a provider refresh cannot inherit the installer's + // much longer timeout. + const resolved = yield* catalog + .resolve(input.settings, input.cwd, input.environment) + .pipe(Effect.mapError(toAcpRegistryOperationError)); + const authMethodsRef = yield* Ref.make>([]); + const authActionRef = yield* Ref.make(undefined); + const runtimeCoordinator = yield* Effect.serviceOption(AcpRegistryRuntimeCoordinator); + const runtimeContext = yield* Layer.build( + AcpSessionRuntime.layer({ + spawn: resolved.spawn, + cwd: input.cwd, + clientCapabilities: { + auth: { terminal: Option.isSome(pty) }, + elicitation: { url: {} }, + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code-provider-test", version: "0.0.0" }, + authenticateOnAuthRequired: false, + onInitialized: (initializeResult) => + Ref.set( + authMethodsRef, + normalizeAcpRegistryAuthMethods(initializeResult.authMethods, { + command: resolved.spawn.command, + args: resolved.spawn.args, + }), + ), + ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}), + }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Layer.succeed(Crypto.Crypto, crypto), + ), + ), + ), + ); + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(runtimeContext), + ); + const commandsRef = yield* Ref.make( + emptyAcpRegistryAvailableCommands(), + ); + const commandsAdvertised = yield* Deferred.make(); + yield* runtime.handleElicitation((request) => + Effect.gen(function* () { + if ( + request.mode !== "url" || + !("url" in request) || + !("elicitationId" in request) || + typeof request.url !== "string" || + typeof request.elicitationId !== "string" + ) { + return { action: "decline" } as const; + } + const url = normalizeAcpRegistryWebUrl(request.url); + const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH); + if (url === undefined || elicitationId === undefined) { + return { action: "decline" } as const; + } + const action: AcpRegistryUrlAuthAction = { + elicitationId, + url, + message: boundedText(request.message, MAX_DESCRIPTION_LENGTH), + }; + yield* Ref.set(authActionRef, action); + const accepted = yield* Option.match(runtimeCoordinator, { + onNone: () => Effect.succeed(false), + onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action), + }); + return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const); + }), + ); + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + return update.sessionUpdate === "available_commands_update" + ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe( + Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)), + Effect.asVoid, + ) + : Effect.void; + }); + const startResult = yield* Effect.result(runtime.start()); + if (Result.isFailure(startResult)) { + return yield* acpRegistryProbeFailure( + startResult.failure, + yield* Ref.get(authMethodsRef), + yield* Ref.get(authActionRef), + ); + } + const started = startResult.success; + yield* Deferred.await(commandsAdvertised).pipe( + Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE), + ); + return { resolved, started, commands: yield* Ref.get(commandsRef) }; + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new AcpRegistryOperationError({ + reason: "probe_failed", + message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`, + }), + ), + }), + Effect.mapError((error) => + error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error), + ), + ); + return { + probe: acpRegistryProbeResult( + input.instanceId, + result.started, + result.resolved.agent.icon ?? null, + { + command: result.resolved.spawn.command, + args: result.resolved.spawn.args, + }, + ), + slashCommands: result.commands.slashCommands, + skills: result.commands.skills, + }; + }, +); diff --git a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts new file mode 100644 index 000000000000..39a40633e3d8 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import type { + AcpRegistryAvailableCommands, + AcpRegistryLiveConfiguration, +} from "./AcpRegistryProbe.ts"; +import { AcpRegistryRuntimeCoordinator } from "./AcpRegistryRuntimeCoordinator.ts"; + +describe("AcpRegistryRuntimeCoordinator", () => { + it.effect("suppresses a background probe while foreground startup is active", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const foregroundStarted = yield* Deferred.make(); + const releaseForeground = yield* Deferred.make(); + const foreground = yield* coordinator + .withForegroundStartup( + "kilo", + Deferred.succeed(foregroundStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseForeground)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(foregroundStarted); + + const probed = yield* coordinator.runBackgroundProbe("kilo", Effect.succeed("unexpected")); + expect(Option.isNone(probed)).toBe(true); + + yield* Deferred.succeed(releaseForeground, undefined); + yield* Fiber.join(foreground); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("interrupts an active background probe when foreground startup begins", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const probeStarted = yield* Deferred.make(); + const probeFinalized = yield* Deferred.make(); + const releaseForeground = yield* Deferred.make(); + const probe = yield* coordinator + .runBackgroundProbe( + "kilo", + Deferred.succeed(probeStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Deferred.succeed(probeFinalized, undefined).pipe(Effect.ignore)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + + const foreground = yield* coordinator + .withForegroundStartup("kilo", Deferred.await(releaseForeground)) + .pipe(Effect.forkChild); + expect(Option.isNone(yield* Fiber.join(probe))).toBe(true); + yield* Deferred.await(probeFinalized); + + yield* Deferred.succeed(releaseForeground, undefined); + yield* Fiber.join(foreground); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("replays and replaces late command advertisements per provider instance", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const codex = ProviderInstanceId.make("acpRegistry_codex"); + const kilo = ProviderInstanceId.make("acpRegistry_kilo"); + const firstSeen = yield* Deferred.make(); + const replacementSeen = yield* Deferred.make(); + const received: Array = []; + + yield* coordinator.publishAvailableCommands(codex, { + slashCommands: [{ name: "status" }], + skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], + }); + const consumer = yield* coordinator + .watchAvailableCommands(codex, (commands) => + Effect.gen(function* () { + received.push(commands); + yield* received.length === 1 + ? Deferred.succeed(firstSeen, undefined) + : Deferred.succeed(replacementSeen, undefined); + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSeen); + + yield* coordinator.publishAvailableCommands(kilo, { + slashCommands: [{ name: "review" }], + skills: [], + }); + yield* coordinator.publishAvailableCommands(codex, { slashCommands: [], skills: [] }); + yield* Deferred.await(replacementSeen); + yield* Fiber.interrupt(consumer); + + expect(received).toEqual([ + { + slashCommands: [{ name: "status" }], + skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], + }, + { slashCommands: [], skills: [] }, + ]); + expect(yield* coordinator.getAvailableCommands(kilo)).toEqual( + Option.some({ slashCommands: [{ name: "review" }], skills: [] }), + ); + yield* coordinator.clearAvailableCommands(codex); + expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("replays and replaces live configuration per provider instance", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const instanceId = ProviderInstanceId.make("acpRegistry_kilo"); + const firstSeen = yield* Deferred.make(); + const replacementSeen = yield* Deferred.make(); + const received: Array = []; + const first = { + models: [{ id: "sonnet", name: "Sonnet", description: null }], + currentModelId: "sonnet", + configOptions: [], + } satisfies AcpRegistryLiveConfiguration; + const replacement = { + models: [{ id: "opus", name: "Opus", description: null }], + currentModelId: "opus", + configOptions: [], + } satisfies AcpRegistryLiveConfiguration; + + yield* coordinator.publishLiveConfiguration(instanceId, first); + const consumer = yield* coordinator + .watchLiveConfiguration(instanceId, (configuration) => + Effect.gen(function* () { + received.push(configuration); + yield* received.length === 1 + ? Deferred.succeed(firstSeen, undefined) + : Deferred.succeed(replacementSeen, undefined); + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSeen); + yield* coordinator.publishLiveConfiguration(instanceId, replacement); + yield* Deferred.await(replacementSeen); + yield* Fiber.interrupt(consumer); + + expect(received).toEqual([first, replacement]); + expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement)); + yield* coordinator.clearLiveConfiguration(instanceId); + expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("requires matching user consent before accepting URL authentication", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); + const action = { + elicitationId: "login-1", + url: "https://accounts.example.com/login", + message: "Continue in your browser", + }; + const actionSeen = yield* Deferred.make(); + const consumer = yield* coordinator + .watchUrlAuthAction(instanceId, (current) => + current === null + ? Effect.void + : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), + ) + .pipe(Effect.forkChild); + const request = yield* coordinator + .requestUrlAuthentication(instanceId, action) + .pipe(Effect.forkChild); + yield* Deferred.await(actionSeen); + + expect(yield* coordinator.getUrlAuthAction(instanceId)).toEqual( + Option.some({ + ...action, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + }), + ); + + expect( + yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "stale" }), + ).toBe(false); + expect( + yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-1" }), + ).toBe(true); + expect(yield* Fiber.join(request)).toBe(true); + expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); + yield* Fiber.interrupt(consumer); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("expires stale URL authentication actions without accepting them", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const instanceId = ProviderInstanceId.make("acpRegistry_expired"); + const actionSeen = yield* Deferred.make(); + const consumer = yield* coordinator + .watchUrlAuthAction(instanceId, (current) => + current === null + ? Effect.void + : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), + ) + .pipe(Effect.forkChild); + const request = yield* coordinator + .requestUrlAuthentication(instanceId, { + elicitationId: "expired-login", + url: "https://accounts.example.com/login", + message: "Continue in your browser", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(actionSeen); + yield* TestClock.adjust("10 minutes"); + + expect(yield* Fiber.join(request)).toBe(false); + expect( + yield* coordinator.acceptUrlAuthentication({ + instanceId, + elicitationId: "expired-login", + }), + ).toBe(false); + expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); + yield* Fiber.interrupt(consumer); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("publishes a replacement URL action before retiring the previous request", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const instanceId = ProviderInstanceId.make("acpRegistry_replacement"); + const received: Array = []; + const firstSeen = yield* Deferred.make(); + const replacementSeen = yield* Deferred.make(); + const consumer = yield* coordinator + .watchUrlAuthAction(instanceId, (current) => + Effect.sync(() => { + received.push(current?.elicitationId ?? null); + }).pipe( + Effect.andThen( + current?.elicitationId === "login-1" + ? Deferred.succeed(firstSeen, undefined).pipe(Effect.asVoid) + : current?.elicitationId === "login-2" + ? Deferred.succeed(replacementSeen, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ) + .pipe(Effect.forkChild); + const first = yield* coordinator + .requestUrlAuthentication(instanceId, { + elicitationId: "login-1", + url: "https://accounts.example.com/first", + message: "Continue with the first login", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSeen); + const replacement = yield* coordinator + .requestUrlAuthentication(instanceId, { + elicitationId: "login-2", + url: "https://accounts.example.com/replacement", + message: "Continue with the replacement login", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(replacementSeen); + + expect(yield* Fiber.join(first)).toBe(false); + expect( + (yield* coordinator.getUrlAuthAction(instanceId)).pipe(Option.getOrThrow), + ).toMatchObject({ elicitationId: "login-2" }); + expect(received.slice(0, 2)).toEqual(["login-1", "login-2"]); + expect( + yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-2" }), + ).toBe(true); + expect(yield* Fiber.join(replacement)).toBe(true); + yield* Fiber.interrupt(consumer); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); + + it.effect("clears a published URL action when its request is interrupted", () => + Effect.gen(function* () { + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const instanceId = ProviderInstanceId.make("acpRegistry_interrupted"); + const actionSeen = yield* Deferred.make(); + const cleared = yield* Deferred.make(); + const consumer = yield* coordinator + .watchUrlAuthAction(instanceId, (current) => + current === null + ? Deferred.succeed(cleared, undefined).pipe(Effect.asVoid) + : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), + ) + .pipe(Effect.forkChild); + const request = yield* coordinator + .requestUrlAuthentication(instanceId, { + elicitationId: "interrupted-login", + url: "https://accounts.example.com/interrupted", + message: "Continue with login", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(actionSeen); + + yield* Fiber.interrupt(request); + yield* Deferred.await(cleared); + expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); + yield* Fiber.interrupt(consumer); + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts new file mode 100644 index 000000000000..a7a0639ce151 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts @@ -0,0 +1,359 @@ +import { + type AcpRegistryAcceptUrlAuthInput, + type AcpRegistryUrlAuthAction, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import type { + AcpRegistryAvailableCommands, + AcpRegistryLiveConfiguration, +} from "./AcpRegistryProbe.ts"; + +interface AvailableCommandsUpdate { + readonly instanceId: ProviderInstanceId; + readonly commands: AcpRegistryAvailableCommands; +} + +interface LiveConfigurationUpdate { + readonly instanceId: ProviderInstanceId; + readonly configuration: AcpRegistryLiveConfiguration; +} + +interface UrlAuthActionUpdate { + readonly instanceId: ProviderInstanceId; + readonly action: AcpRegistryUrlAuthAction | null; +} + +/** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */ +const URL_AUTH_ACTION_TTL = Duration.minutes(10); + +interface PendingUrlAuthAction { + readonly action: AcpRegistryUrlAuthAction; + readonly consent: Deferred.Deferred; + readonly expiresAt: DateTime.Utc; +} + +/** Gives a user-started ACP process priority over disposable discovery for the same agent. */ +export class AcpRegistryRuntimeCoordinator extends Context.Service< + AcpRegistryRuntimeCoordinator, + { + readonly withForegroundStartup: ( + agentId: string, + effect: Effect.Effect, + ) => Effect.Effect; + readonly runBackgroundProbe: ( + agentId: string, + effect: Effect.Effect, + ) => Effect.Effect, E, R>; + readonly clearAvailableCommands: (instanceId: ProviderInstanceId) => Effect.Effect; + readonly publishAvailableCommands: ( + instanceId: ProviderInstanceId, + commands: AcpRegistryAvailableCommands, + ) => Effect.Effect; + readonly getAvailableCommands: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + readonly watchAvailableCommands: ( + instanceId: ProviderInstanceId, + onUpdate: (commands: AcpRegistryAvailableCommands) => Effect.Effect, + ) => Effect.Effect; + readonly clearLiveConfiguration: (instanceId: ProviderInstanceId) => Effect.Effect; + readonly publishLiveConfiguration: ( + instanceId: ProviderInstanceId, + configuration: AcpRegistryLiveConfiguration, + ) => Effect.Effect; + readonly getLiveConfiguration: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + readonly watchLiveConfiguration: ( + instanceId: ProviderInstanceId, + onUpdate: (configuration: AcpRegistryLiveConfiguration) => Effect.Effect, + ) => Effect.Effect; + readonly requestUrlAuthentication: ( + instanceId: ProviderInstanceId, + action: AcpRegistryUrlAuthAction, + ) => Effect.Effect; + readonly acceptUrlAuthentication: ( + input: AcpRegistryAcceptUrlAuthInput, + ) => Effect.Effect; + readonly getUrlAuthAction: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect>; + readonly watchUrlAuthAction: ( + instanceId: ProviderInstanceId, + onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/provider/acp/AcpRegistryRuntimeCoordinator") { + static get layer() { + return layer; + } +} + +const make = Effect.gen(function* () { + const foregroundStarts = yield* PubSub.unbounded(); + const activeForegroundCounts = yield* Ref.make(new Map()); + const availableCommandsUpdates = yield* PubSub.unbounded(); + const availableCommandsByInstance = yield* Ref.make( + new Map(), + ); + const liveConfigurationUpdates = yield* PubSub.unbounded(); + const liveConfigurationByInstance = yield* Ref.make( + new Map(), + ); + const urlAuthActionUpdates = yield* PubSub.unbounded(); + const pendingUrlAuthActions = yield* Ref.make( + new Map(), + ); + const urlAuthActionPermit = yield* Semaphore.make(1); + + const withForegroundStartup: AcpRegistryRuntimeCoordinator["Service"]["withForegroundStartup"] = ( + agentId, + effect, + ) => + Effect.acquireUseRelease( + Ref.update(activeForegroundCounts, (current) => { + const next = new Map(current); + next.set(agentId, (next.get(agentId) ?? 0) + 1); + return next; + }).pipe(Effect.andThen(PubSub.publish(foregroundStarts, agentId))), + () => effect, + () => + Ref.update(activeForegroundCounts, (current) => { + const next = new Map(current); + const remaining = (next.get(agentId) ?? 1) - 1; + if (remaining <= 0) next.delete(agentId); + else next.set(agentId, remaining); + return next; + }), + ); + + const runBackgroundProbe: AcpRegistryRuntimeCoordinator["Service"]["runBackgroundProbe"] = ( + agentId, + effect, + ) => + Effect.scoped( + Effect.gen(function* () { + // Subscribe before checking state so a foreground start cannot land + // in the gap between the check and the interruptible probe. + const subscription = yield* PubSub.subscribe(foregroundStarts); + if ((yield* Ref.get(activeForegroundCounts)).has(agentId)) { + return Option.none(); + } + const foregroundStarted = Stream.fromSubscription(subscription).pipe( + Stream.filter((candidate) => candidate === agentId), + Stream.runHead, + Effect.as(Option.none()), + ); + return yield* Effect.raceFirst(effect.pipe(Effect.map(Option.some)), foregroundStarted); + }), + ); + + const clearAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["clearAvailableCommands"] = + (instanceId) => + Ref.update(availableCommandsByInstance, (current) => { + const next = new Map(current); + next.delete(instanceId); + return next; + }); + + const publishAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["publishAvailableCommands"] = + (instanceId, commands) => + Ref.update(availableCommandsByInstance, (current) => { + const next = new Map(current); + next.set(instanceId, commands); + return next; + }).pipe( + Effect.andThen( + PubSub.publish(availableCommandsUpdates, { + instanceId, + commands, + }), + ), + Effect.asVoid, + ); + + const getAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["getAvailableCommands"] = + Effect.fn("AcpRegistryRuntimeCoordinator.getAvailableCommands")(function* (instanceId) { + return Option.fromNullishOr((yield* Ref.get(availableCommandsByInstance)).get(instanceId)); + }); + + const watchAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["watchAvailableCommands"] = + (instanceId, onUpdate) => + Effect.scoped( + Effect.gen(function* () { + // Subscribe before reading the current value so a publication in + // between is either observed from the snapshot, the queue, or both. + const subscription = yield* PubSub.subscribe(availableCommandsUpdates); + const current = yield* getAvailableCommands(instanceId); + if (Option.isSome(current)) { + yield* onUpdate(current.value); + } + yield* Stream.fromSubscription(subscription).pipe( + Stream.filter((update) => update.instanceId === instanceId), + Stream.runForEach((update) => onUpdate(update.commands)), + ); + }), + ); + + const clearLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["clearLiveConfiguration"] = + (instanceId) => + Ref.update(liveConfigurationByInstance, (current) => { + const next = new Map(current); + next.delete(instanceId); + return next; + }); + + const publishLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["publishLiveConfiguration"] = + (instanceId, configuration) => + Ref.update(liveConfigurationByInstance, (current) => + new Map(current).set(instanceId, configuration), + ).pipe( + Effect.andThen( + PubSub.publish(liveConfigurationUpdates, { + instanceId, + configuration, + }), + ), + Effect.asVoid, + ); + + const getLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["getLiveConfiguration"] = + Effect.fn("AcpRegistryRuntimeCoordinator.getLiveConfiguration")(function* (instanceId) { + return Option.fromNullishOr((yield* Ref.get(liveConfigurationByInstance)).get(instanceId)); + }); + + const watchLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["watchLiveConfiguration"] = + (instanceId, onUpdate) => + Effect.scoped( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(liveConfigurationUpdates); + const current = yield* getLiveConfiguration(instanceId); + if (Option.isSome(current)) { + yield* onUpdate(current.value); + } + yield* Stream.fromSubscription(subscription).pipe( + Stream.filter((update) => update.instanceId === instanceId), + Stream.runForEach((update) => onUpdate(update.configuration)), + ); + }), + ); + + const publishUrlAuthAction = ( + instanceId: ProviderInstanceId, + action: AcpRegistryUrlAuthAction | null, + ) => PubSub.publish(urlAuthActionUpdates, { instanceId, action }).pipe(Effect.asVoid); + + const requestUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["requestUrlAuthentication"] = + Effect.fn("AcpRegistryRuntimeCoordinator.requestUrlAuthentication")( + function* (instanceId, action) { + const consent = yield* Deferred.make(); + const createdAt = yield* DateTime.now; + const expiresAt = DateTime.addDuration(createdAt, URL_AUTH_ACTION_TTL); + const publishedAction = { + ...action, + createdAt: DateTime.formatIso(createdAt), + expiresAt: DateTime.formatIso(expiresAt), + } satisfies AcpRegistryUrlAuthAction; + const cleanup = Ref.modify(pendingUrlAuthActions, (current) => { + if (current.get(instanceId)?.consent !== consent) { + return [false, current] as const; + } + const next = new Map(current); + next.delete(instanceId); + return [true, next] as const; + }).pipe( + Effect.flatMap((removed) => + removed ? publishUrlAuthAction(instanceId, null) : Effect.void, + ), + ); + return yield* Effect.gen(function* () { + yield* urlAuthActionPermit.withPermits(1)( + Effect.gen(function* () { + const previous = yield* Ref.modify(pendingUrlAuthActions, (current) => { + const next = new Map(current); + const existing = next.get(instanceId); + next.set(instanceId, { action: publishedAction, consent, expiresAt }); + return [existing, next] as const; + }); + if (previous !== undefined) { + yield* Deferred.succeed(previous.consent, false).pipe(Effect.ignore); + } + yield* publishUrlAuthAction(instanceId, publishedAction); + }), + ); + return yield* Deferred.await(consent).pipe( + Effect.timeoutOrElse({ + duration: URL_AUTH_ACTION_TTL, + orElse: () => Effect.succeed(false), + }), + ); + }).pipe(Effect.ensuring(cleanup)); + }, + ); + + const acceptUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["acceptUrlAuthentication"] = + Effect.fn("AcpRegistryRuntimeCoordinator.acceptUrlAuthentication")(function* (input) { + const pending = (yield* Ref.get(pendingUrlAuthActions)).get(input.instanceId); + if (pending?.action.elicitationId !== input.elicitationId) return false; + if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, pending.expiresAt)) { + yield* Deferred.succeed(pending.consent, false).pipe(Effect.ignore); + return false; + } + return yield* Deferred.succeed(pending.consent, true); + }); + + const getUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["getUrlAuthAction"] = Effect.fn( + "AcpRegistryRuntimeCoordinator.getUrlAuthAction", + )(function* (instanceId) { + return Option.fromNullishOr((yield* Ref.get(pendingUrlAuthActions)).get(instanceId)?.action); + }); + + const watchUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["watchUrlAuthAction"] = ( + instanceId, + onUpdate, + ) => + Effect.scoped( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(urlAuthActionUpdates); + const current = yield* getUrlAuthAction(instanceId); + if (Option.isSome(current)) { + yield* onUpdate(current.value); + } + yield* Stream.fromSubscription(subscription).pipe( + Stream.filter((update) => update.instanceId === instanceId), + Stream.runForEach((update) => onUpdate(update.action)), + ); + }), + ); + + return AcpRegistryRuntimeCoordinator.of({ + withForegroundStartup, + runBackgroundProbe, + clearAvailableCommands, + publishAvailableCommands, + getAvailableCommands, + watchAvailableCommands, + clearLiveConfiguration, + publishLiveConfiguration, + getLiveConfiguration, + watchLiveConfiguration, + requestUrlAuthentication, + acceptUrlAuthentication, + getUrlAuthAction, + watchUrlAuthAction, + }); +}); + +const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts new file mode 100644 index 000000000000..c276dcd8362a --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -0,0 +1,1584 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { AcpRegistrySettings } from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as TestClock from "effect/testing/TestClock"; +import * as NodeCrypto from "node:crypto"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + AcpRegistryError, + acpRegistryManagedBinaryDirectories, + makeAcpRegistryCatalog, + resolveAcpRegistryDistribution, + resolveAcpRegistryPlatformTarget, + toAcpRegistryOperationError, + type AcpRegistryAgent, +} from "./AcpRegistrySupport.ts"; + +const registryUrl = "https://registry.test/registry.json"; +const archiveUrl = "https://registry.test/example-agent.bin"; +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeAgent(distribution: AcpRegistryAgent["distribution"]): AcpRegistryAgent { + return { + id: "example-agent", + name: "Example Agent", + version: "1.2.3", + description: "ACP Registry test agent", + distribution, + }; +} + +function makeRegistry(agent: AcpRegistryAgent): string { + return JSON.stringify({ version: "1.0.0", agents: [agent] }); +} + +function settings(input: Partial = {}): AcpRegistrySettings { + return decodeAcpRegistrySettings({ + agentId: "example-agent", + ...input, + }); +} + +function resolverLayer( + execute: Parameters[0], + environment: NodeJS.ProcessEnv = process.env, +) { + return Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcessEnvironment, environment), + Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)), + ); +} + +const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* ( + rootDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const toolchainBin = path.join(rootDirectory, "fake-node", "bin"); + const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm"); + const globalBin = path.join(globalPrefix, "bin"); + const executablePath = path.join(globalBin, "example-agent"); + const npmPath = path.join(toolchainBin, "npm"); + const logPath = path.join(rootDirectory, "npm.log"); + yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); + yield* fileSystem.makeDirectory(globalPrefix, { recursive: true }); + yield* fileSystem.writeFileString(logPath, ""); + yield* fileSystem.writeFileString( + npmPath, + [ + "#!/bin/sh", + 'printf \'%s\\n\' "$*" >> "$FAKE_NPM_LOG"', + 'prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}"', + 'if [ "$1" = "root" ] && [ "$2" = "--global" ]; then', + " printf '%s\\n' \"$prefix/lib/node_modules\"", + " exit 0", + "fi", + 'if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then', + " printf '%s\\n' \"$prefix\"", + " exit 0", + "fi", + 'if [ "$1" = "install" ] && [ "$2" = "--global" ]; then', + ' package_root="$prefix/lib/node_modules/@example/acp"', + ' executable="$prefix/bin/example-agent"', + ' mkdir -p "$package_root" "$prefix/bin"', + ' printf \'%s\' "$FAKE_NPM_MANIFEST" > "$package_root/package.json"', + " printf '#!/bin/sh\\n' > \"$executable\"", + ' chmod 755 "$executable"', + " exit 0", + "fi", + "exit 64", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(npmPath, 0o755); + return { + environment: { + ...process.env, + PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, + FAKE_NPM_LOG: logPath, + FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"), + FAKE_NPM_MANIFEST: encodeUnknownJson({ + name: "@example/acp", + version: "1.2.3", + bin: { "example-agent": "dist/cli.js" }, + }), + } satisfies NodeJS.ProcessEnv, + executablePath, + globalBin, + logPath, + npmPath, + }; +}); + +const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* ( + rootDirectory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const toolchainBin = path.join(rootDirectory, "fake-uv", "bin"); + const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin"); + const executablePath = path.join(globalBin, "fast-agent"); + const uvPath = path.join(toolchainBin, "uv"); + const logPath = path.join(rootDirectory, "uv.log"); + yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); + yield* fileSystem.writeFileString(logPath, ""); + yield* fileSystem.writeFileString( + uvPath, + [ + "#!/bin/sh", + 'printf \'%s\\n\' "$*" >> "$FAKE_UV_LOG"', + 'tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}"', + 'executable="$tool_bin/fast-agent"', + 'printf "tool-dir=%s bin-dir=%s\\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG"', + 'if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then', + " printf '%s\\n' \"$tool_bin\"", + " exit 0", + "fi", + 'if [ "$1" = "tool" ] && [ "$2" = "list" ]; then', + ' if [ -x "$executable" ]; then', + " printf 'fast-agent-acp v0.10.1\\n- fast-agent\\n'", + " fi", + " exit 0", + "fi", + 'if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then', + ' mkdir -p "$tool_bin"', + " printf '#!/bin/sh\\n' > \"$executable\"", + ' chmod 755 "$executable"', + " exit 0", + "fi", + "exit 64", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(uvPath, 0o755); + return { + environment: { + ...process.env, + PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, + FAKE_UV_LOG: logPath, + FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"), + FAKE_UV_EXECUTABLE: executablePath, + } satisfies NodeJS.ProcessEnv, + executablePath, + globalBin, + logPath, + uvPath, + }; +}); + +describe("AcpRegistrySupport", () => { + it("preserves the registry failure when translating it for clients", () => { + const cause = new Error("registry unavailable"); + const failure = new AcpRegistryError({ + reason: "registry_unavailable", + detail: "Could not load the ACP Registry.", + cause, + }); + + expect(toAcpRegistryOperationError(failure)).toMatchObject({ + reason: "registry_unavailable", + message: "Could not load the ACP Registry.", + cause: failure, + }); + }); + + it("maps supported Node platforms to ACP Registry target keys", () => { + expect(resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe("darwin-aarch64"); + expect(resolveAcpRegistryPlatformTarget("linux", "x64")).toBe("linux-x86_64"); + expect(resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe("windows-aarch64"); + expect(resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined(); + expect(resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined(); + }); + + it("selects the preferred compatible distribution", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + args: ["acp"], + }, + }, + npx: { + package: "@example/acp@1.2.3", + args: ["--stdio"], + }, + }); + + expect( + resolveAcpRegistryDistribution({ + agent, + preference: "auto", + platformTarget: "linux-x86_64", + }), + ).toMatchObject({ kind: "binary", args: ["acp"] }); + expect( + resolveAcpRegistryDistribution({ + agent, + preference: "npx", + platformTarget: "linux-x86_64", + }), + ).toEqual({ + kind: "npx", + packageName: "@example/acp@1.2.3", + args: ["--stdio"], + env: {}, + }); + expect( + resolveAcpRegistryDistribution({ + agent, + preference: "binary", + platformTarget: "darwin-aarch64", + }), + ).toBeUndefined(); + }); + + it.effect("resolves command overrides while preserving registry args and environment", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + args: ["acp", "--stdio"], + env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" }, + }, + }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-override-", + }); + const commandPath = `${cacheDir}/example-agent`; + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", { + HOST_VALUE: "host", + OVERRIDE_ME: "host", + }); + + expect(resolved.distribution).toBe("binary"); + expect(resolved.spawn).toEqual({ + command: commandPath, + args: ["acp", "--stdio"], + cwd: "/workspace", + env: { + HOST_VALUE: "host", + OVERRIDE_ME: "registry", + REGISTRY_VALUE: "registry", + }, + }); + expect(requests).toEqual([registryUrl]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ); + }), + ), + ); + }); + + it.effect("installs into T3 home a package and launches its exposed command", () => { + const agent = makeAgent({ + npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-global-package-", + }); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); + + const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); + const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); + + expect(first.spawn).toMatchObject({ + command: toolchain.executablePath, + args: ["--stdio"], + env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, + }); + expect(second.spawn.command).toBe(toolchain.executablePath); + const npmCommands = yield* fileSystem.readFileString(toolchain.logPath); + expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1); + expect(npmCommands).toContain("install --global @example/acp@V1.2.3"); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("uses its managed prefix despite a system-owned npm prefix", () => { + const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-user-global-package-", + }); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + const systemPrefix = path.join(cacheDir, "system-global"); + yield* fileSystem.makeDirectory(systemPrefix); + yield* fileSystem.chmod(systemPrefix, 0o555); + const userHome = path.join(cacheDir, "home"); + const environment = { + ...toolchain.environment, + HOME: userHome, + FAKE_NPM_PREFIX: systemPrefix, + }; + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, environment)); + + const resolved = yield* resolver.resolve(settings(), "/workspace", environment); + const userGlobalBin = toolchain.globalBin; + expect(resolved.spawn).toMatchObject({ + command: path.join(userGlobalBin, "example-agent"), + env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("installs into T3 home a uv tool and launches its exposed command", () => { + const agent = makeAgent({ + uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-global-uv-tool-", + }); + const toolchain = yield* makeFakeUvToolchain(cacheDir); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + + const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); + + expect(resolved.spawn).toMatchObject({ + command: toolchain.executablePath, + args: ["--acp"], + env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, + }); + expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( + "tool install --force fast-agent-acp==V0.10.1", + ); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("honors an exact Windows PATH override when launching a global package", () => { + const agent = makeAgent({ + uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-windows-package-path-", + }); + const toolchain = yield* makeFakeUvToolchain(cacheDir); + const linuxResolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment); + + const windowsEnvironment = { + ...toolchain.environment, + Path: "C:\\host\\bin", + PATH: "C:\\provider\\bin", + }; + const windowsResolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, (command) => { + if (command === "uv") return toolchain.uvPath; + if (command === "fast-agent") return toolchain.executablePath; + return undefined; + }), + ); + const resolved = yield* windowsResolver.resolve( + settings(), + "C:\\workspace", + windowsEnvironment, + ); + + expect(resolved.spawn).toMatchObject({ + env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("installs and reuses a registry binary in the managed cache", () => { + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + args: ["acp"], + sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"), + }, + }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-install-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const first = yield* resolver.resolve(settings(), "/workspace"); + const second = yield* resolver.resolve(settings(), "/workspace"); + + expect(first.spawn.command).toBe(second.spawn.command); + expect(first.spawn.command).toContain( + "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent", + ); + expect(yield* fileSystem.readFileString(first.spawn.command)).toBe( + "#!/bin/sh\necho example\n", + ); + expect(requests).toEqual([registryUrl, archiveUrl]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + const response = + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(binaryBytes.slice(0, 8)); + controller.enqueue(binaryBytes.slice(8)); + controller.close(); + }, + }), + ); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }), + ), + ); + }); + + it.effect( + "prepares the registry version despite an older PATH binary and permits explicit overrides", + () => { + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n"); + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + args: ["acp"], + sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"), + }, + }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-system-", + }); + const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-system-bin-", + }); + const systemBinary = path.join(systemBinDir, "example-agent"); + yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n"); + yield* fileSystem.chmod(systemBinary, 0o755); + const environment = { PATH: systemBinDir }; + + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, environment)); + yield* resolver.search({ query: "example" }); + expect(yield* resolver.inspect(settings(), environment)).toMatchObject({ + status: "unprepared", + }); + + const overridden = settings({ commandPath: systemBinary }); + expect(yield* resolver.inspect(overridden, environment)).toMatchObject({ + status: "ready", + version: null, + }); + const custom = yield* resolver.resolve(overridden, "/workspace", environment); + expect(custom.spawn.command).toBe(systemBinary); + expect(custom.spawn.args).toEqual(["acp"]); + expect(requests).toEqual([registryUrl]); + + yield* resolver.prepare({ agentId: "example-agent" }); + const resolved = yield* resolver.resolve(settings(), "/workspace", environment); + expect(resolved.spawn.command).not.toBe(systemBinary); + expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( + "#!/bin/sh\necho managed\n", + ); + expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n"); + + const inspection = yield* resolver.inspect(settings(), environment); + expect(inspection).toMatchObject({ + status: "ready", + distribution: "binary", + version: "1.2.3", + }); + + expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(binaryBytes.buffer as ArrayBuffer), + ), + ); + }), + ), + ); + }, + ); + + it.effect("installs a tar archive containing a root directory entry", () => { + const downloadUrl = "https://registry.test/agent.tar.gz"; + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" }); + const source = `${cacheDir}/source`; + yield* fileSystem.makeDirectory(source); + yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n"); + const archivePath = `${cacheDir}/agent.tar.gz`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tar = yield* spawner.spawn( + ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]), + ); + expect(yield* tar.exitCode).toBe(0); + const archive = yield* fileSystem.readFile(archivePath); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(archive), + ), + ), + ), + ), + ); + yield* resolver.prepare({ agentId: agent.id }); + const resolved = yield* resolver.resolve(settings(), "/workspace", {}); + expect(resolved.spawn.command).toBe( + yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`), + ); + expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( + "#!/bin/sh\necho managed\n", + ); + }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + ); + }); + + it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => { + const exact = { + ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }), + id: "codex-acp", + name: "Codex", + authors: ["OpenAI", "Zed Industries"], + license: "Apache-2.0", + website: "https://example.test/codex", + repository: "https://example.test/codex/source", + icon: "https://example.test/codex.svg", + } satisfies AcpRegistryAgent; + const descriptionMatch = { + ...makeAgent({ npx: { package: "other-agent@1.2.3" } }), + id: "other-agent", + name: "Other Agent", + description: "An adapter for Codex workflows", + } satisfies AcpRegistryAgent; + const incompatible = { + ...makeAgent({ + binary: { + "darwin-aarch64": { archive: archiveUrl, cmd: "agent" }, + }, + }), + id: "darwin-only", + name: "Codex Darwin", + } satisfies AcpRegistryAgent; + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-search-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const result = yield* resolver.search({ query: "codex" }); + + expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]); + expect(result.agents[0]).toMatchObject({ + authors: ["OpenAI", "Zed Industries"], + distribution: "npx", + integrity: "registry", + license: "Apache-2.0", + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + version: "1.0.0", + agents: [descriptionMatch, incompatible, exact], + }), + ), + ), + ), + ), + ), + ); + }); + + it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => { + const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); + let requests = 0; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-refresh-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + yield* Effect.all( + [resolver.search({ query: "example" }), resolver.search({ query: "example" })], + { concurrency: "unbounded" }, + ); + expect(requests).toBe(1); + + yield* resolver.search({ query: "example" }); + expect(requests).toBe(2); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests += 1; + return Effect.yieldNow.pipe( + Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ); + }), + ), + ); + }); + + it.effect("filters runner recipes that the environment cannot prepare", () => { + const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); + const binaryAgent = { + ...makeAgent({ + binary: { + "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, + }, + }), + id: "binary-agent", + name: "Binary Agent", + } satisfies AcpRegistryAgent; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-runner-filter-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const result = yield* resolver.search({ query: "" }); + + expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer( + (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }), + ), + ), + ), + { PATH: "" }, + ), + ), + ); + }); + + it.effect("discards registry agents with blank names or unsafe versions", () => { + const distribution = { + binary: { + "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, + }, + } satisfies AcpRegistryAgent["distribution"]; + const valid = makeAgent(distribution); + const blankName = { ...valid, id: "blank-name", name: " " }; + const blankVersion = { ...valid, id: "blank-version", version: "\t" }; + const parentVersion = { ...valid, id: "parent-version", version: ".." }; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-blank-fields-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const result = yield* resolver.search({ query: "" }); + + expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + version: "1.0.0", + agents: [blankName, blankVersion, parentVersion, valid], + }), + ), + ), + ), + ), + ), + ); + }); + + it.effect("ignores unpinned runner recipes from the registry", () => { + const agent = makeAgent({ npx: { package: "@example/acp@latest" } }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-unpinned-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const result = yield* resolver.search({ query: "" }); + + expect(result.agents).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("rejects package syntax for the wrong runner", () => { + const invalidAgents = [ + { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" }, + ]; + const validAgents = [ + { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" }, + { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" }, + ]; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-runner-syntax-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip); + const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip); + const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip); + + expect(invalid.reason).toBe("agent_not_found"); + expect(validAt.reason).toBe("runner_unavailable"); + expect(validEquals.reason).toBe("runner_unavailable"); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer( + (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }), + ), + ), + ), + { PATH: "" }, + ), + ), + ); + }); + + it.effect("verifies declared SHA-256 before installing a binary", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "example-agent", + sha256: "0".repeat(64), + }, + }, + }); + const binaryBytes = new TextEncoder().encode("not the declared binary"); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-checksum-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip); + + expect(error.reason).toBe("checksum_mismatch"); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ), + ), + ); + }); + + it.effect("installs into T3 home package recipes during preparation", () => { + const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } }); + const requests: string[] = []; + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-runner-", + }); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); + const prepared = yield* resolver.prepare({ agentId: agent.id }); + expect(prepared).toEqual({ + agentId: "example-agent", + version: "1.2.3", + distribution: "npx", + prepared: true, + }); + expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true); + expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false); + expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( + "install --global @example/acp@1.2.3", + ); + expect(requests).toEqual([registryUrl]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ); + }, process.env), + ), + ); + }); + + it.effect("falls back to a valid cached registry index when refresh fails", () => { + const agent = makeAgent({ + npx: { + package: "@example/acp@1.2.3", + args: ["--stdio"], + }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-cache-", + }); + const registryDirectory = `${cacheDir}/acp-registry`; + yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); + yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); + + expect(resolved.spawn).toMatchObject({ + command: toolchain.executablePath, + args: ["--stdio"], + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), + ), + ), + ), + ); + }); + + it.effect("rejects unsafe command paths before downloading an archive", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "../outside", + }, + }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-invalid-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip); + + expect(error.reason).toBe("archive_invalid"); + expect(requests).toEqual([registryUrl]); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ); + }), + ), + ); + }); + + it.effect("inspects from disk without waiting for a registry refresh", () => { + const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); + let requests = 0; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-cold-inspect-", + }); + const registryDirectory = `${cacheDir}/acp-registry`; + yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); + yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); + + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const inspection = yield* resolver.inspect(settings()); + + expect(inspection).toMatchObject({ status: "ready", agentId: agent.id }); + expect(requests).toBe(0); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ); + }), + ), + ); + }); + + it.effect("fails a cold inspection immediately when no local registry is available", () => { + let requests = 0; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-empty-inspect-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const error = yield* resolver.inspect(settings()).pipe(Effect.flip); + + expect(error.reason).toBe("registry_unavailable"); + expect(requests).toBe(0); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests += 1; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))); + }), + ), + ); + }); + + it.effect("uses the effective provider environment for inspection and resolution", () => { + const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-effective-env-", + }); + const registryDirectory = `${cacheDir}/acp-registry`; + yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); + yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const hostInspection = yield* resolver.inspect(settings()); + const providerEnvironment = toolchain.environment; + const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment); + const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment); + + expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" }); + expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" }); + expect(resolved.spawn).toMatchObject({ + command: toolchain.executablePath, + env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer( + (request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + { PATH: "" }, + ), + ), + ); + }); + + it.effect("requires a regular executable file in the managed binary cache", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "bin/example-agent", + }, + }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-non-file-", + }); + const registryDirectory = `${cacheDir}/acp-registry`; + const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`; + yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true }); + yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); + yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); + + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip); + + expect(directoryError.reason).toBe("archive_invalid"); + + yield* fileSystem.remove(fakeExecutable, { recursive: true }); + yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n"); + yield* fileSystem.chmod(fakeExecutable, 0o644); + const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip); + + expect(modeError.reason).toBe("archive_invalid"); + + yield* fileSystem.chmod(fakeExecutable, 0o755); + expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), + ), + ), + ); + }); + + it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-", + }); + const agentRoot = `${cacheDir}/tools/example-agent`; + const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; + yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); + yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); + yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { + recursive: true, + }); + yield* fileSystem.writeFileString(runnerCache, "{}"); + + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + + expect(first).toEqual({ agentId: "example-agent", removed: true }); + expect(second).toEqual({ agentId: "example-agent", removed: false }); + expect(yield* fileSystem.exists(agentRoot)).toBe(false); + expect(yield* fileSystem.exists(runnerCache)).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), + ), + ), + ); + }); + + it.effect("keeps managed package installs when removing the same agent's binaries", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-uninstall-packages-", + }); + const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`; + yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true }); + yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary"); + yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command"); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ + agentId: "example-agent", + removed: true, + }); + expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false); + expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe( + "package command", + ); + expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ + agentId: "example-agent", + removed: false, + }); + }).pipe( + Effect.scoped, + Effect.provide(resolverLayer(() => Effect.die("unexpected HTTP request"))), + ), + ); + + it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + }, + }, + }); + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + return Effect.gen(function* () { + const downloadStarted = yield* Deferred.make(); + const releaseDownload = yield* Deferred.make(); + const referenceChecked = yield* Deferred.make(); + const isReferenced = yield* Ref.make(false); + return yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-race-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + + const prepareFiber = yield* resolver + .prepare({ agentId: agent.id }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(downloadStarted); + const uninstallFiber = yield* resolver + .uninstallManagedBinary( + { agentId: agent.id }, + Deferred.succeed(referenceChecked, undefined).pipe( + Effect.andThen(Ref.get(isReferenced)), + ), + ) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true); + yield* Ref.set(isReferenced, true); + yield* Deferred.succeed(releaseDownload, undefined); + + expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); + expect(yield* Fiber.join(uninstallFiber)).toEqual({ + agentId: agent.id, + removed: false, + }); + const agentRoot = `${cacheDir}/tools/${agent.id}`; + expect(yield* fileSystem.exists(agentRoot)).toBe(true); + + yield* Ref.set(isReferenced, false); + expect( + yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), + ).toEqual({ agentId: agent.id, removed: true }); + expect( + yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), + ).toEqual({ agentId: agent.id, removed: false }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + if (request.url === registryUrl) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ); + } + return Deferred.succeed(downloadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseDownload)), + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ); + }), + ), + ); + }); + }); + + it.effect("reserves a prepared binary until a configured instance inspects it", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + }, + }, + }); + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-reservation-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + + yield* resolver.prepare({ agentId: agent.id }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: false, + }); + expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: true, + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ), + ), + ); + }); + + it.effect("expires an abandoned prepared-binary reservation", () => { + const agent = makeAgent({ + binary: { + "linux-x86_64": { + archive: archiveUrl, + cmd: "./bin/example-agent", + }, + }, + }); + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-expiry-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + + yield* resolver.prepare({ agentId: agent.id }); + yield* TestClock.adjust("31 seconds"); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: true, + }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ), + ), + ); + }); +}); + +describe("acpRegistryManagedBinaryDirectories", () => { + it.effect("lists managed package and binary command directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-bins-", + }); + const install = (agent: string, version: string, target: string, commandDirectory = "") => + fileSystem.makeDirectory( + path.join(cacheDir, "tools", agent, version, target, commandDirectory), + { recursive: true }, + ); + yield* install("kimi", "1.49.0", "linux-x86_64", "bin"); + yield* install("kimi", "1.50.0", "linux-x86_64", "cmd"); + yield* install("kimi", "1.50.0", "darwin-aarch64"); + yield* install("other-agent", "0.2.0", "darwin-aarch64"); + const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin"); + const geminiCommand = path.join(globalBin, "gemini"); + const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs"); + yield* fileSystem.makeDirectory(globalBin, { recursive: true }); + yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true }); + yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n"); + yield* fileSystem.chmod(geminiCommand, 0o755); + yield* fileSystem.writeFileString( + path.join(receiptsDirectory, "gemini.json"), + encodeUnknownJson({ + agentId: "gemini", + agentVersion: "0.56.0", + distribution: "npx", + packageSpec: "@google/gemini-cli@0.56.0", + managerPath: "/usr/bin/npm", + binDirectory: globalBin, + executablePath: geminiCommand, + packageRoot: path.join( + cacheDir, + "tools", + "gemini", + "0.56.0", + "npm", + "lib", + "node_modules", + "@google", + "gemini-cli", + ), + packageVersion: "0.56.0", + }), + ); + yield* fileSystem.writeFileString( + path.join(cacheDir, "acp-registry", "registry.json"), + encodeUnknownJson({ + version: "1.0.0", + agents: [ + { + ...makeAgent({ + binary: { + "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" }, + }, + }), + id: "kimi", + name: "Kimi", + version: "1.49.0", + }, + { + ...makeAgent({ + binary: { + "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" }, + }, + }), + id: "kimi", + name: "Kimi", + version: "1.50.0", + }, + ], + }), + ); + + const directories = yield* acpRegistryManagedBinaryDirectories({ + fileSystem, + path, + cacheDir, + toolsDir: path.join(cacheDir, "tools"), + platform: "linux", + architecture: "x64", + }); + expect(directories).toEqual([ + globalBin, + path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"), + path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"), + ]); + + const missing = yield* acpRegistryManagedBinaryDirectories({ + fileSystem, + path, + cacheDir: path.join(cacheDir, "does-not-exist"), + toolsDir: path.join(cacheDir, "does-not-exist", "tools"), + platform: "linux", + architecture: "x64", + }); + expect(missing).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts new file mode 100644 index 000000000000..b4463116ef08 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -0,0 +1,1859 @@ +import { + AcpRegistryOperationError, + AcpRegistryOperationErrorReason, + TrimmedNonEmptyString, + type AcpRegistryManagedBinaryUninstallInput, + type AcpRegistryManagedBinaryUninstallResult, + type AcpRegistryPrepareInput, + type AcpRegistryPrepareResult, + type AcpRegistrySearchInput, + type AcpRegistrySearchResult, + type AcpRegistryDistribution as AcpRegistryDistributionKind, + type AcpRegistryDistributionPreference, + type AcpRegistrySettings, +} from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { + mergePathEntries, + resolveSpawnCommand, + SpawnExecutableResolution, +} from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeCrypto from "node:crypto"; + +import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; + +const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; + +const MAX_REGISTRY_BYTES = 1024 * 1024; +const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; +const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024; +const MAX_SEARCH_RESULTS = 20; +const REGISTRY_REQUEST_TIMEOUT = "30 seconds"; +const ARCHIVE_REQUEST_TIMEOUT = "20 minutes"; +const PACKAGE_INSTALL_TIMEOUT = "20 minutes"; +const PACKAGE_QUERY_TIMEOUT = "30 seconds"; + +const BoundedAgentId = Schema.String.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u), +); +const BoundedName = TrimmedNonEmptyString.check(Schema.isMaxLength(160)); +// Versions become install directory names, so "." and ".." must not decode. +const BoundedVersion = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u), +); +const BoundedDescription = Schema.String.check(Schema.isMaxLength(1_024)); +const BoundedMetadata = Schema.String.check(Schema.isMaxLength(256)); +const BoundedArgument = Schema.String.check(Schema.isMaxLength(1_024)); +const PackageCommandName = Schema.String.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/iu), +); +const isPackageCommandName = Schema.is(PackageCommandName); +const EXACT_RUNNER_VERSION = "v?[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?"; +const EXACT_NPX_PACKAGE = new RegExp( + `^(?:@[^/@\\s]+/[^@\\s]+|[a-z0-9][a-z0-9._-]*)@${EXACT_RUNNER_VERSION}$`, + "iu", +); +const EXACT_UVX_PACKAGE = new RegExp(`^[a-z0-9][a-z0-9._-]*(?:@|==)${EXACT_RUNNER_VERSION}$`, "iu"); +const NpxPackage = Schema.String.check( + Schema.isMaxLength(256), + Schema.makeFilter( + (value) => + EXACT_NPX_PACKAGE.test(value) || "ACP Registry npx packages must include an exact version.", + ), +); +const UvxPackage = Schema.String.check( + Schema.isMaxLength(256), + Schema.makeFilter( + (value) => + EXACT_UVX_PACKAGE.test(value) || "ACP Registry uvx packages must include an exact version.", + ), +); +const HttpsUrl = Schema.String.check( + Schema.isMaxLength(2_048), + Schema.makeFilter((value) => { + try { + const url = new URL(value); + return ( + (url.protocol === "https:" && url.username.length === 0 && url.password.length === 0) || + "ACP Registry URLs must use HTTPS without embedded credentials." + ); + } catch { + return "ACP Registry URLs must be valid absolute URLs."; + } + }), +); + +const AcpRegistryPackageDistributionFields = { + args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), + env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), +} as const; +const AcpRegistryNpxDistribution = Schema.Struct({ + package: NpxPackage, + ...AcpRegistryPackageDistributionFields, +}); +const AcpRegistryUvxDistribution = Schema.Struct({ + package: UvxPackage, + ...AcpRegistryPackageDistributionFields, +}); + +const AcpRegistryBinaryTarget = Schema.Struct({ + archive: HttpsUrl, + cmd: BoundedArgument, + sha256: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/iu))), + args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), + env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), +}); + +const AcpRegistryAgent = Schema.Struct({ + id: BoundedAgentId, + name: BoundedName, + version: BoundedVersion, + description: BoundedDescription, + authors: Schema.optionalKey(Schema.Array(BoundedMetadata).check(Schema.isMaxLength(16))), + license: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), + website: Schema.optionalKey(HttpsUrl), + repository: Schema.optionalKey(HttpsUrl), + icon: Schema.optionalKey(HttpsUrl), + distribution: Schema.Struct({ + binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)), + npx: Schema.optionalKey(AcpRegistryNpxDistribution), + uvx: Schema.optionalKey(AcpRegistryUvxDistribution), + }), +}); +export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; + +const NpmPackageManifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + bin: Schema.Union([BoundedArgument, Schema.Record(PackageCommandName, BoundedArgument)]), +}); + +const AcpRegistryPackageInstallReceipt = Schema.Struct({ + agentId: BoundedAgentId, + agentVersion: BoundedVersion, + distribution: Schema.Literals(["npx", "uvx"]), + packageSpec: Schema.String, + managerPath: Schema.String, + binDirectory: Schema.String, + executablePath: Schema.String, + packageRoot: Schema.optional(Schema.String), + packageVersion: Schema.optional(Schema.String), +}); +type AcpRegistryPackageInstallReceipt = typeof AcpRegistryPackageInstallReceipt.Type; + +const AcpRegistryIndexEnvelope = Schema.Struct({ + version: BoundedVersion, + agents: Schema.Array(Schema.Unknown).check(Schema.isMaxLength(512)), +}); +export interface AcpRegistryIndex { + readonly version: string; + readonly agents: ReadonlyArray; +} + +const decodeRegistryIndexEnvelope = Schema.decodeUnknownEffect(AcpRegistryIndexEnvelope); +const decodeRegistryAgent = Schema.decodeUnknownOption(AcpRegistryAgent); +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeNpmPackageManifest = Schema.decodeUnknownEffect( + Schema.fromJsonString(NpmPackageManifest), +); +const decodePackageInstallReceipt = Schema.decodeUnknownOption( + Schema.fromJsonString(AcpRegistryPackageInstallReceipt), +); +const encodePackageInstallReceipt = Schema.encodeSync( + Schema.fromJsonString(AcpRegistryPackageInstallReceipt), +); +const decodeHttpsUrl = Schema.decodeUnknownEffect(HttpsUrl); +const decodeBoundedAgentId = Schema.decodeUnknownEffect(BoundedAgentId); + +export const AcpRegistryErrorReason = AcpRegistryOperationErrorReason; +export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type; + +export class AcpRegistryError extends Schema.TaggedError()("AcpRegistryError", { + reason: AcpRegistryErrorReason, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.detail; + } +} + +export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistryOperationError { + return new AcpRegistryOperationError({ + reason: error.reason, + message: error.detail, + cause: error, + }); +} + +const isAcpRegistryError = Schema.is(AcpRegistryError); + +export type AcpRegistryPlatformTarget = + | "darwin-aarch64" + | "darwin-x86_64" + | "linux-aarch64" + | "linux-x86_64" + | "windows-aarch64" + | "windows-x86_64"; + +export function resolveAcpRegistryPlatformTarget( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, +): AcpRegistryPlatformTarget | undefined { + const os = + platform === "darwin" + ? "darwin" + : platform === "linux" + ? "linux" + : platform === "win32" + ? "windows" + : undefined; + const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined; + return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined; +} + +interface ExactPackageSpec { + readonly name: string; + readonly version: string; +} + +function parseNpxPackageSpec(packageSpec: string): ExactPackageSpec { + const separator = packageSpec.lastIndexOf("@"); + return { + name: packageSpec.slice(0, separator), + version: packageSpec.slice(separator + 1).replace(/^v/iu, ""), + }; +} + +function parseUvxPackageSpec(packageSpec: string): ExactPackageSpec { + const equalsSeparator = packageSpec.lastIndexOf("=="); + const separator = equalsSeparator >= 0 ? equalsSeparator : packageSpec.lastIndexOf("@"); + const separatorLength = equalsSeparator >= 0 ? 2 : 1; + return { + name: packageSpec.slice(0, separator), + version: packageSpec.slice(separator + separatorLength).replace(/^v/iu, ""), + }; +} + +function packageManagerFor(distribution: AcpRegistryDistributionKind): "npm" | "uv" | undefined { + return distribution === "npx" ? "npm" : distribution === "uvx" ? "uv" : undefined; +} + +function packageCommandCandidates( + agent: AcpRegistryAgent, + packageName: string, +): ReadonlyArray { + const unscopedPackageName = packageName.split("/").pop() ?? packageName; + return Array.from( + new Set( + [agent.id, unscopedPackageName, unscopedPackageName.replace(/-acp$/u, "")].filter( + isPackageCommandName, + ), + ), + ); +} + +function readEnvironmentPath( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string | undefined { + if (platform !== "win32") return environment.PATH; + return ( + environment.PATH ?? + Object.entries(environment).find(([key]) => key.toLowerCase() === "path")?.[1] + ); +} + +function withPreferredPath( + environment: NodeJS.ProcessEnv, + directory: string, + platform: NodeJS.Platform, +): NodeJS.ProcessEnv { + return { + ...environment, + PATH: mergePathEntries(directory, readEnvironmentPath(environment, platform), platform), + }; +} + +/** + * Directories exposing installed ACP Registry commands. The integrated + * terminal appends them to PATH so managed package commands and + * managed binary agents are both available by name. Best effort: unreadable + * directories resolve to no entries. + */ +export const acpRegistryManagedBinaryDirectories = (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly cacheDir: string; + readonly toolsDir: string; + readonly platform: NodeJS.Platform; + readonly architecture: NodeJS.Architecture; +}): Effect.Effect> => + Effect.gen(function* () { + const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture); + const registryDirectory = input.path.join(input.cacheDir, "acp-registry"); + const installsDirectory = input.toolsDir; + const packageReceiptsDirectory = input.path.join(registryDirectory, "package-installs"); + const listDirectories = (directory: string) => + input.fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): Array => [])); + const directories: Array = []; + + for (const receiptFile of (yield* listDirectories(packageReceiptsDirectory)).toSorted()) { + const receipt = yield* input.fileSystem + .readFileString(input.path.join(packageReceiptsDirectory, receiptFile)) + .pipe( + Effect.map(decodePackageInstallReceipt), + Effect.orElseSucceed(() => Option.none()), + ); + if ( + Option.isSome(receipt) && + receipt.value.binDirectory === + input.path.join( + installsDirectory, + receipt.value.agentId, + encodeURIComponent(receipt.value.agentVersion), + receipt.value.distribution === "npx" ? "npm" : "python", + ...(receipt.value.distribution === "npx" && input.platform === "win32" ? [] : ["bin"]), + ) && + input.path.dirname(receipt.value.executablePath) === receipt.value.binDirectory && + (yield* input.fileSystem + .exists(receipt.value.executablePath) + .pipe(Effect.orElseSucceed(() => false))) + ) { + directories.push(receipt.value.binDirectory); + } + } + + if (target === undefined) return Array.from(new Set(directories)); + const cachedAgents = yield* input.fileSystem + .readFileString(input.path.join(registryDirectory, "registry.json")) + .pipe( + Effect.flatMap(decodeJson), + Effect.flatMap(decodeRegistryIndexEnvelope), + Effect.map((envelope) => + envelope.agents.flatMap((candidate) => { + const decoded = decodeRegistryAgent(candidate); + return Option.isSome(decoded) ? [decoded.value] : []; + }), + ), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + const cachedAgentByInstall = new Map( + // Install directories are named by the URI-encoded version. + cachedAgents.map( + (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const, + ), + ); + for (const agent of (yield* listDirectories(installsDirectory)).toSorted()) { + const versions = (yield* listDirectories(input.path.join(installsDirectory, agent))).toSorted( + (left, right) => right.localeCompare(left, undefined, { numeric: true }), + ); + for (const version of versions) { + const installRoot = input.path.join(installsDirectory, agent, version, target); + if (yield* input.fileSystem.exists(installRoot).pipe(Effect.orElseSucceed(() => false))) { + const binaryTarget = cachedAgentByInstall.get(`${agent}\0${version}`)?.distribution + .binary?.[target]; + const commandSegments = + binaryTarget === undefined ? undefined : normalizeRegistryCommandPath(binaryTarget.cmd); + const directory = + commandSegments === undefined + ? installRoot + : input.path.join(installRoot, ...commandSegments.slice(0, -1)); + if (yield* input.fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) { + directories.push(directory); + } + } + } + } + return Array.from(new Set(directories)); + }); + +export interface ResolvedAcpRegistryDistribution { + readonly kind: AcpRegistryDistributionKind; + readonly args: ReadonlyArray; + readonly env: Readonly>; + readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type; + readonly packageName?: string; +} + +export function resolveAcpRegistryDistribution(input: { + readonly agent: AcpRegistryAgent; + readonly preference: AcpRegistryDistributionPreference; + readonly platformTarget: AcpRegistryPlatformTarget | undefined; +}): ResolvedAcpRegistryDistribution | undefined { + const { agent, platformTarget } = input; + const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined; + const candidates: ReadonlyArray = + input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference]; + + for (const kind of candidates) { + if (kind === "binary" && binary) { + return { + kind, + args: binary.args ?? [], + env: binary.env ?? {}, + binaryTarget: binary, + }; + } + if (kind === "npx" && agent.distribution.npx) { + return { + kind, + args: agent.distribution.npx.args ?? [], + env: agent.distribution.npx.env ?? {}, + packageName: agent.distribution.npx.package, + }; + } + if (kind === "uvx" && agent.distribution.uvx) { + return { + kind, + args: agent.distribution.uvx.args ?? [], + env: agent.distribution.uvx.env ?? {}, + packageName: agent.distribution.uvx.package, + }; + } + } + return undefined; +} + +export interface ResolvedAcpRegistryAgent { + readonly agent: AcpRegistryAgent; + readonly distribution: AcpRegistryDistributionKind; + readonly spawn: AcpSpawnInput; +} + +export type AcpRegistryInspection = + | { readonly status: "unconfigured" } + | { readonly status: "not_found"; readonly agentId: string } + | { + readonly status: "unsupported"; + readonly agentId: string; + readonly version: string; + } + | { + readonly status: "missing_runner"; + readonly agentId: string; + readonly version: string; + readonly distribution: AcpRegistryDistributionKind; + readonly runner: string; + } + | { + readonly status: "unprepared"; + readonly agentId: string; + readonly version: string; + readonly distribution: "binary"; + } + | { + readonly status: "ready"; + readonly agentId: string; + readonly version: string | null; + readonly distribution: AcpRegistryDistributionKind; + readonly documentationUrl?: string; + }; + +export class AcpRegistryCatalog extends Context.Service< + AcpRegistryCatalog, + { + readonly search: ( + input: AcpRegistrySearchInput, + ) => Effect.Effect; + readonly prepare: ( + input: AcpRegistryPrepareInput, + ) => Effect.Effect; + readonly inspect: ( + settings: AcpRegistrySettings, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly resolve: ( + settings: AcpRegistrySettings, + cwd: string, + environment?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly uninstallManagedBinary: ( + input: AcpRegistryManagedBinaryUninstallInput, + isReferenced?: Effect.Effect, + ) => Effect.Effect; + } +>()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { + static layer(options: AcpRegistryCatalogOptions) { + return Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); + } +} + +export interface AcpRegistryCatalogOptions { + readonly cacheDir: string; + readonly toolsDir: string; + readonly registryUrl?: string; +} + +const INSTALL_LOCK_RETRY_COUNT = 300; +const INSTALL_LOCK_RETRY_DELAY = "100 millis"; +const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000; + +function isAlreadyExists(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "AlreadyExists"; +} + +function normalizeRegistryCommandPath(command: string): ReadonlyArray | undefined { + const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + if ( + segments.length === 0 || + normalized.startsWith("/") || + /^[a-zA-Z]:/u.test(normalized) || + segments.some((segment) => segment === "." || segment === "..") + ) { + return undefined; + } + return segments; +} + +function validateArchiveEntries(output: string): boolean { + return output + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .every((entry) => entry === "./" || normalizeRegistryCommandPath(entry) !== undefined); +} + +type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip"; + +function archiveKind(url: string): ArchiveKind { + const pathname = new URL(url).pathname.toLowerCase(); + if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz"; + if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2"; + if (pathname.endsWith(".zip")) return "zip"; + return "raw"; +} + +function archiveFileName(kind: ArchiveKind): string { + switch (kind) { + case "tar_gz": + return "agent.tar.gz"; + case "tar_bz2": + return "agent.tar.bz2"; + case "zip": + return "agent.zip"; + case "raw": + return "agent.bin"; + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function searchRank(agent: AcpRegistryAgent, query: string): number | undefined { + const normalized = query.trim().toLowerCase(); + if (normalized.length === 0) return 100; + + const id = agent.id.toLowerCase(); + const name = agent.name.toLowerCase(); + const authors = (agent.authors ?? []).join(" ").toLowerCase(); + const description = agent.description.toLowerCase(); + const terms = normalized.split(/\s+/u); + if (id === normalized || name === normalized) return 0; + if (id.startsWith(normalized) || name.startsWith(normalized)) return 10; + + const identityTokens = `${id} ${name}`.split(/[^a-z0-9]+/u).filter(Boolean); + if (terms.every((term) => identityTokens.some((token) => token.startsWith(term)))) return 20; + if (terms.every((term) => id.includes(term) || name.includes(term))) return 30; + if (terms.every((term) => authors.includes(term))) return 40; + if (terms.every((term) => `${id} ${name} ${authors} ${description}`.includes(term))) return 50; + return undefined; +} + +export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(function* ( + input: AcpRegistryCatalogOptions, +): Effect.fn.Return< + AcpRegistryCatalog["Service"], + never, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const hostEnvironment = yield* HostProcessEnvironment; + const resolveExecutable = yield* SpawnExecutableResolution; + const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture); + const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL; + const registryDirectory = path.join(input.cacheDir, "acp-registry"); + const registryCachePath = path.join(registryDirectory, "registry.json"); + const installsDirectory = input.toolsDir; + const agentInstallRoot = (agent: AcpRegistryAgent) => + path.join(installsDirectory, agent.id, encodeURIComponent(agent.version)); + const packageInstallRoot = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => + path.join(agentInstallRoot(agent), distribution === "npx" ? "npm" : "python"); + const packageBinDirectory = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => + distribution === "npx" && platform === "win32" + ? packageInstallRoot(agent, distribution) + : path.join(packageInstallRoot(agent, distribution), "bin"); + const packageReceiptsDirectory = path.join(registryDirectory, "package-installs"); + const registryRef = yield* Ref.make(undefined); + const registryRevision = yield* Ref.make(0); + const registrySemaphore = yield* Semaphore.make(1); + const installSemaphore = yield* Semaphore.make(1); + const preparedBinaryReservations = yield* Ref.make>(new Map()); + + const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* ( + runner: string, + ) { + const linkedTarget = yield* fileSystem.readLink(runner).pipe(Effect.option); + if (Option.isNone(linkedTarget)) return path.dirname(runner); + const target = path.isAbsolute(linkedTarget.value) + ? linkedTarget.value + : path.resolve(path.dirname(runner), linkedTarget.value); + // Some service installs expose npm/uv through a wrapper symlink into the + // actual toolchain bin directory. Do not mistake npm's internal + // `npm-cli.js` directory for the global command directory. + return path.basename(target) === path.basename(runner) + ? path.dirname(target) + : path.dirname(runner); + }); + + const reservePreparedBinary = Effect.fn("AcpRegistryCatalog.reservePreparedBinary")(function* ( + agentId: string, + ) { + const now = yield* Clock.currentTimeMillis; + yield* Ref.update(preparedBinaryReservations, (current) => { + const next = new Map(Array.from(current).filter(([, expiresAt]) => expiresAt > now)); + next.set(agentId, now + PREPARED_BINARY_RESERVATION_MS); + return next; + }); + }); + + const consumePreparedBinaryReservation = (agentId: string) => + Ref.update(preparedBinaryReservations, (current) => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }); + + const hasPreparedBinaryReservation = Effect.fn("AcpRegistryCatalog.hasPreparedBinaryReservation")( + function* (agentId: string) { + const now = yield* Clock.currentTimeMillis; + return yield* Ref.modify(preparedBinaryReservations, (current) => { + const expiresAt = current.get(agentId); + if (expiresAt === undefined || expiresAt <= now) { + if (expiresAt === undefined) return [false, current] as const; + const next = new Map(current); + next.delete(agentId); + return [false, next] as const; + } + return [true, current] as const; + }); + }, + ); + + const assertHttpsUrl = Effect.fn("AcpRegistryCatalog.assertHttpsUrl")(function* ( + value: string, + detail: string, + ) { + const valid = yield* decodeHttpsUrl(value).pipe(Effect.option); + if (Option.isNone(valid)) { + return yield* new AcpRegistryError({ reason: "registry_unavailable", detail }); + } + return value; + }); + + const decodeRegistryText = Effect.fn("AcpRegistryCatalog.decodeRegistryText")(function* ( + text: string, + ) { + const decoded = yield* decodeJson(text).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "registry_unavailable", + detail: "ACP Registry returned invalid JSON.", + cause, + }), + ), + ); + const envelope = yield* decodeRegistryIndexEnvelope(decoded).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "registry_unavailable", + detail: "ACP Registry returned an invalid index.", + cause, + }), + ), + ); + const agents = envelope.agents.flatMap((candidate) => { + const decodedAgent = decodeRegistryAgent(candidate); + return Option.isSome(decodedAgent) ? [decodedAgent.value] : []; + }); + const discarded = envelope.agents.length - agents.length; + if (discarded > 0) { + yield* Effect.logWarning("ignored invalid ACP Registry entries", { discarded }); + } + return { version: envelope.version, agents } satisfies AcpRegistryIndex; + }); + + const readCachedRegistry = fileSystem + .readFileString(registryCachePath) + .pipe(Effect.flatMap(decodeRegistryText), Effect.option); + + const writeRegistryCache = (text: string) => { + const temporaryPath = `${registryCachePath}.${process.pid}-${NodeCrypto.randomUUID()}.tmp`; + return fileSystem + .makeDirectory(registryDirectory, { recursive: true }) + .pipe( + Effect.andThen(fileSystem.writeFileString(temporaryPath, text)), + Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)), + Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)), + Effect.ignore, + ); + }; + + const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () { + yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS."); + const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "registry_unavailable", + detail: `Could not fetch ACP Registry index from ${registryUrl}.`, + cause, + }), + ), + Effect.timeoutOrElse({ + duration: REGISTRY_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new AcpRegistryError({ + reason: "registry_unavailable", + detail: "Timed out fetching ACP Registry index.", + }), + ), + }), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: MAX_REGISTRY_BYTES + 1, + }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "registry_unavailable", + detail: "Could not read ACP Registry response body.", + cause, + }), + ), + Effect.timeoutOrElse({ + duration: REGISTRY_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new AcpRegistryError({ + reason: "registry_unavailable", + detail: "Timed out reading ACP Registry response body.", + }), + ), + }), + ); + if (collected.truncated || collected.bytes > MAX_REGISTRY_BYTES) { + return yield* new AcpRegistryError({ + reason: "registry_unavailable", + detail: `ACP Registry index exceeds ${MAX_REGISTRY_BYTES} bytes.`, + }); + } + if (collected.invalidUtf8) { + return yield* new AcpRegistryError({ + reason: "registry_unavailable", + detail: "ACP Registry index is not valid UTF-8.", + }); + } + const registry = yield* decodeRegistryText(collected.text); + yield* writeRegistryCache(collected.text); + return registry; + }); + + const refreshRegistry = Effect.fn("AcpRegistryCatalog.refreshRegistry")(function* () { + const observedRevision = yield* Ref.get(registryRevision); + return yield* registrySemaphore.withPermits(1)( + Effect.gen(function* () { + const currentRevision = yield* Ref.get(registryRevision); + const current = yield* Ref.get(registryRef); + if (current !== undefined && currentRevision !== observedRevision) return current; + + const registry = yield* fetchRegistry().pipe( + Effect.catch((networkError) => + readCachedRegistry.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(networkError), + onSome: Effect.succeed, + }), + ), + ), + ), + ); + yield* Ref.set(registryRef, registry); + yield* Ref.update(registryRevision, (revision) => revision + 1); + return registry; + }), + ); + }); + + const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () { + const current = yield* Ref.get(registryRef); + if (current !== undefined) return current; + + const cached = yield* readCachedRegistry; + if (Option.isNone(cached)) { + return yield* new AcpRegistryError({ + reason: "registry_unavailable", + detail: "No valid cached ACP Registry index is available.", + }); + } + yield* Ref.set(registryRef, cached.value); + return cached.value; + }); + + const loadRegistry = Effect.fn("AcpRegistryCatalog.loadRegistry")(function* () { + return yield* loadCachedRegistry().pipe(Effect.catch(() => refreshRegistry())); + }); + + const runCommand = Effect.fn("AcpRegistryCatalog.runCommand")(function* ( + command: string, + args: ReadonlyArray, + options: { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; + readonly timeout?: Duration.Input; + readonly truncatedOutputReason?: AcpRegistryErrorReason; + } = {}, + ) { + const collect = Effect.gen(function* () { + const resolved = yield* resolveSpawnCommand( + command, + args, + options.env === undefined ? {} : { env: options.env }, + ); + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { + ...(options.cwd ? { cwd: options.cwd } : {}), + ...(options.env === undefined ? {} : { env: options.env }), + shell: resolved.shell, + }), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }), + collectUint8StreamText({ stream: child.stderr, maxBytes: 1024 * 1024 }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (Number(exitCode) !== 0) { + return yield* new AcpRegistryError({ + reason: "install_failed", + detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`, + }); + } + // Archive listings feed validateArchiveEntries; a truncated listing would let + // unvalidated entries past the traversal check, so oversized output is fatal. + if (stdout.truncated) { + return yield* new AcpRegistryError({ + reason: options.truncatedOutputReason ?? "archive_invalid", + detail: `ACP Registry install command '${command}' produced more output than expected.`, + }); + } + return stdout.text; + }); + const scoped = collect.pipe( + Effect.scoped, + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: `Could not run ACP Registry install command '${command}'.`, + cause, + }), + ), + ); + return yield* options.timeout === undefined + ? scoped + : scoped.pipe( + Effect.timeoutOrElse({ + duration: options.timeout, + orElse: () => + Effect.fail( + new AcpRegistryError({ + reason: "install_failed", + detail: `Timed out running ACP Registry install command '${command}'.`, + }), + ), + }), + ); + }); + + const packageReceiptPath = ( + agentId: string, + distribution: "npx" | "uvx", + managerPath: string, + ) => { + const managerId = NodeCrypto.createHash("sha256") + .update(`${distribution}\0${managerPath}`) + .digest("hex") + .slice(0, 16); + return path.join(packageReceiptsDirectory, `${agentId}-${managerId}.json`); + }; + + const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* ( + agent: AcpRegistryAgent, + distribution: "npx" | "uvx", + packageSpec: string, + managerPath: string, + ) { + const receipt = yield* fileSystem + .readFileString(packageReceiptPath(agent.id, distribution, managerPath)) + .pipe( + Effect.map(decodePackageInstallReceipt), + Effect.orElseSucceed(() => Option.none()), + ); + if ( + Option.isNone(receipt) || + receipt.value.agentId !== agent.id || + receipt.value.agentVersion !== agent.version || + receipt.value.distribution !== distribution || + receipt.value.packageSpec !== packageSpec || + receipt.value.managerPath !== managerPath || + receipt.value.binDirectory !== packageBinDirectory(agent, distribution) || + path.dirname(receipt.value.executablePath) !== receipt.value.binDirectory + ) { + return Option.none(); + } + const executableExists = yield* fileSystem + .exists(receipt.value.executablePath) + .pipe(Effect.orElseSucceed(() => false)); + if (!executableExists) return Option.none(); + + if (distribution === "npx") { + const packageIdentity = parseNpxPackageSpec(packageSpec); + if (receipt.value.packageRoot === undefined || receipt.value.packageVersion === undefined) { + return Option.none(); + } + const manifest = yield* fileSystem + .readFileString(path.join(receipt.value.packageRoot, "package.json")) + .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); + if ( + Option.isNone(manifest) || + manifest.value.name !== packageIdentity.name || + manifest.value.version !== packageIdentity.version || + receipt.value.packageVersion !== packageIdentity.version + ) { + return Option.none(); + } + } + return receipt; + }); + + const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")( + function* (receipt: AcpRegistryPackageInstallReceipt) { + const receiptPath = packageReceiptPath( + receipt.agentId, + receipt.distribution, + receipt.managerPath, + ); + const temporaryPath = `${receiptPath}.${process.pid}.tmp`; + yield* fileSystem.makeDirectory(packageReceiptsDirectory, { recursive: true }); + yield* fileSystem.remove(temporaryPath, { force: true }); + yield* fileSystem.writeFileString(temporaryPath, `${encodePackageInstallReceipt(receipt)}\n`); + yield* fileSystem.remove(receiptPath, { force: true }); + yield* fileSystem.rename(temporaryPath, receiptPath); + }, + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: "Could not record the globally installed ACP Registry command.", + cause, + }), + ), + ); + + const parsePackageManagerPath = Effect.fn("AcpRegistryCatalog.parsePackageManagerPath")( + function* (managerPath: string, output: string) { + const lines = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.length !== 1 || !path.isAbsolute(lines[0]!)) { + return yield* new AcpRegistryError({ + reason: "install_failed", + detail: `ACP Registry package manager '${managerPath}' returned an invalid global path.`, + }); + } + return lines[0]!; + }, + ); + + const npmCommandName = ( + agent: AcpRegistryAgent, + packageName: string, + manifest: typeof NpmPackageManifest.Type, + ): string | undefined => { + const packageBaseName = packageName.split("/").pop() ?? packageName; + if (typeof manifest.bin === "string") return packageBaseName; + const entries = Object.entries(manifest.bin).toSorted(([left], [right]) => + compareText(left, right), + ); + if (entries.length === 1) return entries[0]![0]; + if (manifest.bin[packageBaseName] !== undefined) return packageBaseName; + if (manifest.bin[agent.id] !== undefined) return agent.id; + return new Set(entries.map(([, commandPath]) => commandPath)).size === 1 + ? entries[0]?.[0] + : undefined; + }; + + const discoverNpmGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverNpmGlobalPackage")( + function* ( + agent: AcpRegistryAgent, + packageSpec: string, + managerPath: string, + environment: NodeJS.ProcessEnv, + ) { + const packageIdentity = parseNpxPackageSpec(packageSpec); + const commandOptions = { + env: environment, + timeout: PACKAGE_QUERY_TIMEOUT, + truncatedOutputReason: "install_failed" as const, + } as const; + const globalRoot = yield* runCommand(managerPath, ["root", "--global"], commandOptions).pipe( + Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)), + ); + const globalPrefix = yield* runCommand( + managerPath, + ["prefix", "--global"], + commandOptions, + ).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); + const packageRoot = path.join(globalRoot, ...packageIdentity.name.split("/")); + const manifestPath = path.join(packageRoot, "package.json"); + const manifestInfo = yield* fileSystem.stat(manifestPath).pipe(Effect.option); + if (Option.isNone(manifestInfo) || manifestInfo.value.size > MAX_PACKAGE_MANIFEST_BYTES) { + return Option.none(); + } + const manifest = yield* fileSystem + .readFileString(manifestPath) + .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); + if ( + Option.isNone(manifest) || + manifest.value.name !== packageIdentity.name || + manifest.value.version !== packageIdentity.version + ) { + return Option.none(); + } + const commandName = npmCommandName(agent, packageIdentity.name, manifest.value); + if (commandName === undefined) return Option.none(); + const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin"); + const executablePath = resolveExecutable( + commandName, + platform, + withPreferredPath(environment, binDirectory, platform), + ); + return executablePath === undefined + ? Option.none() + : Option.some({ + agentId: agent.id, + agentVersion: agent.version, + distribution: "npx" as const, + packageSpec, + managerPath, + binDirectory, + executablePath, + packageRoot, + packageVersion: manifest.value.version, + }); + }, + ); + + const discoverUvGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverUvGlobalPackage")( + function* ( + agent: AcpRegistryAgent, + packageSpec: string, + managerPath: string, + environment: NodeJS.ProcessEnv, + ) { + const packageIdentity = parseUvxPackageSpec(packageSpec); + const installedTools = yield* runCommand(managerPath, ["tool", "list"], { + env: environment, + timeout: PACKAGE_QUERY_TIMEOUT, + truncatedOutputReason: "install_failed", + }); + const normalizedPackageName = packageIdentity.name.toLowerCase().replace(/[._-]+/gu, "-"); + const exactVersionInstalled = installedTools.split(/\r?\n/u).some((line) => { + const match = /^(\S+) v(\S+)(?:\s|$)/u.exec(line); + return ( + match?.[1]?.toLowerCase().replace(/[._-]+/gu, "-") === normalizedPackageName && + match[2] === packageIdentity.version + ); + }); + if (!exactVersionInstalled) return Option.none(); + const binDirectory = yield* runCommand(managerPath, ["tool", "dir", "--bin"], { + env: environment, + timeout: PACKAGE_QUERY_TIMEOUT, + truncatedOutputReason: "install_failed", + }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); + const commandEnvironment = withPreferredPath(environment, binDirectory, platform); + const executablePath = packageCommandCandidates(agent, packageIdentity.name) + .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment)) + .find((candidate): candidate is string => candidate !== undefined); + return executablePath === undefined + ? Option.none() + : Option.some({ + agentId: agent.id, + agentVersion: agent.version, + distribution: "uvx" as const, + packageSpec, + managerPath, + binDirectory, + executablePath, + packageVersion: packageIdentity.version, + }); + }, + ); + + const ensureGlobalPackage = Effect.fn("AcpRegistryCatalog.ensureGlobalPackage")(function* ( + agent: AcpRegistryAgent, + distribution: "npx" | "uvx", + packageSpec: string, + environment: NodeJS.ProcessEnv, + ) { + const managerName = packageManagerFor(distribution)!; + const managerPath = resolveExecutable(managerName, platform, environment); + if (managerPath === undefined) { + return yield* new AcpRegistryError({ + reason: "runner_unavailable", + detail: `ACP Registry agent ${agent.id} requires '${managerName}', but it is not available on this environment's PATH.`, + }); + } + const receipt = yield* readPackageReceipt(agent, distribution, packageSpec, managerPath); + if (Option.isSome(receipt) && distribution === "npx") return receipt.value; + + const packageRoot = packageInstallRoot(agent, distribution); + yield* fileSystem.makeDirectory(packageRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not create the managed install directory for ${agent.id}.`, + cause, + }), + ), + ); + const packageEnvironment = + distribution === "npx" + ? { ...environment, npm_config_prefix: packageRoot } + : { + ...environment, + UV_TOOL_DIR: packageRoot, + UV_TOOL_BIN_DIR: packageBinDirectory(agent, distribution), + }; + if (Option.isSome(receipt)) { + const installed = yield* discoverUvGlobalPackage( + agent, + packageSpec, + managerPath, + packageEnvironment, + ); + if (Option.isSome(installed)) return installed.value; + } + + const discover = + distribution === "npx" + ? () => discoverNpmGlobalPackage(agent, packageSpec, managerPath, packageEnvironment) + : () => discoverUvGlobalPackage(agent, packageSpec, managerPath, packageEnvironment); + const existing = yield* discover(); + if (Option.isSome(existing)) { + yield* writePackageReceipt(existing.value); + return existing.value; + } + + const managerDirectory = yield* managerBinDirectory(managerPath); + const installEnvironment = withPreferredPath(packageEnvironment, managerDirectory, platform); + if (distribution === "npx") { + yield* runCommand(managerPath, ["install", "--global", packageSpec], { + env: installEnvironment, + timeout: PACKAGE_INSTALL_TIMEOUT, + truncatedOutputReason: "install_failed", + }); + } else { + yield* runCommand(managerPath, ["tool", "install", "--force", packageSpec], { + env: installEnvironment, + timeout: PACKAGE_INSTALL_TIMEOUT, + truncatedOutputReason: "install_failed", + }); + } + + if (distribution === "npx") { + const installed = yield* discoverNpmGlobalPackage( + agent, + packageSpec, + managerPath, + packageEnvironment, + ); + if (Option.isNone(installed)) { + return yield* new AcpRegistryError({ + reason: "install_failed", + detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, + }); + } + yield* writePackageReceipt(installed.value); + return installed.value; + } + + const installed = yield* discoverUvGlobalPackage( + agent, + packageSpec, + managerPath, + packageEnvironment, + ); + if (Option.isNone(installed)) { + return yield* new AcpRegistryError({ + reason: "install_failed", + detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, + }); + } + yield* writePackageReceipt(installed.value); + return installed.value; + }); + + const acquireInstallLock = Effect.fn("AcpRegistryCatalog.acquireInstallLock")(function* ( + lockPath: string, + ) { + for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { + const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( + Effect.as(true), + Effect.catch((error) => + isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), + ), + ); + if (acquired) return; + const now = yield* Clock.currentTimeMillis; + const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); + const mtime = Option.flatMap(lockInfo, (info) => info.mtime); + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { + yield* fileSystem.remove(lockPath, { force: true }); + continue; + } + yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); + } + return yield* new AcpRegistryError({ + reason: "install_failed", + detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`, + }); + }); + + const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* ( + root: string, + executablePath: string, + ) { + const rootRealPath = yield* fileSystem.realPath(root); + const executableRealPath = yield* fileSystem.realPath(executablePath); + const relative = path.relative(rootRealPath, executableRealPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return yield* new AcpRegistryError({ + reason: "archive_invalid", + detail: "ACP Registry archive command resolves outside its installation directory.", + }); + } + const info = yield* fileSystem.stat(executableRealPath); + const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0; + if (info.type !== "File" || !hasExecutableMode) { + return yield* new AcpRegistryError({ + reason: "archive_invalid", + detail: "ACP Registry archive command is not a regular executable file.", + }); + } + return executableRealPath; + }); + + const binaryPaths = (agent: AcpRegistryAgent, target: typeof AcpRegistryBinaryTarget.Type) => { + const commandSegments = normalizeRegistryCommandPath(target.cmd); + if (platformTarget === undefined || commandSegments === undefined) return undefined; + const installRoot = path.join(agentInstallRoot(agent), platformTarget); + return { + commandSegments, + installRoot, + executablePath: path.join(installRoot, ...commandSegments), + }; + }; + + const installBinary = Effect.fn("AcpRegistryCatalog.installBinary")(function* ( + agent: AcpRegistryAgent, + target: typeof AcpRegistryBinaryTarget.Type, + ) { + if (platformTarget === undefined) { + return yield* new AcpRegistryError({ + reason: "unsupported_platform", + detail: `ACP Registry does not support platform ${platform}-${architecture}.`, + }); + } + const paths = binaryPaths(agent, target); + if (paths === undefined) { + return yield* new AcpRegistryError({ + reason: "archive_invalid", + detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`, + }); + } + const { commandSegments, installRoot, executablePath } = paths; + if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { + return yield* assertExecutableInRoot(installRoot, executablePath).pipe( + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: `Could not validate cached ACP Registry agent ${agent.id}.`, + cause, + }), + ), + ); + } + + yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not create ACP Registry cache for ${agent.id}.`, + cause, + }), + ), + ); + const lockPath = `${installRoot}.lock`; + yield* acquireInstallLock(lockPath).pipe( + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`, + cause, + }), + ), + ); + + return yield* Effect.gen(function* () { + if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { + return yield* assertExecutableInRoot(installRoot, executablePath); + } + const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({ + directory: path.dirname(installRoot), + prefix: `.${agent.id}-install-`, + }); + const extractionRoot = path.join(temporaryRoot, "extracted"); + yield* fileSystem.makeDirectory(extractionRoot, { recursive: true }); + const kind = archiveKind(target.archive); + const archivePath = path.join(temporaryRoot, archiveFileName(kind)); + const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "download_failed", + detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`, + cause, + }), + ), + Effect.timeoutOrElse({ + duration: ARCHIVE_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new AcpRegistryError({ + reason: "download_failed", + detail: `Timed out downloading ACP Registry agent ${agent.id} ${agent.version}.`, + }), + ), + }), + ); + const hash = NodeCrypto.createHash("sha256"); + let downloadedBytes = 0; + yield* response.stream.pipe( + Stream.tap((chunk) => { + if (downloadedBytes + chunk.byteLength > MAX_ARCHIVE_BYTES) { + return Effect.fail( + new AcpRegistryError({ + reason: "archive_invalid", + detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`, + }), + ); + } + downloadedBytes += chunk.byteLength; + hash.update(chunk); + return Effect.void; + }), + Stream.run(fileSystem.sink(archivePath)), + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "download_failed", + detail: `Could not save ACP Registry agent ${agent.id} ${agent.version}.`, + cause, + }), + ), + Effect.timeoutOrElse({ + duration: ARCHIVE_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new AcpRegistryError({ + reason: "download_failed", + detail: `Timed out reading ACP Registry agent ${agent.id} download.`, + }), + ), + }), + ); + if (target.sha256 !== undefined) { + const actual = hash.digest("hex"); + if (actual !== target.sha256.toLowerCase()) { + return yield* new AcpRegistryError({ + reason: "checksum_mismatch", + detail: `ACP Registry agent ${agent.id} download did not match its declared SHA-256.`, + }); + } + } + + if (kind === "raw") { + const targetPath = path.join(extractionRoot, ...commandSegments); + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.rename(archivePath, targetPath); + } else { + const listArgs = + kind === "tar_gz" + ? ["-tzf", archivePath] + : kind === "tar_bz2" + ? ["-tjf", archivePath] + : platform === "win32" + ? ["-tf", archivePath] + : undefined; + const entries = + listArgs === undefined + ? yield* runCommand("unzip", ["-Z1", archivePath]) + : yield* runCommand("tar", listArgs); + if (!validateArchiveEntries(entries)) { + return yield* new AcpRegistryError({ + reason: "archive_invalid", + detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`, + }); + } + if (kind === "zip" && platform !== "win32") { + yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]); + } else { + const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf"; + yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]); + } + } + + const stagedExecutable = path.join(extractionRoot, ...commandSegments); + if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new AcpRegistryError({ + reason: "archive_invalid", + detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`, + }); + } + if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755); + yield* assertExecutableInRoot(extractionRoot, stagedExecutable); + yield* fileSystem.remove(installRoot, { recursive: true, force: true }); + yield* fileSystem.rename(extractionRoot, installRoot); + return yield* assertExecutableInRoot(installRoot, executablePath); + }).pipe( + Effect.scoped, + Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: `Could not install ACP Registry agent ${agent.id}.`, + cause, + }), + ), + ); + }); + + const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* ( + registry: AcpRegistryIndex, + agentId: string, + ) { + const agent = registry.agents.find((candidate) => candidate.id === agentId.trim()); + if (agent === undefined) { + return yield* new AcpRegistryError({ + reason: "agent_not_found", + detail: `ACP Registry does not contain agent '${agentId.trim()}'.`, + }); + } + return agent; + }); + + const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* ( + agent: AcpRegistryAgent, + preference: AcpRegistryDistributionPreference, + ) { + const distribution = resolveAcpRegistryDistribution({ + agent, + preference, + platformTarget, + }); + if (distribution === undefined) { + return yield* new AcpRegistryError({ + reason: platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution", + detail: `ACP Registry agent ${agent.id} has no ${preference === "auto" ? "compatible" : preference} distribution for ${platform}-${architecture}.`, + }); + } + return distribution; + }); + + const search: AcpRegistryCatalog["Service"]["search"] = (input) => + Effect.gen(function* () { + const registry = yield* refreshRegistry(); + const ranked = registry.agents.flatMap((agent) => { + const distribution = resolveAcpRegistryDistribution({ + agent, + preference: "auto", + platformTarget, + }); + const rank = searchRank(agent, input.query); + const packageManager = + distribution === undefined ? undefined : packageManagerFor(distribution.kind); + const packageManagerAvailable = + packageManager === undefined || + resolveExecutable(packageManager, platform, hostEnvironment) !== undefined; + return distribution === undefined || rank === undefined || !packageManagerAvailable + ? [] + : [{ agent, distribution, rank }]; + }); + ranked.sort( + (left, right) => + left.rank - right.rank || + compareText(left.agent.name.toLowerCase(), right.agent.name.toLowerCase()) || + compareText(left.agent.id, right.agent.id), + ); + return { + agents: ranked.slice(0, MAX_SEARCH_RESULTS).map(({ agent, distribution }) => ({ + id: agent.id, + name: agent.name, + version: agent.version, + description: agent.description, + authors: agent.authors ?? [], + license: agent.license ?? null, + website: agent.website ?? null, + repository: agent.repository ?? null, + icon: agent.icon ?? null, + distribution: distribution.kind, + integrity: + distribution.kind === "binary" && distribution.binaryTarget?.sha256 !== undefined + ? "sha256" + : "registry", + })), + } satisfies AcpRegistrySearchResult; + }); + + const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) => + Effect.gen(function* () { + const registry = yield* refreshRegistry(); + const agent = yield* findAgent(registry, input.agentId); + const distribution = yield* compatibleDistribution(agent, "auto"); + if (distribution.kind === "binary") { + // A PATH executable can be a different version with different ACP + // capabilities. Only an explicit command override opts into that copy. + yield* installSemaphore.withPermits(1)( + installBinary(agent, distribution.binaryTarget!).pipe( + Effect.tap(() => reservePreparedBinary(agent.id)), + ), + ); + } else { + yield* installSemaphore.withPermits(1)( + ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment), + ); + } + return { + agentId: agent.id, + version: agent.version, + distribution: distribution.kind, + prepared: true, + } satisfies AcpRegistryPrepareResult; + }); + + const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => + Effect.gen(function* () { + const agentId = settings.agentId.trim(); + if (agentId.length === 0) return { status: "unconfigured" } as const; + const registry = yield* loadCachedRegistry(); + const agent = registry.agents.find((candidate) => candidate.id === agentId); + if (agent === undefined) return { status: "not_found", agentId } as const; + const documentationUrl = agent.website ?? agent.repository; + const distribution = resolveAcpRegistryDistribution({ + agent, + preference: settings.distribution, + platformTarget, + }); + if (distribution === undefined) { + return { status: "unsupported", agentId, version: agent.version } as const; + } + + const commandOverride = settings.commandPath.trim(); + if (commandOverride.length > 0) { + const available = + resolveExecutable(commandOverride, platform, environment ?? hostEnvironment) !== + undefined; + return available + ? ({ + status: "ready", + agentId, + // The catalog version does not describe an overridden executable. + version: null, + distribution: distribution.kind, + ...(documentationUrl ? { documentationUrl } : {}), + } as const) + : ({ + status: "missing_runner", + agentId, + version: agent.version, + distribution: distribution.kind, + runner: commandOverride, + } as const); + } + + if (distribution.kind === "binary") { + return yield* installSemaphore.withPermits(1)( + Effect.gen(function* () { + const paths = binaryPaths(agent, distribution.binaryTarget!); + if (paths === undefined) { + return { status: "unsupported", agentId, version: agent.version } as const; + } + yield* consumePreparedBinaryReservation(agent.id); + const exists = yield* fileSystem + .exists(paths.executablePath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return { + status: "unprepared", + agentId, + version: agent.version, + distribution: "binary", + } as const; + } + yield* assertExecutableInRoot(paths.installRoot, paths.executablePath).pipe( + Effect.mapError((cause) => + isAcpRegistryError(cause) + ? cause + : new AcpRegistryError({ + reason: "install_failed", + detail: `Could not validate cached ACP Registry agent ${agent.id}.`, + cause, + }), + ), + ); + return { + status: "ready", + agentId, + version: agent.version, + distribution: "binary", + ...(documentationUrl ? { documentationUrl } : {}), + } as const; + }), + ); + } + + const runner = packageManagerFor(distribution.kind)!; + const available = + resolveExecutable(runner, platform, environment ?? hostEnvironment) !== undefined; + return available + ? ({ + status: "ready", + agentId, + version: agent.version, + distribution: distribution.kind, + ...(documentationUrl ? { documentationUrl } : {}), + } as const) + : ({ + status: "missing_runner", + agentId, + version: agent.version, + distribution: distribution.kind, + runner, + } as const); + }); + + const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => + Effect.gen(function* () { + const agentId = settings.agentId.trim(); + if (agentId.length === 0) { + return yield* new AcpRegistryError({ + reason: "agent_not_configured", + detail: "ACP Registry provider requires a registry agent ID.", + }); + } + const registry = yield* loadRegistry(); + const agent = yield* findAgent(registry, agentId); + const distribution = yield* compatibleDistribution(agent, settings.distribution); + + let command: string; + let args: ReadonlyArray; + let commandBinDirectory: string | undefined; + const effectiveEnvironment = environment ?? hostEnvironment; + const commandOverride = settings.commandPath.trim(); + if (commandOverride.length > 0) { + const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment); + if (resolvedOverride === undefined) { + return yield* new AcpRegistryError({ + reason: "runner_unavailable", + detail: `ACP Registry agent ${agent.id} requires '${commandOverride}', but it is not available on this provider instance's PATH.`, + }); + } + command = resolvedOverride; + args = distribution.args; + } else if (distribution.kind === "npx" || distribution.kind === "uvx") { + const installed = yield* installSemaphore.withPermits(1)( + ensureGlobalPackage( + agent, + distribution.kind, + distribution.packageName!, + effectiveEnvironment, + ), + ); + command = installed.executablePath; + args = distribution.args; + commandBinDirectory = installed.binDirectory; + } else { + const target = distribution.binaryTarget!; + command = yield* installSemaphore.withPermits(1)( + installBinary(agent, target).pipe( + Effect.tap(() => consumePreparedBinaryReservation(agent.id)), + ), + ); + args = distribution.args; + } + + const baseSpawnEnvironment = { + ...effectiveEnvironment, + ...distribution.env, + }; + const spawnEnvironment = + commandBinDirectory === undefined + ? baseSpawnEnvironment + : withPreferredPath(baseSpawnEnvironment, commandBinDirectory, platform); + + return { + agent, + distribution: distribution.kind, + spawn: { + command, + args, + cwd, + env: spawnEnvironment, + }, + } satisfies ResolvedAcpRegistryAgent; + }); + + const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = ( + input, + isReferenced = Effect.succeed(false), + ) => + installSemaphore.withPermits(1)( + Effect.gen(function* () { + const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: "ACP Registry managed binary uninstall received an invalid agent ID.", + cause, + }), + ), + ); + if (yield* isReferenced) { + yield* consumePreparedBinaryReservation(safeAgentId); + return { agentId: safeAgentId, removed: false }; + } + if (yield* hasPreparedBinaryReservation(safeAgentId)) { + return { agentId: safeAgentId, removed: false }; + } + const agentRoot = path.join(installsDirectory, safeAgentId); + const existed = yield* fileSystem.exists(agentRoot).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + if (!existed) return { agentId: safeAgentId, removed: false }; + + let removed = false; + yield* Effect.gen(function* () { + for (const version of yield* fileSystem.readDirectory(agentRoot)) { + const versionRoot = path.join(agentRoot, version); + for (const entry of yield* fileSystem.readDirectory(versionRoot)) { + if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; + yield* fileSystem.remove(path.join(versionRoot, entry), { + recursive: true, + force: true, + }); + removed = true; + } + if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) + yield* fileSystem.remove(versionRoot, { recursive: true }); + } + if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) + yield* fileSystem.remove(agentRoot, { recursive: true }); + }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + return { agentId: safeAgentId, removed }; + }), + ); + + return AcpRegistryCatalog.of({ + search, + prepare, + inspect, + resolve, + uninstallManagedBinary, + }); +}); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..a521be8a417f 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,6 +26,7 @@ import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; +import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -39,7 +40,8 @@ export type BuiltInDriversEnv = | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - | AntigravityDriverEnv; + | AntigravityDriverEnv + | AcpRegistryDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -53,4 +55,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { it("bundles a compatibility policy for every built-in harness", () => { for (const builtIn of BUILT_IN_DRIVERS) { + // Registry entries are arbitrary external ACP agents, not one versioned harness. + if (builtIn.driverKind === "acpRegistry") continue; assert.isDefined( resolveProviderCompatibility( ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5dc793b5cdf1..f756090890ad 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -136,6 +136,8 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; +import { AcpRegistryCatalog } from "./provider/acp/AcpRegistrySupport.ts"; +import { AcpRegistryRuntimeCoordinator } from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; import { AntigravityInstallation, AntigravityInstallationError, @@ -830,6 +832,8 @@ const buildAppUnderTest = (options?: { managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), + Layer.mock(AcpRegistryCatalog)({}), + Layer.mock(AcpRegistryRuntimeCoordinator)({}), Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ upsert: () => Effect.void, getBinding: () => Effect.succeedNone, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b7020458160d..16c7ef766071 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -69,6 +69,7 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { AcpRegistryCatalogLive } from "./provider/Layers/AcpRegistryCatalog.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -532,6 +533,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), ).pipe( Layer.provideMerge(AntigravityInstallation.layer), + // Search, prepare, status inspection, and turn launch share one registry + // cache so every client and provider instance sees the same prepared agents. + Layer.provideMerge(AcpRegistryCatalogLive), + // ACP Registry sign-in runs an agent's terminal login in a PTY. + Layer.provideMerge(PtyAdapterLive), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 077087a7d84e..5dfbcb034d87 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -117,6 +117,12 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; +import { + AcpRegistryCatalog, + AcpRegistryError, + toAcpRegistryOperationError, +} from "./provider/acp/AcpRegistrySupport.ts"; +import { AcpRegistryRuntimeCoordinator } from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -575,6 +581,8 @@ const makeWsRpcLayer = ( const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; + const acpRegistryCatalog = yield* AcpRegistryCatalog; + const acpRegistryRuntimeCoordinator = yield* AcpRegistryRuntimeCoordinator; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -2634,6 +2642,69 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverSearchAcpRegistry]: (input) => + observeRpcEffect( + WS_METHODS.serverSearchAcpRegistry, + acpRegistryCatalog.search(input).pipe(Effect.mapError(toAcpRegistryOperationError)), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverPrepareAcpRegistryAgent]: (input) => + observeRpcEffect( + WS_METHODS.serverPrepareAcpRegistryAgent, + acpRegistryCatalog.prepare(input).pipe(Effect.mapError(toAcpRegistryOperationError)), + { + "rpc.aggregate": "server", + "acp_registry.agent_id": input.agentId, + }, + ), + [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => + observeRpcEffect( + WS_METHODS.serverUninstallAcpRegistryManagedBinary, + acpRegistryCatalog + .uninstallManagedBinary( + input, + // Read inside the catalog's install lock, from the latest + // settings, so two removals cannot both skip the cleanup. + serverSettings.getSettings.pipe( + Effect.map((settings) => + Object.values(settings.providerInstances).some((instance) => { + if ( + instance.driver !== "acpRegistry" || + instance.config === null || + typeof instance.config !== "object" + ) { + return false; + } + return (instance.config as Record).agentId === input.agentId; + }), + ), + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not read provider settings while checking references for ACP Registry agent ${input.agentId}.`, + cause, + }), + ), + ), + ) + .pipe(Effect.mapError(toAcpRegistryOperationError)), + { + "rpc.aggregate": "server", + "acp_registry.agent_id": input.agentId, + }, + ), + [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: (input) => + observeRpcEffect( + WS_METHODS.serverAcceptAcpRegistryUrlAuth, + acpRegistryRuntimeCoordinator + .acceptUrlAuthentication(input) + .pipe(Effect.map((accepted) => ({ accepted }))), + { + "rpc.aggregate": "server", + "provider.instance_id": input.instanceId, + }, + ), [WS_METHODS.serverGetTraceDiagnostics]: (_input) => observeRpcEffect( WS_METHODS.serverGetTraceDiagnostics, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0825c5cb92cc..70db44676fe2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1362,6 +1362,8 @@ function OpenCommandPaletteDialog(props: { providerDisplayName={ thread.session?.providerName ?? providerEntry?.displayName ?? modelInstanceId } + acpRegistryAgentId={providerEntry?.acpRegistryAgentId} + acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl} /> ); }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4b8027c14108..ce59af520d8b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -390,6 +390,8 @@ function SidebarThreadTooltip({ providerEntry?.displayName ?? thread.session?.providerName ?? modelInstanceId } accentColor={providerEntry?.accentColor} + acpRegistryAgentId={providerEntry?.acpRegistryAgentId} + acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl} // Initials would swallow a size-3 glyph: accent dot, name in label. showBadge={showInstanceBadge && providerEntry?.accentColor !== undefined} badgeContent="none" @@ -1962,6 +1964,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { modelInstanceId } accentColor={providerEntry?.accentColor} + acpRegistryAgentId={providerEntry?.acpRegistryAgentId} + acpRegistryIconUrl={providerEntry?.acpRegistryIconUrl} showBadge={showInstanceBadge} // Glyph dims, badge stays saturated; offset matches the composer trigger. iconClassName="size-3.5 opacity-60" diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 58c28609dcef..6059de8ad725 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -44,6 +44,8 @@ export function ThreadCommandSubtitle(props: { isCurrent: boolean; driverKind?: ProviderDriverKind | null; providerDisplayName?: string | null; + acpRegistryAgentId?: string | undefined; + acpRegistryIconUrl?: string | undefined; variant?: ThreadCommandSubtitleVariant; className?: string; }) { @@ -97,6 +99,8 @@ export function ThreadCommandSubtitle(props: { diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 36de2e0362fe..a59ee5237fb8 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -5,7 +5,6 @@ import { getDisplayModelName, getTriggerDisplayModelLabel, type ModelEsque, - PROVIDER_ICON_BY_PROVIDER, } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; import { Button } from "../ui/button"; @@ -14,6 +13,7 @@ import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { modelPickerModelKey } from "./modelPickerKeys"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; export const ModelListRow = memo(function ModelListRow(props: { index: number; @@ -29,6 +29,8 @@ export const ModelListRow = memo(function ModelListRow(props: { */ providerDisplayName: string; providerAccentColor?: string | undefined; + acpRegistryAgentId?: string | undefined; + acpRegistryIconUrl?: string | undefined; isFavorite: boolean; isSelected: boolean; showSelection?: boolean; @@ -41,7 +43,6 @@ export const ModelListRow = memo(function ModelListRow(props: { disabledReason?: string | null; onToggleFavorite: () => void; }) { - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; const providerLabel = props.model.subProvider ? `${props.providerDisplayName} · ${props.model.subProvider}` : props.providerDisplayName; @@ -84,7 +85,14 @@ export const ModelListRow = memo(function ModelListRow(props: { {props.showProvider && (
- {ProviderIcon ? : null} + {providerLabel} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 57a04b1a2f24..70181947bc4a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -57,6 +57,8 @@ type ModelPickerItem = { driverKind: ProviderDriverKind; instanceDisplayName: string; instanceAccentColor?: string | undefined; + acpRegistryAgentId?: string | undefined; + acpRegistryIconUrl?: string | undefined; continuationGroupKey?: string | undefined; isLegacy?: boolean | undefined; isUnavailable?: boolean | undefined; @@ -387,6 +389,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { driverKind: entry.driverKind, instanceDisplayName: entry.displayName, ...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}), + ...(entry.acpRegistryAgentId ? { acpRegistryAgentId: entry.acpRegistryAgentId } : {}), + ...(entry.acpRegistryIconUrl ? { acpRegistryIconUrl: entry.acpRegistryIconUrl } : {}), ...(entry.continuationGroupKey ? { continuationGroupKey: entry.continuationGroupKey } : {}), @@ -987,6 +991,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { driverKind={model.driverKind} providerDisplayName={model.instanceDisplayName} providerAccentColor={model.instanceAccentColor} + acpRegistryAgentId={model.acpRegistryAgentId} + acpRegistryIconUrl={model.acpRegistryIconUrl} isFavorite={favoritesSet.has( providerModelKey(model.instanceId, model.slug), )} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index d043c053a073..6d4710205435 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -200,6 +200,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { driverKind={entry.driverKind} displayName={entry.displayName} accentColor={entry.accentColor} + acpRegistryAgentId={entry.acpRegistryAgentId} + acpRegistryIconUrl={entry.acpRegistryIconUrl} showBadge={showInstanceBadge} className="size-6 z-30" iconClassName="size-5" diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.test.ts b/apps/web/src/components/chat/ProviderInstanceIcon.test.ts new file mode 100644 index 000000000000..edc68c599fb0 --- /dev/null +++ b/apps/web/src/components/chat/ProviderInstanceIcon.test.ts @@ -0,0 +1,32 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProviderInstanceAcpRegistryIconUrl } from "./ProviderInstanceIcon"; + +describe("resolveProviderInstanceAcpRegistryIconUrl", () => { + it("uses allowlisted catalog metadata and rejects untrusted overrides", () => { + expect( + resolveProviderInstanceAcpRegistryIconUrl({ + driverKind: ProviderDriverKind.make("acpRegistry"), + agentId: "kilo", + iconUrl: "https://cdn.agentclientprotocol.com/registry/icons/kilo.svg", + }), + ).toBe("https://cdn.agentclientprotocol.com/registry/icons/kilo.svg"); + expect( + resolveProviderInstanceAcpRegistryIconUrl({ + driverKind: ProviderDriverKind.make("acpRegistry"), + agentId: "generic-agent", + iconUrl: "https://example.com/not-official.svg", + }), + ).toBe("https://cdn.agentclientprotocol.com/registry/v1/latest/generic-agent.svg"); + }); + + it("does not resolve registry icons for other provider drivers", () => { + expect( + resolveProviderInstanceAcpRegistryIconUrl({ + driverKind: ProviderDriverKind.make("codex"), + agentId: "kilo", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 6c36087a1e29..05e2a0a967d5 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -4,13 +4,33 @@ import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { cn } from "~/lib/utils"; +import { + AcpRegistryAgentIcon, + officialAcpRegistryIconUrlForAgentId, + resolveOfficialAcpRegistryIconUrl, +} from "../settings/AcpRegistryIcon"; export { providerInstanceInitials }; +/** Registry agents show their own official glyph, from the catalog or derived from the agent id. */ +export function resolveProviderInstanceAcpRegistryIconUrl(input: { + readonly driverKind: ProviderDriverKind; + readonly agentId?: string | undefined; + readonly iconUrl?: string | undefined; +}): string | null { + if (input.driverKind !== "acpRegistry") return null; + return ( + resolveOfficialAcpRegistryIconUrl(input.iconUrl ?? null) ?? + officialAcpRegistryIconUrlForAgentId(input.agentId?.trim() || null) + ); +} + export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; displayName: string; accentColor?: string | undefined; + acpRegistryAgentId?: string | undefined; + acpRegistryIconUrl?: string | undefined; showBadge?: boolean; badgeContent?: "initials" | "none"; className?: string; @@ -25,6 +45,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { ? ({ "--provider-accent": props.accentColor } as CSSProperties) : undefined; const badgeContent = props.badgeContent ?? "initials"; + const isAcpRegistry = props.driverKind === "acpRegistry"; return ( - {Icon ? ( + {isAcpRegistry ? ( + + ) : Icon ? ( ) : ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index fbc178965378..b8d5e0cf9d8e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -245,6 +245,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={activeEntry.driverKind} displayName={activeEntry.displayName} accentColor={activeEntry.accentColor} + acpRegistryAgentId={activeEntry.acpRegistryAgentId} + acpRegistryIconUrl={activeEntry.acpRegistryIconUrl} showBadge={showInstanceBadge} className="size-4" iconClassName={cn("size-4", props.activeProviderIconClassName)} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..a9ebfebfb9b5 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { + ACPRegistryIcon, AntigravityIcon, ClaudeAI, CursorIcon, @@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, + [ProviderDriverKind.make("acpRegistry")]: ACPRegistryIcon, }; export type ModelEsque = { diff --git a/apps/web/src/components/settings/AcpRegistryIcon.test.ts b/apps/web/src/components/settings/AcpRegistryIcon.test.ts new file mode 100644 index 000000000000..39e65f644c7d --- /dev/null +++ b/apps/web/src/components/settings/AcpRegistryIcon.test.ts @@ -0,0 +1,206 @@ +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => effect(), + useState: reactHookHarness.useState, + useId: () => "acp-icon-test-filter", + }; +}); + +import { + AcpRegistryAgentIcon, + loadCachedAcpRegistryIcon, + officialAcpRegistryIconUrlForAgentId, + resolveOfficialAcpRegistryIconUrl, +} from "./AcpRegistryIcon"; + +describe("ACP Registry icon cache", () => { + const stored = new Map(); + const match = vi.fn(async (url: string) => stored.get(url)?.clone()); + const put = vi.fn(async (url: string, response: Response) => { + stored.set(url, response.clone()); + }); + const fetchIcon = vi.fn(); + + beforeEach(() => { + hooks.reset(); + stored.clear(); + match.mockClear(); + put.mockClear(); + fetchIcon.mockReset(); + vi.stubGlobal("caches", { open: vi.fn(async () => ({ match, put })) }); + vi.stubGlobal("fetch", fetchIcon); + }); + + it("single-flights the first fetch and serves later reads from persistent cache", async () => { + const url = "https://cdn.agentclientprotocol.com/registry/icons/cache-test.svg"; + fetchIcon.mockResolvedValue( + new Response("", { + status: 200, + headers: { "content-type": "image/svg+xml" }, + }), + ); + + const [first, concurrent] = await Promise.all([ + loadCachedAcpRegistryIcon(url), + loadCachedAcpRegistryIcon(url), + ]); + const cached = await loadCachedAcpRegistryIcon(url); + + expect(first.type).toBe("image/svg+xml"); + expect(concurrent.size).toBe(first.size); + expect(cached.size).toBe(first.size); + expect(fetchIcon).toHaveBeenCalledOnce(); + expect(put).toHaveBeenCalledOnce(); + }); + + it("rejects oversized image responses before caching", async () => { + const url = "https://cdn.agentclientprotocol.com/registry/icons/oversized.svg"; + fetchIcon.mockResolvedValue( + new Response("too large", { + status: 200, + headers: { + "content-length": String(513 * 1_024), + "content-type": "image/svg+xml", + }, + }), + ); + + await expect(loadCachedAcpRegistryIcon(url)).rejects.toThrow("too large"); + expect(put).not.toHaveBeenCalled(); + }); + + it("falls back to the network when CacheStorage cannot be opened", async () => { + const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; + vi.stubGlobal("caches", { + open: vi.fn(async () => { + throw new Error("CacheStorage unavailable"); + }), + }); + fetchIcon.mockResolvedValue( + new Response("", { + status: 200, + headers: { "content-type": "image/svg+xml" }, + }), + ); + + await expect(loadCachedAcpRegistryIcon(url)).resolves.toMatchObject({ + type: "image/svg+xml", + }); + expect(fetchIcon).toHaveBeenCalledWith(url, { + credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + }); + }); + + it("accepts only credential-free HTTPS URLs on the official CDN", () => { + expect( + resolveOfficialAcpRegistryIconUrl( + "https://cdn.agentclientprotocol.com/registry/icons/gemini.png", + ), + ).toBe("https://cdn.agentclientprotocol.com/registry/icons/gemini.png"); + expect( + resolveOfficialAcpRegistryIconUrl("http://cdn.agentclientprotocol.com/icon.png"), + ).toBeNull(); + expect( + resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com.evil/icon.png"), + ).toBeNull(); + expect( + resolveOfficialAcpRegistryIconUrl("https://user@cdn.agentclientprotocol.com/icon.png"), + ).toBeNull(); + expect( + resolveOfficialAcpRegistryIconUrl("https://cdn.agentclientprotocol.com:8443/icon.png"), + ).toBeNull(); + expect(officialAcpRegistryIconUrlForAgentId("kilo")).toBe( + "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg", + ); + expect(officialAcpRegistryIconUrlForAgentId("../kilo")).toBeNull(); + }); + + it("falls back to the raw allowlisted CDN URL when the validating fetch is blocked", async () => { + const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; + // The official CDN serves no CORS headers, so the fetch can reject even + // though native loading works. + fetchIcon.mockRejectedValue(new TypeError("Failed to fetch")); + hooks.beginRender(); + AcpRegistryAgentIcon({ icon: url }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + hooks.beginRender(); + const tree = AcpRegistryAgentIcon({ icon: url }) as ReactElement>; + const image = visitElements(tree, (element) => element.type === "img"); + expect(image?.props).toMatchObject({ src: url, referrerPolicy: "no-referrer" }); + expect( + visitElements(tree, (element) => element.props["data-slot"] === "acp-icon-fallback"), + ).not.toBeNull(); + }); + + it("renders only the validated blob object URL and keeps the fallback until load", async () => { + const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; + const objectUrl = "blob:t3/kilo-icon"; + Object.assign(URL, { + createObjectURL: vi.fn(() => objectUrl), + revokeObjectURL: vi.fn(), + }); + fetchIcon.mockResolvedValue( + new Response("", { + status: 200, + headers: { "content-type": "image/svg+xml" }, + }), + ); + hooks.beginRender(); + const validating = AcpRegistryAgentIcon({ icon: url }) as ReactElement>; + + // The raw CDN URL is never rendered; only the fallback shows while the + // blob is fetched and validated. + expect(visitElements(validating, (element) => element.type === "img")).toBeNull(); + expect( + visitElements(validating, (element) => element.props["data-slot"] === "acp-icon-fallback"), + ).not.toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + hooks.beginRender(); + const loading = AcpRegistryAgentIcon({ icon: url }) as ReactElement>; + const loadingImage = visitElements(loading, (element) => element.type === "img"); + + expect(loadingImage?.props).toMatchObject({ + src: objectUrl, + alt: "", + decoding: "async", + referrerPolicy: "no-referrer", + }); + expect(loadingImage?.props.className).not.toContain("dark:invert"); + expect(loadingImage?.props.className).toContain("invisible"); + expect( + visitElements(loading, (element) => element.props["data-slot"] === "acp-icon-fallback"), + ).not.toBeNull(); + + (loadingImage?.props.onLoad as (() => void) | undefined)?.(); + hooks.beginRender(); + const loaded = AcpRegistryAgentIcon({ icon: url }) as ReactElement>; + expect( + visitElements(loaded, (element) => element.type === "img")?.props.className, + ).not.toContain("invisible"); + expect( + visitElements(loaded, (element) => element.props["data-slot"] === "acp-icon-fallback"), + ).toBeNull(); + + const loadedImage = visitElements(loaded, (element) => element.type === "img"); + (loadedImage?.props.onError as (() => void) | undefined)?.(); + hooks.beginRender(); + const failed = AcpRegistryAgentIcon({ icon: url }) as ReactElement>; + expect(visitElements(failed, (element) => element.type === "img")).toBeNull(); + expect( + visitElements(failed, (element) => element.props["data-slot"] === "acp-icon-fallback"), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/AcpRegistryIcon.tsx b/apps/web/src/components/settings/AcpRegistryIcon.tsx new file mode 100644 index 000000000000..62553177d14e --- /dev/null +++ b/apps/web/src/components/settings/AcpRegistryIcon.tsx @@ -0,0 +1,170 @@ +import { useEffect, useId, useState } from "react"; +import { + resolveOfficialAcpRegistryIconUrl, + officialAcpRegistryIconUrlForAgentId, +} from "@t3tools/contracts"; + +import { cn } from "../../lib/utils"; +import { ACPRegistryIcon } from "../Icons"; + +const ACP_REGISTRY_ICON_CACHE = "t3-acp-registry-icons-v1"; +const MAX_ICON_BYTES = 512 * 1_024; +const inFlightIcons = new Map>(); + +export { officialAcpRegistryIconUrlForAgentId, resolveOfficialAcpRegistryIconUrl }; + +async function checkedIconBlob(response: Response): Promise { + if (!response.ok || response.redirected) throw new Error("ACP registry icon request failed."); + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? ""; + if (!contentType.startsWith("image/")) throw new Error("ACP registry icon was not an image."); + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_ICON_BYTES) { + throw new Error("ACP registry icon was too large."); + } + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > MAX_ICON_BYTES) throw new Error("ACP registry icon was too large."); + return new Blob([bytes], { type: contentType }); +} + +/** Fetches an official icon once, then serves it from persistent browser cache. */ +export function loadCachedAcpRegistryIcon(iconUrl: string): Promise { + if (resolveOfficialAcpRegistryIconUrl(iconUrl) !== iconUrl) { + return Promise.reject(new Error("ACP registry icon URL was not allowed.")); + } + const pending = inFlightIcons.get(iconUrl); + if (pending) return pending; + const load = (async () => { + let cache: Cache | null = null; + if (typeof caches !== "undefined") { + try { + cache = await caches.open(ACP_REGISTRY_ICON_CACHE); + } catch { + // Some privacy modes expose CacheStorage but reject access. Icons can + // still load normally from the allowlisted registry CDN. + } + } + if (cache !== null) { + try { + const cached = await cache.match(iconUrl); + if (cached) return await checkedIconBlob(cached); + } catch { + // Ignore unreadable or invalid cache entries and repair from network. + } + } + + const response = await fetch(iconUrl, { + credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + }); + const clone = response.clone(); + const blob = await checkedIconBlob(response); + await cache?.put(iconUrl, clone).catch(() => undefined); + return blob; + })(); + inFlightIcons.set(iconUrl, load); + void load.then( + () => inFlightIcons.delete(iconUrl), + () => inFlightIcons.delete(iconUrl), + ); + return load; +} + +export function AcpRegistryAgentIcon({ + icon, + className, + fallbackClassName, +}: { + readonly icon: string | null; + readonly className?: string; + readonly fallbackClassName?: string; +}) { + const colorFilterId = useId(); + const iconUrl = resolveOfficialAcpRegistryIconUrl(icon); + const [image, setImage] = useState<{ + readonly iconUrl: string; + readonly source: string; + readonly status: "loading" | "loaded" | "failed"; + } | null>(null); + const currentImage = image?.iconUrl === iconUrl ? image : null; + // Render only after the load pipeline resolves: the validated blob when the + // fetch succeeds, or the raw allowlisted CDN URL when it fails (the official + // CDN serves no CORS headers, so the validating fetch is often blocked in + // cross-origin browser contexts while native loading still works). + const source = currentImage?.source ?? null; + const status = currentImage?.status ?? "loading"; + + useEffect(() => { + if (iconUrl === null) return; + let active = true; + let objectUrl: string | null = null; + void loadCachedAcpRegistryIcon(iconUrl) + .then((blob) => { + if (!active) return; + objectUrl = URL.createObjectURL(blob); + setImage({ iconUrl, source: objectUrl, status: "loading" }); + }) + .catch(() => { + if (!active) return; + setImage((current) => + current?.iconUrl === iconUrl ? current : { iconUrl, source: iconUrl, status: "loading" }, + ); + }); + return () => { + active = false; + if (objectUrl !== null) URL.revokeObjectURL(objectUrl); + }; + }, [iconUrl]); + + return ( + + {/* Registry glyphs are monochrome. Recolor their alpha rather than + inverting RGB values; external images cannot inherit currentColor. + A local SVG filter also works when CDN CORS prevents CSS masks. */} + + + + + + + + + {status !== "loaded" ? ( + + ) : null} + {source !== null && status !== "failed" ? ( + { + if (iconUrl === null) return; + setImage((current) => + current?.iconUrl === iconUrl && current.source !== source + ? current + : { iconUrl, source, status: "failed" }, + ); + }} + onLoad={() => { + if (iconUrl !== null) setImage({ iconUrl, source, status: "loaded" }); + }} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx new file mode 100644 index 000000000000..06d5704c415c --- /dev/null +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx @@ -0,0 +1,328 @@ +import type { ReactElement } from "react"; +import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const atoms = vi.hoisted(() => ({ + search: Symbol("acp-search"), + prepare: Symbol("acp-prepare"), +})); + +const state = vi.hoisted(() => ({ + result: null as { readonly agents: ReadonlyArray } | null, + error: null as string | null, + isPending: false, + refresh: vi.fn(), + search: vi.fn(() => atoms.search), + prepare: vi.fn(), +})); + +const lifecycle = vi.hoisted(() => ({ + cleanups: [] as Array<() => void>, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect(); + if (cleanup) lifecycle.cleanups.push(cleanup); + }, + useRef: reactHookHarness.useRef, + useLayoutEffect: (effect: () => void | (() => void)) => { + const cleanup = effect(); + if (cleanup) lifecycle.cleanups.push(cleanup); + }, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../state/server", () => ({ + serverEnvironment: { + searchAcpRegistry: state.search, + prepareAcpRegistryAgent: atoms.prepare, + }, +})); + +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: () => ({ + data: state.result, + error: state.error, + isPending: state.isPending, + refresh: state.refresh, + }), +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: () => state.prepare, +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + isAtomCommandInterrupted: () => false, + squashAtomCommandFailure: () => new Error("Prepare failed."), +})); + +import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; + +const environmentId = EnvironmentId.make("remote-device"); +const gemini: AcpRegistrySearchAgent = { + id: "gemini", + name: "Gemini CLI", + version: "1.2.3", + description: "Google's agent", + authors: ["Google ", "Contributor"], + license: "Apache-2.0", + website: "https://example.com/docs", + repository: "https://example.com/source", + icon: "https://example.com/icon.png", + distribution: "npx", + integrity: "registry", +}; + +function render(options?: { + readonly configured?: boolean; + readonly onPrepared?: (agent: AcpRegistrySearchAgent) => void; +}): ReactElement> { + hooks.beginRender(); + return AcpRegistrySearchStep({ + environmentId, + providerInstances: options?.configured + ? { + acpRegistry_gemini: { + driver: ProviderDriverKind.make("acpRegistry"), + config: { agentId: "gemini" }, + }, + } + : {}, + onPrepared: options?.onPrepared ?? vi.fn(), + onManualConfiguration: vi.fn(), + }) as ReactElement>; +} + +function findByAriaLabel( + tree: ReactElement>, + label: string, +): ReactElement> { + const found = visitElements(tree, (element) => element.props["aria-label"] === label); + expect(found).not.toBeNull(); + return found!; +} + +describe("AcpRegistrySearchStep", () => { + beforeEach(() => { + vi.useFakeTimers(); + hooks.reset(); + state.result = null; + state.error = null; + state.isPending = false; + state.refresh.mockReset(); + state.search.mockClear(); + state.prepare.mockReset().mockResolvedValue({ + _tag: "Success", + value: { agentId: "gemini", version: "1.2.3", distribution: "npx", prepared: true }, + }); + lifecycle.cleanups = []; + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + }); + afterEach(() => { + for (const cleanup of lifecycle.cleanups) cleanup(); + vi.useRealTimers(); + }); + + it("loads the catalog immediately and searches only after typing settles", () => { + const initial = render(); + expect(state.search).toHaveBeenLastCalledWith({ + environmentId, + input: { query: "" }, + }); + + const input = findByAriaLabel(initial, "Search ACP Registry"); + expect(input.props.size).toBe("sm"); + (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.( + { currentTarget: { value: " Gemini " } }, + ); + vi.advanceTimersByTime(200); + render(); + expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } }); + (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.( + { + currentTarget: { value: " Gemini CLI " }, + }, + ); + vi.advanceTimersByTime(299); + render(); + expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } }); + vi.advanceTimersByTime(1); + render(); + expect(state.search).toHaveBeenLastCalledWith({ + environmentId, + input: { query: "Gemini CLI" }, + }); + (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.( + { + currentTarget: { value: "" }, + }, + ); + vi.advanceTimersByTime(300); + render(); + expect(state.search).toHaveBeenLastCalledWith({ environmentId, input: { query: "" } }); + }); + + it("renders deterministic loading, error, and empty states", () => { + state.isPending = true; + expect( + visitElements(render(), (element) => element.props.children === "Searching the registry..."), + ).not.toBeNull(); + + state.isPending = false; + state.error = "Registry unavailable."; + expect( + visitElements(render(), (element) => element.props.children === "Registry unavailable."), + ).not.toBeNull(); + + state.error = null; + state.result = { agents: [] }; + expect( + visitElements(render(), (element) => element.props.children === "No compatible agents found"), + ).not.toBeNull(); + }); + + it("keeps same-query refreshes visible and announced while retaining results", () => { + const first = render(); + const input = findByAriaLabel(first, "Search ACP Registry"); + (input.props.onChange as ((event: { currentTarget: { value: string } }) => void) | undefined)?.( + { + currentTarget: { value: "Codex" }, + }, + ); + const draft = render(); + const searchForm = visitElements(draft, (element) => element.type === "form"); + (searchForm?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.( + { + preventDefault: vi.fn(), + }, + ); + + state.result = { agents: [gemini] }; + const resultTree = render(); + expect(state.search).toHaveBeenCalledWith({ + environmentId, + input: { query: "Codex" }, + }); + const form = visitElements(resultTree, (element) => element.type === "form"); + (form?.props.onSubmit as ((event: { preventDefault: () => void }) => void) | undefined)?.({ + preventDefault: vi.fn(), + }); + expect(state.refresh).toHaveBeenCalledOnce(); + + state.isPending = true; + const refreshingTree = render(); + expect( + visitElements( + refreshingTree, + (element) => + element.props.role === "status" && + element.props.children === "Refreshing ACP Registry results.", + ), + ).not.toBeNull(); + expect(findByAriaLabel(refreshingTree, "Add Gemini CLI")).not.toBeNull(); + }); + + it("prepares a result before handing it back to the wizard", async () => { + state.prepare.mockResolvedValueOnce({ + _tag: "Success", + value: { agentId: "gemini", version: "2.0.0", distribution: "binary", prepared: true }, + }); + state.result = { agents: [gemini] }; + const onPrepared = vi.fn(); + const tree = render({ onPrepared }); + + const add = findByAriaLabel(tree, "Add Gemini CLI"); + (add.props.onClick as (() => void) | undefined)?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(state.prepare).toHaveBeenCalledWith({ + environmentId, + input: { agentId: "gemini" }, + }); + expect(onPrepared).toHaveBeenCalledWith({ + ...gemini, + version: "2.0.0", + distribution: "binary", + }); + }); + + it("ignores a stale prepare completion", async () => { + let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void; + let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void; + state.prepare + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + state.result = { agents: [gemini] }; + const onPrepared = vi.fn(); + const tree = render({ onPrepared }); + const add = findByAriaLabel(tree, "Add Gemini CLI"); + + (add.props.onClick as (() => void) | undefined)?.(); + (add.props.onClick as (() => void) | undefined)?.(); + resolveFirst({ _tag: "Success", value: {} }); + await Promise.resolve(); + expect(onPrepared).not.toHaveBeenCalled(); + + resolveSecond({ _tag: "Success", value: {} }); + await Promise.resolve(); + expect(onPrepared).toHaveBeenCalledOnce(); + }); + + it("ignores prepare completion after unmount", async () => { + let resolvePrepare!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void; + state.prepare.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePrepare = resolve; + }), + ); + state.result = { agents: [gemini] }; + const onPrepared = vi.fn(); + const tree = render({ onPrepared }); + + const add = findByAriaLabel(tree, "Add Gemini CLI"); + (add.props.onClick as (() => void) | undefined)?.(); + for (const cleanup of lifecycle.cleanups) cleanup(); + resolvePrepare({ _tag: "Success", value: {} }); + await Promise.resolve(); + + expect(onPrepared).not.toHaveBeenCalled(); + }); + + it("renders existing registry configuration as already added", () => { + state.result = { agents: [gemini] }; + const tree = render({ configured: true }); + const added = findByAriaLabel(tree, "Already added Gemini CLI"); + + expect(added.props.disabled).toBe(true); + }); +}); diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx new file mode 100644 index 000000000000..2084c035d671 --- /dev/null +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx @@ -0,0 +1,279 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { + AcpRegistryPrepareResult, + AcpRegistrySearchAgent, + EnvironmentId, + ProviderInstanceConfig, +} from "@t3tools/contracts"; +import { ExternalLinkIcon, SearchIcon } from "lucide-react"; +import { type FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { isConfiguredAcpRegistryAgent } from "./AddProviderInstanceDialog.logic"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "The ACP could not be prepared."; +} + +interface AcpRegistrySearchStepProps { + readonly environmentId: EnvironmentId; + readonly providerInstances: Readonly>; + readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; + readonly onManualConfiguration: () => void; + readonly onLoadingChange?: (loading: boolean) => void; + readonly onPreparingChange?: (preparing: boolean) => void; +} + +function applyAcpRegistryPrepareResult( + agent: AcpRegistrySearchAgent, + prepared: AcpRegistryPrepareResult, +): AcpRegistrySearchAgent { + return { + ...agent, + id: prepared.agentId, + version: prepared.version, + distribution: prepared.distribution, + }; +} + +export function AcpRegistrySearchStep({ + environmentId, + providerInstances, + onPrepared, + onManualConfiguration, + onLoadingChange, + onPreparingChange, +}: AcpRegistrySearchStepProps) { + const [query, setQuery] = useState(""); + // An empty registry query is the compact compatible catalog. Start there so + // entering this step is useful before the user knows what to search for. + const [submittedQuery, setSubmittedQuery] = useState(""); + const [preparingId, setPreparingId] = useState(null); + const [prepareError, setPrepareError] = useState(null); + const prepareGeneration = useRef(0); + const searchTimer = useRef | null>(null); + const search = useEnvironmentQuery( + serverEnvironment.searchAcpRegistry({ + environmentId, + input: { query: submittedQuery }, + }), + ); + const prepareAgent = useAtomCommand(serverEnvironment.prepareAcpRegistryAgent, { + reportFailure: false, + }); + + useEffect( + () => () => { + prepareGeneration.current += 1; + if (searchTimer.current !== null) clearTimeout(searchTimer.current); + onPreparingChange?.(false); + }, + [onPreparingChange], + ); + + const submitSearch = (nextQuery: string) => { + if (searchTimer.current !== null) clearTimeout(searchTimer.current); + const trimmed = nextQuery.trim(); + setQuery(trimmed); + setPrepareError(null); + if (trimmed === submittedQuery) { + search.refresh(); + } else { + setSubmittedQuery(trimmed); + } + }; + + const handleSearch = (event: FormEvent) => { + event.preventDefault(); + submitSearch(query); + }; + + const handlePrepare = async (agent: AcpRegistrySearchAgent) => { + const generation = ++prepareGeneration.current; + setPrepareError(null); + setPreparingId(agent.id); + onPreparingChange?.(true); + const result = await prepareAgent({ environmentId, input: { agentId: agent.id } }); + if (prepareGeneration.current !== generation) return; + setPreparingId(null); + onPreparingChange?.(false); + if (result._tag === "Success") { + onPrepared(applyAcpRegistryPrepareResult(agent, result.value)); + return; + } + if (!isAtomCommandInterrupted(result)) { + setPrepareError(errorMessage(squashAtomCommandFailure(result))); + } + }; + + const results = search.data?.agents ?? null; + const isInitialSearch = search.isPending && results === null; + const isRefreshing = search.isPending && results !== null; + const resultCount = results?.length ?? 0; + + useLayoutEffect(() => { + onLoadingChange?.(search.isPending); + return () => onLoadingChange?.(false); + }, [onLoadingChange, search.isPending]); + + return ( +
+

+ Choose an agent +

+ +
+ + + + + { + const nextQuery = event.currentTarget.value; + setQuery(nextQuery); + setPrepareError(null); + if (searchTimer.current !== null) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => { + searchTimer.current = null; + setSubmittedQuery(nextQuery.trim()); + }, 300); + }} + placeholder="Search agents…" + size="sm" + type="search" + value={query} + /> + + +
+ +
+ {isInitialSearch + ? "Searching the ACP Registry." + : isRefreshing + ? "Refreshing ACP Registry results." + : results + ? `${resultCount} compatible ${resultCount === 1 ? "agent" : "agents"} found.` + : ""} +
+ + {search.error || prepareError ? ( + + {prepareError ?? search.error} + + ) : null} + + {isInitialSearch ? ( +
+ Searching the registry... +
+ ) : null} + + {results ? ( + results.length === 0 ? ( +
+

No compatible agents found

+

Try a broader search.

+
+ ) : ( + + {/* The overlay scrollbar takes no layout space, so the rows + reserve its lane explicitly. */} +
+ {results.map((agent) => { + const alreadyAdded = isConfiguredAcpRegistryAgent(providerInstances, agent.id); + const isPreparing = preparingId === agent.id; + const progressLabel = agent.distribution === "binary" ? "Downloading" : "Preparing"; + return ( +
+
+
+ +
+
+

+ {agent.name} +

+
+ {agent.description ? ( +

+ {agent.description} +

+ ) : null} +
+
+
+ {agent.website || agent.repository ? ( + + + } + > + + + } + /> + About {agent.name} + + ) : null} + +
+
+
+ ); + })} +
+
+ ) + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 3c502c624ddd..b59b070f18a2 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -28,6 +28,10 @@ vi.mock("../../hooks/useSettings", () => ({ useUpdateEnvironmentSettings: settingsHooks.update, })); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: () => vi.fn(), +})); + import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; const remoteEnvironmentId = EnvironmentId.make("remote-device"); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index fdffa9a190ea..63a803d2d908 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -3,8 +3,73 @@ export type WizardNavigation = | { readonly kind: "blocked"; readonly step: number; readonly error: string }; const IDENTITY_STEP = 1; +const ACP_REGISTRY_IDENTITY_STEP = 1; export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; +export const ACP_REGISTRY_WIZARD_STEPS = ["Driver", "Identity", "Sign in"] as const; + +export interface ProviderIdentityDraft { + readonly label: string; + readonly accentColor: string; + readonly instanceIdOverride: string | null; +} + +const EMPTY_PROVIDER_IDENTITY_DRAFT: ProviderIdentityDraft = { + label: "", + accentColor: "", + instanceIdOverride: null, +}; + +export function getProviderIdentityDraft( + drafts: Readonly>, + driver: string, +): ProviderIdentityDraft { + return drafts[driver] ?? EMPTY_PROVIDER_IDENTITY_DRAFT; +} + +/** Identity drafts are kept per driver so a registry prefill never leaks into another driver. */ +export function updateProviderIdentityDraft( + drafts: Readonly>, + driver: string, + update: Partial, +): Record { + return { + ...drafts, + [driver]: { ...getProviderIdentityDraft(drafts, driver), ...update }, + }; +} + +/** Appends `_2`, `_3`, ... until the id is free, keeping it within the 64-char slug cap. */ +export function deriveAvailableInstanceId( + derive: (label: string) => string, + label: string, + existing: ReadonlySet, +): string { + const base = derive(label); + if (!base || !existing.has(base)) return base; + + for (let suffix = 2; ; suffix += 1) { + const suffixText = `_${suffix}`; + const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`; + if (!existing.has(candidate)) return candidate; + } +} + +export function isConfiguredAcpRegistryAgent( + instances: Readonly>, + agentId: string, +): boolean { + return Object.values(instances).some((instance) => { + if ( + instance.driver !== "acpRegistry" || + !instance.config || + typeof instance.config !== "object" + ) { + return false; + } + return (instance.config as Record).agentId === agentId; + }); +} /** * Resolve navigation within the add-provider wizard. @@ -12,25 +77,59 @@ export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as con * Moving forward past Identity requires a valid instance id, whether the user * advances one step at a time or skips directly to Config from a step header. * A blocked skip lands on Identity so its existing inline validation is - * visible. Backward navigation is always preserved. + * visible. A prerequisite step (the ACP Registry agent pick) blocks the same + * way. Backward navigation is always preserved. */ export function resolveWizardNavigation( currentStep: number, requestedStep: number, stepCount: number, - validation: { readonly instanceIdError: string | null }, + validation: { + readonly instanceIdError: string | null; + readonly identityStep?: number; + readonly prerequisite?: { + readonly step: number; + readonly error: string | null; + }; + }, ): WizardNavigation { const lastStep = Math.max(0, stepCount - 1); const targetStep = Math.max(0, Math.min(lastStep, requestedStep)); - const movesForwardPastIdentity = currentStep <= IDENTITY_STEP && targetStep > IDENTITY_STEP; + const identityStep = validation.identityStep ?? IDENTITY_STEP; + const prerequisite = validation.prerequisite; + + if (prerequisite?.error && currentStep <= prerequisite.step && targetStep > prerequisite.step) { + return { + kind: "blocked", + step: Math.min(prerequisite.step, lastStep), + error: prerequisite.error, + }; + } + + const movesForwardPastIdentity = currentStep <= identityStep && targetStep > identityStep; if (movesForwardPastIdentity && validation.instanceIdError !== null) { return { kind: "blocked", - step: Math.min(IDENTITY_STEP, lastStep), + step: Math.min(identityStep, lastStep), error: validation.instanceIdError, }; } return { kind: "navigate", step: targetStep }; } + +export function resolveAcpRegistryWizardNavigation( + currentStep: number, + requestedStep: number, + validation: { + readonly instanceIdError: string | null; + readonly selectionError: string | null; + }, +): WizardNavigation { + return resolveWizardNavigation(currentStep, requestedStep, ACP_REGISTRY_WIZARD_STEPS.length, { + instanceIdError: validation.instanceIdError, + identityStep: ACP_REGISTRY_IDENTITY_STEP, + prerequisite: { step: 0, error: validation.selectionError }, + }); +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index 594d2e4537e3..71cac1f1c3a0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; +import { + deriveAvailableInstanceId, + getProviderIdentityDraft, + isConfiguredAcpRegistryAgent, + resolveAcpRegistryWizardNavigation, + resolveWizardNavigation, + updateProviderIdentityDraft, +} from "./AddProviderInstanceDialog.logic"; describe("resolveWizardNavigation", () => { const invalidId = { instanceIdError: "Instance ID is required." }; @@ -42,3 +49,69 @@ describe("resolveWizardNavigation", () => { expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); }); }); + +describe("ACP Registry wizard", () => { + it("requires a prepared result or valid manual configuration before Identity", () => { + expect( + resolveAcpRegistryWizardNavigation(0, 1, { + instanceIdError: null, + selectionError: "Select an ACP or configure one manually.", + }), + ).toEqual({ + kind: "blocked", + step: 0, + error: "Select an ACP or configure one manually.", + }); + + expect( + resolveAcpRegistryWizardNavigation(0, 1, { + instanceIdError: null, + selectionError: null, + }), + ).toEqual({ kind: "navigate", step: 1 }); + }); + + it("derives a collision-free instance id without exceeding the slug limit", () => { + const derive = (label: string) => `acpRegistry_${label}`; + const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]); + + expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3"); + + const longBase = `acpRegistry_${"a".repeat(48)}`; + expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength( + 62, + ); + }); + + it("only marks matching ACP Registry instances as already added", () => { + const instances = { + codex: { driver: "codex", config: { agentId: "gemini" } }, + registry: { driver: "acpRegistry", config: { agentId: "gemini" } }, + }; + + expect(isConfiguredAcpRegistryAgent(instances, "gemini")).toBe(true); + expect(isConfiguredAcpRegistryAgent(instances, "codex")).toBe(false); + }); + + it("keeps registry-prefilled identity separate from other drivers", () => { + const registryDrafts = updateProviderIdentityDraft({}, "acpRegistry", { + label: "Gemini CLI", + instanceIdOverride: "acpRegistry_gemini_cli", + }); + + expect(getProviderIdentityDraft(registryDrafts, "codex")).toEqual({ + label: "", + accentColor: "", + instanceIdOverride: null, + }); + + const drafts = updateProviderIdentityDraft(registryDrafts, "codex", { + label: "Work", + instanceIdOverride: "codex_work", + }); + expect(getProviderIdentityDraft(drafts, "acpRegistry")).toMatchObject({ + label: "Gemini CLI", + instanceIdOverride: "acpRegistry_gemini_cli", + }); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 84d0a966880d..00c547454719 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -3,9 +3,11 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { ProviderInstanceId, ProviderDriverKind, + type AcpRegistrySearchAgent, type EnvironmentId, type ProviderInstanceConfig, } from "@t3tools/contracts"; @@ -13,8 +15,10 @@ import { import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; -import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; +import { Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; @@ -25,11 +29,20 @@ import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { + ACP_REGISTRY_WIZARD_STEPS, ADD_PROVIDER_WIZARD_STEPS, + deriveAvailableInstanceId, + getProviderIdentityDraft, + resolveAcpRegistryWizardNavigation, resolveWizardNavigation, + updateProviderIdentityDraft, + type ProviderIdentityDraft, type WizardNavigation, } from "./AddProviderInstanceDialog.logic"; import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; +import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; +import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; +import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -63,6 +76,7 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; interface ComingSoonDriverOption { @@ -82,11 +96,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ label: "Gemini", icon: Gemini, }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, { value: ProviderDriverKind.make("piAgent"), label: "Pi Agent", @@ -114,6 +123,7 @@ interface AddProviderInstanceDialogProps { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly onOpenChange: (open: boolean) => void; + readonly onCreated?: (instanceId: ProviderInstanceId) => void; } export function AddProviderInstanceDialog({ @@ -121,21 +131,33 @@ export function AddProviderInstanceDialog({ environmentId, environmentLabel, onOpenChange, + onCreated, }: AddProviderInstanceDialogProps) { const settings = useEnvironmentSettings(environmentId); const updateSettings = useUpdateEnvironmentSettings(environmentId); + // ACP Registry instances go straight to sign-in, which needs the saved + // instance, so their save is awaited instead of fire-and-forget. + const persistSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); - const [label, setLabel] = useState(""); - const [accentColor, setAccentColor] = useState(""); - const [instanceIdOverride, setInstanceIdOverride] = useState(null); + const [identityByDriver, setIdentityByDriver] = useState>( + {}, + ); + const [selectedAcp, setSelectedAcp] = useState(null); + const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); + const [isRegistryLoading, setIsRegistryLoading] = useState(false); + const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); // Driver-specific config drafts keyed by driver so toggling between drivers // during the same dialog session does not lose in-progress input. const [configByDriver, setConfigByDriver] = useState>>({}); // Errors are suppressed until the user has tried to submit once. After that // they update live so fixing the problem clears the message in place. const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [createdInstanceId, setCreatedInstanceId] = useState(null); const existingIds = useMemo( () => new Set(Object.keys(settings.providerInstances ?? {})), @@ -143,6 +165,11 @@ export function AddProviderInstanceDialog({ ); const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; + const { label, accentColor, instanceIdOverride } = getProviderIdentityDraft( + identityByDriver, + driver, + ); const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), @@ -151,9 +178,18 @@ export function AddProviderInstanceDialog({ const instanceIdError = validateInstanceId(instanceId, existingIds); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; const previewLabel = label.trim() || `${driverOption.label} Workspace`; - const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; + const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; + const acpSelectionError = + selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) + ? null + : "Select an ACP agent or configure one manually."; + const wizardStepSummaries = isAcpRegistry + ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) + : ([driverOption.label, previewLabel, null] as const); + const isBusy = isSaving || isPreparingRegistryAgent || createdInstanceId !== null; + const setConfigDraft = (config: Record | undefined) => { setConfigByDriver((existing) => { const next = { ...existing }; @@ -165,25 +201,85 @@ export function AddProviderInstanceDialog({ return next; }); }; + const setIdentityDraft = (update: Partial) => { + setIdentityByDriver((existing) => updateProviderIdentityDraft(existing, driver, update)); + }; const applyWizardNavigation = (navigation: WizardNavigation) => { + if (isBusy) return; if (navigation.kind === "blocked") { setHasAttemptedSubmit(true); } + if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { + void handleSave(); + return; + } setWizardStep(navigation.step); }; const navigateToStep = (requestedStep: number) => { applyWizardNavigation( - resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { - instanceIdError, + isAcpRegistry + ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { + instanceIdError, + selectionError: acpSelectionError, + }) + : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { + instanceIdError, + }), + ); + }; + + /** A prepared registry agent prefills the ACP Registry identity and config. */ + const handleAcpPrepared = (agent: AcpRegistrySearchAgent) => { + const registryIconUrl = resolveOfficialAcpRegistryIconUrl(agent.icon); + setDriver(ACP_REGISTRY_DRIVER_KIND); + setSelectedAcp(agent); + setIsManualAcpConfiguration(false); + setIdentityByDriver((existing) => + updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { + label: agent.name, + instanceIdOverride: deriveAvailableInstanceId( + (candidateLabel) => deriveInstanceId(ACP_REGISTRY_DRIVER_KIND, candidateLabel), + agent.name, + existingIds, + ), + }), + ); + setConfigByDriver((existing) => ({ + ...existing, + [ACP_REGISTRY_DRIVER_KIND]: { + agentId: agent.id, + distribution: "auto", + ...(registryIconUrl ? { registryIconUrl } : {}), + }, + })); + setHasAttemptedSubmit(false); + setWizardStep(1); + }; + + const handleManualAcpConfiguration = () => { + setDriver(ACP_REGISTRY_DRIVER_KIND); + setSelectedAcp(null); + setIsManualAcpConfiguration(true); + setConfigByDriver((existing) => { + const next = { ...existing }; + delete next[ACP_REGISTRY_DRIVER_KIND]; + return next; + }); + setIdentityByDriver((existing) => + updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { + label: "", + instanceIdOverride: null, }), ); + setHasAttemptedSubmit(false); }; - const handleSave = () => { + const handleSave = async () => { + if (isSaving || createdInstanceId) return; setHasAttemptedSubmit(true); - if (instanceIdError !== null) return; + if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return; const config = configByDriver[driver] ?? {}; const hasConfig = Object.keys(config).length > 0; @@ -205,8 +301,30 @@ export function AddProviderInstanceDialog({ ...settings.providerInstances, [brandedId]: nextInstance, }; + if (isAcpRegistry) { + setIsSaving(true); + const result = await persistSettings({ + environmentId, + input: { patch: { providerInstances: nextMap } }, + }); + setIsSaving(false); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not add provider instance", + description: error instanceof Error ? error.message : "The settings update failed.", + }); + return; + } + onCreated?.(brandedId); + setCreatedInstanceId(brandedId); + setWizardStep(2); + return; + } try { updateSettings({ providerInstances: nextMap }); + onCreated?.(brandedId); toastManager.add({ type: "success", title: "Provider instance added", @@ -234,189 +352,327 @@ export function AddProviderInstanceDialog({ } > - + {isAcpRegistry ? ( + + ) : ( + + )} - -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 sm:grid-cols-2" + {createdInstanceId ? ( + onOpenChange(false)} + /> + ) : ( + <> + - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - +
+
+ Driver +
+ { + setDriver(ProviderDriverKind.make(value)); + setIsManualAcpConfiguration(false); + setHasAttemptedSubmit(false); + }} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 sm:grid-cols-2" + > + {DRIVER_OPTIONS.filter((option) => option.value !== ACP_REGISTRY_DRIVER_KIND).map( + (option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + + + {option.badgeLabel ? ( + + {option.badgeLabel} + + ) : null} + + ); + }, + )} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} + +
+ + {wizardStep === 0 ? ( +
+
+
+ Or choose from ACP Registry +
+
+ {isAcpRegistry && isManualAcpConfiguration ? ( +
+
+
+

Enter manually

+

+ Enter an official registry ID and any local executable or auth override. +

+
+ +
+ + {hasAttemptedSubmit && acpSelectionError ? ( +

{acpSelectionError}

+ ) : null} +
+ ) : ( + <> + + {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? ( +

{acpSelectionError}

+ ) : null} + + )} +
+ ) : null} + + {isAcpRegistry && wizardStep === 1 && selectedAcp ? ( +
+
+

+ {selectedAcp.name} +

+

+ v{selectedAcp.version} · {selectedAcp.distribution} +

+
+
+ {selectedAcp.website ? ( + + Docs + ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - Coming Soon - - - ); - })} - -
+ {selectedAcp.repository ? ( + + Source + + ) : null} +
+
+ ) : null} - + - + -
- Accent color -
- -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = accentColor.toLowerCase() === swatch; - return ( -
+ {accentColor ? ( + + ) : null} +
+ + Optional marker shown in the picker. +
- {accentColor ? ( + + {!isAcpRegistry && driverSettingsFields.length > 0 ? ( +
+ +
+ ) : !isAcpRegistry && wizardStep === 2 ? ( +
+

+ This driver has no required configuration. You can add the instance now. +

+
+ ) : null} + + + + + {wizardStep < (isAcpRegistry ? 1 : ADD_PROVIDER_WIZARD_STEPS.length - 1) ? ( - ) : null} -
- - Optional marker shown in the picker. - -
- - {driverSettingsFields.length > 0 ? ( -
- -
- ) : wizardStep === 2 ? ( -
-

- This driver has no required configuration. You can add the instance now. -

-
- ) : null} -
- - - - {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( - - ) : ( - - )} - + ) : ( + + )} + + + )} ); diff --git a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx index 97df226aa790..d3c638cb1e6d 100644 --- a/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceWizardSteps.tsx @@ -9,24 +9,38 @@ interface AddProviderInstanceWizardStepsProps { readonly currentStep: number; readonly summaries: readonly (string | null)[]; readonly instanceIdError: string | null; + readonly steps?: readonly string[]; + readonly identityStep?: number; + readonly prerequisite?: { + readonly step: number; + readonly error: string | null; + }; readonly onNavigation: (navigation: WizardNavigation) => void; + readonly disabled?: boolean; } export function AddProviderInstanceWizardSteps({ currentStep, summaries, instanceIdError, + steps = ADD_PROVIDER_WIZARD_STEPS, + identityStep, + prerequisite, onNavigation, + disabled = false, }: AddProviderInstanceWizardStepsProps) { return ( disabled} onStepChange={(requestedStep) => onNavigation( - resolveWizardNavigation(currentStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { + resolveWizardNavigation(currentStep, requestedStep, steps.length, { instanceIdError, + ...(identityStep === undefined ? {} : { identityStep }), + ...(prerequisite === undefined ? {} : { prerequisite }), }), ) } diff --git a/apps/web/src/components/settings/ProviderAuthTerminal.tsx b/apps/web/src/components/settings/ProviderAuthTerminal.tsx new file mode 100644 index 000000000000..09cc245e32a0 --- /dev/null +++ b/apps/web/src/components/settings/ProviderAuthTerminal.tsx @@ -0,0 +1,80 @@ +import type { ProviderAuthResponse } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; +import { GhosttyTerminalSurface } from "../../terminal/ghostty/surface"; +import { ensureLocalApi } from "../../localApi"; +import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; + +/** Loaded only for an interactive login. PTY input is serialized by the parent. */ +export default function ProviderAuthTerminal({ + output, + outputOffset, + onResponse, +}: { + readonly output: string; + readonly outputOffset?: number | undefined; + readonly onResponse: (response: Extract) => void; +}) { + const mount = useRef(null); + const surface = useRef(null); + const latest = useRef({ output, offset: outputOffset ?? output.length, onResponse }); + const written = useRef(0); + const [error, setError] = useState(null); + useEffect(() => { + latest.current = { output, offset: outputOffset ?? output.length, onResponse }; + const terminal = surface.current; + if (!terminal) return; + const delta = latest.current.offset - written.current; + if (delta > 0 && delta <= output.length) terminal.write(output.slice(-delta)); + else if (delta !== 0) terminal.resetAndWrite(output); + written.current = latest.current.offset; + }, [output, outputOffset, onResponse]); + useEffect(() => { + const element = mount.current; + if (!element) return; + let disposed = false; + void GhosttyTerminalSurface.create(element, { + theme: terminalThemeFromApp(element), + font: { size: 13 }, + onData: (data) => latest.current.onResponse({ type: "terminal", data }), + onResize: (cols, rows) => + latest.current.onResponse({ type: "terminal", data: "", size: { cols, rows } }), + onSelectionChange: () => {}, + beforeKey: (event) => event.key !== "Tab", + onLinkActivate: (url) => { + if (/^https?:\/\//i.test(url)) + void ensureLocalApi() + .shell.openExternal(url) + .catch(() => setError("Could not open the provider link.")); + }, + }) + .then((terminal) => { + if (disposed) { + terminal.dispose(); + return; + } + surface.current = terminal; + terminal.write(latest.current.output); + written.current = latest.current.offset; + }) + .catch(() => setError("Could not load the sign-in terminal. Cancel and retry sign-in.")); + return () => { + disposed = true; + surface.current?.dispose(); + surface.current = null; + }; + }, []); + return ( + <> +
+ {error ? ( +

+ {error} +

+ ) : null} + + ); +} diff --git a/apps/web/src/components/settings/ProviderAuthenticationSection.tsx b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx new file mode 100644 index 000000000000..5eab7f230a3c --- /dev/null +++ b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx @@ -0,0 +1,475 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + ProviderAuthRespondInput, + ProviderAuthResponse, + ProviderInstanceId, + ServerProvider, +} from "@t3tools/contracts"; +import { lazy, Suspense, useRef, useState } from "react"; +import { CopyIcon } from "lucide-react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { ensureLocalApi } from "../../localApi"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsRow } from "./settingsLayout"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; + +const ProviderAuthTerminal = lazy(() => import("./ProviderAuthTerminal")); + +/** All actions target the provider's environment, even when the browser is on another device. */ +export function ProviderAuthenticationSection({ + environmentId, + environmentLabel, + instanceId, + provider, + readOnly, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly instanceId: ProviderInstanceId; + readonly provider: ServerProvider; + readonly readOnly: boolean; +}) { + const target = { environmentId, input: { instanceId } }; + const query = useEnvironmentQuery(serverEnvironment.providerAuthState(target)); + const commands = { reportFailure: false, reportDefect: false }; + const start = useAtomCommand(serverEnvironment.startProviderAuth, commands); + const respond = useAtomCommand(serverEnvironment.respondProviderAuth, commands); + const complete = useAtomCommand(serverEnvironment.completeProviderAuth, commands); + const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, commands); + const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, commands); + const [methodId, setMethodId] = useState(""); + const [draft, setDraft] = useState({ id: "", values: {} as Record }); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const terminalQueue = useRef([]); + const terminalSending = useRef(false); + const auth = query.data; + const interaction = auth?.interaction; + const active = + auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; + const signedIn = + provider.auth.status === "authenticated" || + (provider.auth.status === "unknown" && auth?.phase === "succeeded"); + const isDiscovering = + provider.driver === "acpRegistry" && + !active && + !signedIn && + !query.error && + auth?.methods === undefined; + const needsExternalSetup = + !active && + !signedIn && + (provider.setup?.canAuthenticate === false || + (provider.driver === "acpRegistry" && auth?.methods?.length === 0)); + const accountDescription = active + ? auth?.phase === "starting" + ? "Starting sign-in…" + : auth?.phase === "verifying" + ? "Checking your account…" + : interaction?.type === "terminal" + ? "Complete sign-in in the terminal below." + : interaction?.type === "credentials" + ? "Enter your credentials below." + : "Finish signing in in your browser." + : signedIn + ? "Signed in." + : isDiscovering + ? "Discovering sign-in methods…" + : needsExternalSetup + ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." + : `Sign in on ${environmentLabel}.`; + const statusMessage = auth?.phase === "failed" ? auth.message : null; + const disabled = readOnly || pending || query.error !== null || isDiscovering; + const draftId = `${auth?.flowId ?? ""}:${interaction?.id ?? ""}`; + const values = draft.id === draftId ? draft.values : {}; + const url = + interaction?.type === "browser" || interaction?.type === "deviceCode" + ? interaction.url + : auth?.authorizationUrl; + + async function run(command: () => Promise>) { + if (pendingRef.current) return false; + pendingRef.current = true; + setPending(true); + setError(null); + let succeeded = false; + try { + const result = await command(); + if (result._tag === "Success") succeeded = true; + else if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError( + failure instanceof Error ? failure.message : "Provider sign-in failed. Try again.", + ); + } + } catch { + setError("Provider sign-in failed. Try again."); + } + pendingRef.current = false; + setPending(false); + return succeeded; + } + + async function send(response: ProviderAuthResponse) { + if (!auth?.flowId || !interaction) return false; + return run(() => + respond({ + environmentId, + input: { instanceId, flowId: auth.flowId!, interactionId: interaction.id, response }, + }), + ); + } + + async function openBrowser() { + if (!url || readOnly) return; + const requiresConsent = interaction?.type === "browser" && interaction.requiresConsent; + // Browsers block tabs opened after an await, so the web build reserves one + // while the environment records consent. + const pending = requiresConsent && !window.desktopBridge ? window.open("", "_blank") : null; + if (pending) pending.opener = null; + try { + // Consent is checked on the environment before opening a provider URL locally. + if (requiresConsent && !(await send({ type: "browser", action: "accept" }))) { + pending?.close(); + return; + } + if (pending) pending.location.href = url; + else await ensureLocalApi().shell.openExternal(url); + setError(null); + } catch { + pending?.close(); + setError("Could not open the sign-in page. Copy the link and open it in your browser."); + } + } + + function updateDraft(name: string, value: string) { + setDraft({ id: draftId, values: { ...values, [name]: value } }); + } + + return ( + + Signed in as{" "} + + + ) : ( + {accountDescription} + ) + } + status={ + statusMessage ? ( +

+ {statusMessage} +

+ ) : undefined + } + control={ +
+ {!active && (auth?.methods?.length ?? 0) > 1 ? ( + + ) : null} + {url ? ( + <> + + + { + // Copy inside the click so the clipboard keeps the user + // activation; the link only works once consent is recorded. + const copied = writeTextToClipboard(url, "Provider sign-in link"); + void (async () => { + await copied; + if (interaction?.type === "browser" && interaction.requiresConsent) + await send({ type: "browser", action: "accept" }); + })().catch(() => setError("Could not copy the sign-in link.")); + }} + > + + + } + /> + Copy sign-in link + + + ) : null} + <> + {needsExternalSetup && provider.setup?.documentationUrl ? ( + + ) : active && auth?.flowId ? ( + + ) : !active && !needsExternalSetup && provider.setup?.canAuthenticate !== false ? ( + + ) : null} + {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? ( + + ) : null} + +
+ } + > + {interaction?.type === "terminal" || + interaction?.type === "credentials" || + interaction?.type === "deviceCode" || + (url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction)) || + error || + query.error ? ( + <> + {interaction?.type === "deviceCode" ? ( +

+ Enter code{" "} + {interaction.userCode}{" "} + in your browser. +

+ ) : null} + {interaction?.type === "terminal" ? ( +
+ Loading sign-in terminal…

+ } + > + { + if (readOnly || !auth?.flowId) return; + for ( + let offset = 0; + offset < Math.max(1, response.data.length); + offset += 4_096 + ) { + terminalQueue.current.push({ + instanceId, + flowId: auth.flowId, + interactionId: interaction.id, + response: { + ...response, + data: response.data.slice(offset, offset + 4_096), + }, + }); + } + if (terminalSending.current) return; + terminalSending.current = true; + void (async () => { + while (terminalQueue.current.length > 0) { + const input = terminalQueue.current.shift()!; + const result = await respond({ environmentId, input }); + if (result._tag !== "Success") { + terminalQueue.current = []; + if (!isAtomCommandInterrupted(result)) + setError("The provider sign-in terminal is no longer available."); + break; + } + } + })() + .catch(() => { + terminalQueue.current = []; + setError("Could not send input to the provider sign-in terminal."); + }) + .finally(() => { + terminalSending.current = false; + }); + }} + /> +
+
+ ) : null} + {interaction?.type === "credentials" ? ( +
{ + event.preventDefault(); + void send({ type: "credentials", values }).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + > + {interaction.fields.map((field) => ( + + ))} + +
+ ) : null} + {url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? ( +
{ + event.preventDefault(); + if (!auth?.flowId || !values.callback?.trim()) return; + void run(() => + complete({ + environmentId, + input: { instanceId, flowId: auth.flowId!, callbackUrl: values.callback! }, + }), + ).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + > + + +
+ ) : null} + {error || query.error ? ( +

+ {error ?? query.error} +

+ ) : null} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ff0546f5012d..d8a3b5fec4b9 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -7,6 +7,7 @@ import { ArrowUpCircleIcon, CopyIcon, DownloadIcon, + ExternalLinkIcon, LockIcon, LockOpenIcon, PlusIcon, @@ -19,6 +20,7 @@ import { useEffect, useRef, useState, type ReactElement, type ReactNode } from " import { isProviderDriverKind, resolveProviderInstanceEnabled, + type AcpRegistryUrlAuthAction, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type ProviderInstanceId, @@ -128,6 +130,12 @@ function readConfigCustomModels(config: unknown): ReadonlyArray).customModels); } +function readConfigString(config: unknown, key: string): string | undefined { + if (config === null || typeof config !== "object") return undefined; + const value = (config as Record)[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + /** * Set `key` to an arbitrary value on the opaque config blob. Unlike * provider settings field updates, does not drop empty-looking values — the @@ -396,6 +404,8 @@ interface ProviderInstanceCardProps { readonly onRunUpdate?: (() => void) | undefined; readonly onInstallRecommended?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; + /** Opens an ACP agent's sign-in page after the user consents to it. */ + readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; } /** @@ -439,6 +449,7 @@ export function ProviderInstanceCard({ onRunUpdate, onInstallRecommended, isUpdating = false, + onAcceptUrlAuth, }: ProviderInstanceCardProps) { const enabled = resolveProviderInstanceEnabled(instance); const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; @@ -471,6 +482,7 @@ export function ProviderInstanceCard({ const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; const FallbackIconComponent = driverOption?.icon; + const urlAuthAction = liveProvider?.auth.action; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); @@ -567,6 +579,10 @@ export function ProviderInstanceCard({ driverKind={driverKind} displayName={displayName} accentColor={accentColor} + acpRegistryAgentId={readConfigString(instance.config, "agentId")} + acpRegistryIconUrl={ + liveProvider?.iconUrl ?? readConfigString(instance.config, "registryIconUrl") + } showBadge={Boolean(accentColor)} className="size-5" iconClassName="size-4 text-foreground/80" @@ -866,14 +882,39 @@ export function ProviderInstanceCard({ -
- {editorStatusNode} -
- + <> + +
+ {editorStatusNode} +
+
+ {urlAuthAction && onAcceptUrlAuth ? ( +
+

{urlAuthAction.message}

+ {urlAuthAction.url} + +
+ ) : null} + } control={
{ - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + const deleteProviderInstance = async (row: InstanceRow) => { + const providerInstances = withoutProviderInstanceKey( + settings.providerInstances, + row.instanceId, + ); + const agentId = acpRegistryAgentId(row.instance); + if (agentId === null) { + updateSettings({ providerInstances }); + return; + } + const updateResult = await persistSettings({ + environmentId, + input: { patch: { providerInstances } }, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + const error = squashAtomCommandFailure(updateResult); + toastManager.add({ + type: "error", + title: "Could not delete provider instance", + description: error instanceof Error ? error.message : "The settings update failed.", + }); + } + return; + } + // The server decides from its latest settings whether this was the last + // instance using the agent, so two removals cannot both skip the cleanup. + const uninstallResult = await uninstallAcpRegistryManagedBinary({ + environmentId, + input: { agentId }, + }); + if (uninstallResult._tag === "Failure" && !isAtomCommandInterrupted(uninstallResult)) { + const error = squashAtomCommandFailure(uninstallResult); + toastManager.add({ + type: "warning", + title: "Provider deleted, but managed files remain", + description: error instanceof Error ? error.message : "Managed binary cleanup failed.", + }); + } + }; + + const acceptUrlAuthentication = ( + instanceId: ProviderInstanceId, + action: AcpRegistryUrlAuthAction, + ) => { + void acceptAcpRegistryUrlAuth({ + environmentId, + input: { instanceId, elicitationId: action.elicitationId }, + }).then((result) => { + if (result._tag === "Success" && !result.value.accepted) { + toastManager.add({ + type: "warning", + title: "Authentication request expired", + description: "Refresh the provider and start the authentication flow again.", + }); + return; + } + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not continue authentication", + description: + error instanceof Error ? error.message : "The authentication request expired.", + }); + } }); }; @@ -948,8 +1035,23 @@ export function EnvironmentProviderSettings({ readOnly={readOnly} onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })} /> + ) : mode === "editor" && + !readOnly && + row.driver === "acpRegistry" && + liveProvider?.installed ? ( + ) : null } + onAcceptUrlAuth={ + readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action) + } onUpdate={(next) => { const wasEnabled = resolveProviderInstanceEnabled(row.instance); const isDisabling = next.enabled === false && wasEnabled; @@ -966,9 +1068,7 @@ export function EnvironmentProviderSettings({ ); }} onDelete={ - mode === "editor" && !row.isDefault - ? () => deleteProviderInstance(row.instanceId) - : undefined + mode === "editor" && !row.isDefault ? () => void deleteProviderInstance(row) : undefined } headerAction={ mode === "editor" && row.isDefault && row.isDirty ? ( @@ -1205,6 +1305,7 @@ export function EnvironmentProviderSettings({ environmentId={environmentId} environmentLabel={environmentLabel} onOpenChange={setIsAddInstanceDialogOpen} + onCreated={setSelectedInstanceId} /> ) : null} diff --git a/apps/web/src/components/settings/ProviderWizardAuthenticationStep.tsx b/apps/web/src/components/settings/ProviderWizardAuthenticationStep.tsx new file mode 100644 index 000000000000..d56cbe9f1ad2 --- /dev/null +++ b/apps/web/src/components/settings/ProviderWizardAuthenticationStep.tsx @@ -0,0 +1,159 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import type { ReactNode } from "react"; + +import { useEnvironmentQuery } from "../../state/query"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { Button } from "../ui/button"; +import { WizardFooter, WizardPanel } from "../ui/wizard"; +import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; +import { SettingsGroup } from "./SettingsGroup"; +import { SettingsRow } from "./settingsLayout"; + +/** Sign-in uses the saved instance's environment and credentials, just like chat. */ +export function ProviderWizardAuthenticationStep({ + environmentId, + environmentLabel, + instanceId, + onFinish, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly instanceId: ProviderInstanceId; + readonly onFinish: () => void; +}) { + const providers = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + + // The server builds the instance after the settings write lands. Sign-in + // state only exists once the instance does, so wait for its snapshot. + return provider ? ( + + ) : ( + + + Sign in + + } + /> + + ); +} + +function ProviderWizardSignIn({ + environmentId, + environmentLabel, + instanceId, + provider, + onFinish, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly instanceId: ProviderInstanceId; + readonly provider: ServerProvider; + readonly onFinish: () => void; +}) { + const query = useEnvironmentQuery( + serverEnvironment.providerAuthState({ environmentId, input: { instanceId } }), + ); + const auth = query.data; + const active = + auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying"; + const signedIn = + provider.auth.status === "authenticated" || + (provider.auth.status === "unknown" && auth?.phase === "succeeded"); + const isDiscovering = !signedIn && !query.error && auth?.methods === undefined; + const canAuthenticate = (auth?.methods?.length ?? 0) > 0; + + return ( + + {canAuthenticate || signedIn ? ( + + ) : ( + + Sign in + + ) : provider.setup?.documentationUrl ? ( + + ) : undefined + } + /> + )} + + ); +} + +function WizardSignInLayout({ + signedIn, + active, + onFinish, + children, +}: { + readonly signedIn: boolean; + readonly active: boolean; + readonly onFinish: () => void; + readonly children: ReactNode; +}) { + return ( + <> + +
+ {children} +
+
+ + + + + ); +} diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..545f5110ed81 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AcpRegistrySettings, AntigravitySettings, ClaudeSettings, CodexSettings, @@ -9,6 +10,7 @@ import { } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; import { + ACPRegistryIcon, AntigravityIcon, ClaudeAI, CursorIcon, @@ -82,6 +84,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: AntigravityIcon, settingsSchema: AntigravitySettings, }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + icon: ACPRegistryIcon, + badgeLabel: "Early Access", + settingsSchema: AcpRegistrySettings, + }, ]; const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 88ea8a257240..cdbb8088400b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -53,6 +53,10 @@ export interface ProviderInstanceEntry { readonly driverKind: ProviderDriverKind; readonly displayName: string; readonly accentColor?: string | undefined; + /** Registry identity used to resolve the official icon for generic ACP instances. */ + readonly acpRegistryAgentId?: string | undefined; + /** Catalog-advertised icon URL. The renderer still applies the official-CDN allowlist. */ + readonly acpRegistryIconUrl?: string | undefined; readonly continuationGroupKey?: string | undefined; readonly enabled: boolean; readonly installed: boolean; @@ -104,6 +108,9 @@ export function deriveProviderInstanceEntries( driverKind, displayName: resolveProviderInstanceDisplayName(snapshot), accentColor: normalizeProviderAccentColor(snapshot.accentColor), + ...(driverKind === "acpRegistry" && snapshot.iconUrl + ? { acpRegistryIconUrl: snapshot.iconUrl } + : {}), continuationGroupKey: snapshot.continuation?.groupKey, enabled: snapshot.enabled, installed: snapshot.installed, @@ -174,7 +181,25 @@ export function applyProviderInstanceSettings( : entry.isDefault && legacyProvider ? (legacyProvider.enabled ?? entry.enabled) : false; - return enabled === entry.enabled ? entry : { ...entry, enabled }; + if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { + return enabled === entry.enabled ? entry : { ...entry, enabled }; + } + const config = + explicitInstance.config !== null && typeof explicitInstance.config === "object" + ? (explicitInstance.config as Readonly>) + : null; + const agentId = config?.agentId; + const iconUrl = config?.registryIconUrl; + return { + ...entry, + enabled, + ...(typeof agentId === "string" && agentId.trim() + ? { acpRegistryAgentId: agentId.trim() } + : {}), + ...(typeof iconUrl === "string" && iconUrl.trim() + ? { acpRegistryIconUrl: iconUrl.trim() } + : {}), + }; }); } diff --git a/docs/README.md b/docs/README.md index 09c6269ed589..c7a1c84b1eed 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [ACP Registry](./user/providers-acp.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index 53ad5fbbed8d..6961e87562ef 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -144,8 +144,8 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and -[Antigravity](./providers-antigravity.md). +[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), +[Antigravity](./providers-antigravity.md), and [ACP Registry](./providers-acp.md) agents. ## Next steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 24893d9dd7c5..d0866466056b 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -32,4 +32,7 @@ still require approval. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. +ACP Registry agents run their own tools in their own mode; T3 Code answers their approval requests +by the permission mode. See [ACP Registry permissions](./providers-acp.md#permissions). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md new file mode 100644 index 000000000000..c45c71b71395 --- /dev/null +++ b/docs/user/providers-acp.md @@ -0,0 +1,69 @@ +# ACP Registry + +T3 Code can run coding agents from the official +[ACP Registry](https://agentclientprotocol.com/get-started/registry), such as Devin, Kimi CLI, +and Gemini CLI. Registry agents bring their own models, tools, and sign-in, while T3 Code provides +projects, threads, checkpoints, and its MCP tools. + +## Add an agent + +1. Open **Settings → Providers**. +2. Select **Add provider** and search the ACP Registry below the driver list. +3. Select **Add** on the agent's result. +4. Confirm the name and instance ID, then complete the agent's sign-in step. + +Search only shows agents that can run on the connected server. Registry agents are third-party +code; review an agent's source and license before adding it. To add an agent by its registry ID, +select **Enter manually**. + +## Where agents run + +Registry agents always run on the machine that hosts your T3 Code server. That stays true when you +connect through `app.t3.codes`, T3 Connect, or a relay. + +Agents install under `tools///` inside T3 home. T3 Code verifies SHA-256 when +the Registry entry provides one; entries without a checksum rely on the Registry's HTTPS download. +Registry `npx` and `uvx` packages install into T3-owned npm prefixes and Python tool directories at +the exact version the Registry publishes. Removing an agent's last provider instance removes the +T3-managed binary files but keeps package installs. + +## Signing in + +Open the agent's account section in **Settings → Providers** on web or desktop and choose +**Sign in**. If the agent offers several methods, select one first. + +Browser sign-in shows the agent's URL and waits for you to open or copy it before telling the +agent to proceed. The page opens on your device, while the agent runs on the environment. +Terminal methods run in an in-app terminal on that environment. T3 Code reconnects after the +terminal login and waits for the agent to confirm sign-in before reporting success. + +Sign-in leaves credentials in the agent's own store. Agents that use API keys read them from +environment variables in the instance's environment settings. If the agent supports logout, +choose **Sign out**. Signing out stops running threads of the same agent on that environment. + +## Models and options + +The model picker lists the models the agent reports. Other settings the agent offers, such as +reasoning effort or its own mode picker, appear in the composer's model options menu. Agents +with a plan or architect mode follow T3 Code's Plan toggle. Slash commands the agent provides +appear in the `/` menu, and commands it names with a `$` prefix appear in the `$` skill menu. + +## Permissions + +Registry agents read files, edit, and run commands themselves, under their own sandbox and +approval rules. When an agent asks for approval, T3 Code answers by the thread's +[permission mode](./permission-modes.md): **Supervised** shows the request in the conversation, +**Auto-accept edits** approves edits and shows the rest, and **Auto** and **Full access** approve +automatically. File reads and searches never wait for approval. + +Devin runs its commands through T3 Code's terminals, and those commands follow the thread's +permission mode. + +## Limits + +- ACP agents cannot rewind their conversation. Reverting a thread or editing and resubmitting an + earlier turn is unavailable. Continue with a follow-up message or start a new thread. +- Registry instances are not used for thread titles, commit messages, branch names, or pull + request descriptions. Configure another provider for those. +- **Executable override** in the instance settings runs an existing local executable instead of + the managed distribution. **Authentication method** picks a specific sign-in method. diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts index 732cd8c0506b..ca112f46c2bf 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.test.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.test.ts @@ -10,6 +10,7 @@ import { const codex = ProviderDriverKind.make("codex"); const claude = ProviderDriverKind.make("claudeAgent"); +const acpRegistry = ProviderDriverKind.make("acpRegistry"); describe("resolveProviderInstanceDisplayName", () => { it("keeps a snapshot name that differs from the brand label", () => { @@ -89,6 +90,33 @@ describe("normalizeProviderAccentColor", () => { }); describe("shouldShowInstanceBadge", () => { + it("hides badges for distinct ACP agents sharing the registry driver", () => { + const mistral = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe" }; + const devin = { driverKind: acpRegistry, acpRegistryAgentId: "devin" }; + expect(shouldShowInstanceBadge(mistral, [mistral, devin])).toBe(false); + expect(shouldShowInstanceBadge(devin, [mistral, devin])).toBe(false); + }); + + it("shows badges for multiple instances of the same ACP agent", () => { + const first = { driverKind: acpRegistry, acpRegistryAgentId: "mistral-vibe" }; + const second = { ...first }; + expect(shouldShowInstanceBadge(first, [first, second])).toBe(true); + }); + + it("keeps an explicit accent on a single ACP agent", () => { + const entry = { + driverKind: acpRegistry, + acpRegistryAgentId: "mistral-vibe", + accentColor: "#ff8800", + }; + expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); + }); + + it("distinguishes registry instances whose agent identity is unavailable", () => { + const entry = { driverKind: acpRegistry }; + expect(shouldShowInstanceBadge(entry, [entry, { ...entry }])).toBe(true); + }); + it("shows the badge when the entry has an accent color", () => { const entry = { driverKind: codex, accentColor: "#ff8800" }; expect(shouldShowInstanceBadge(entry, [entry])).toBe(true); diff --git a/packages/client-runtime/src/state/providerInstanceDisplay.ts b/packages/client-runtime/src/state/providerInstanceDisplay.ts index 6d7a0629b13a..a737f40aa393 100644 --- a/packages/client-runtime/src/state/providerInstanceDisplay.ts +++ b/packages/client-runtime/src/state/providerInstanceDisplay.ts @@ -72,17 +72,32 @@ export function normalizeProviderAccentColor(value: string | undefined): string /** * Whether an instance's icon carries the account badge: accent color set, or - * several instances sharing a driver so the brand glyph alone is ambiguous. + * several instances sharing a provider so the brand glyph alone is ambiguous. + * ACP agents have distinct glyphs even though they share the registry driver. * Shared by the composer trigger, the picker rail, and sidebar/thread rows. */ export function shouldShowInstanceBadge( - entry: { readonly driverKind: ProviderDriverKind; readonly accentColor?: string | undefined }, - entries: Iterable<{ readonly driverKind: ProviderDriverKind }>, + entry: { + readonly driverKind: ProviderDriverKind; + readonly accentColor?: string | undefined; + readonly acpRegistryAgentId?: string | undefined; + }, + entries: Iterable<{ + readonly driverKind: ProviderDriverKind; + readonly acpRegistryAgentId?: string | undefined; + }>, ): boolean { if (entry.accentColor) return true; - let sharedDriverCount = 0; + let sharedProviderCount = 0; for (const candidate of entries) { - if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + if (candidate.driverKind !== entry.driverKind) continue; + if ( + entry.driverKind === "acpRegistry" && + candidate.acpRegistryAgentId !== entry.acpRegistryAgentId + ) { + continue; + } + if (++sharedProviderCount > 1) return true; } return false; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5919c0af263a..307985b99572 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1060,6 +1060,14 @@ export function createServerEnvironmentAtoms( staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId), }), + searchAcpRegistry: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:acp-registry:search", + tag: WS_METHODS.serverSearchAcpRegistry, + // Each submitted search refreshes the server-side registry. Dropping an + // abandoned query immediately also interrupts stale in-flight requests. + staleTimeMs: 0, + idleTtlMs: 0, + }), configProjection, welcome, consumeResetCredit: createEnvironmentRpcCommand(runtime, { @@ -1110,6 +1118,31 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + prepareAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:acp-registry:prepare", + tag: WS_METHODS.serverPrepareAcpRegistryAgent, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.agentId}`, + }, + }), + uninstallAcpRegistryManagedBinary: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:acp-registry:uninstall-managed-binary", + tag: WS_METHODS.serverUninstallAcpRegistryManagedBinary, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.agentId}`, + }, + }), + acceptAcpRegistryUrlAuth: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:acp-registry:accept-url-auth", + tag: WS_METHODS.serverAcceptAcpRegistryUrlAuth, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + `${environmentId}:${input.instanceId}:${input.elicitationId}`, + }, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/contracts/src/acpRegistry.test.ts b/packages/contracts/src/acpRegistry.test.ts new file mode 100644 index 000000000000..8f87d41a9b06 --- /dev/null +++ b/packages/contracts/src/acpRegistry.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + AcpRegistryImportSessionInput, + AcpRegistryManagedBinaryUninstallInput, + AcpRegistryManagedBinaryUninstallResult, + AcpRegistryOperationError, + AcpRegistryPrepareResult, + AcpRegistryProbeResult, + AcpRegistrySearchAgent, + AcpRegistrySearchInput, + officialAcpRegistryIconUrlForAgentId, + resolveOfficialAcpRegistryIconUrl, +} from "./acpRegistry.ts"; + +const decodeSearchInput = Schema.decodeUnknownSync(AcpRegistrySearchInput); +const decodeImportSessionInput = Schema.decodeUnknownSync(AcpRegistryImportSessionInput); +const decodePrepareResult = Schema.decodeUnknownSync(AcpRegistryPrepareResult); +const decodeSearchAgent = Schema.decodeUnknownSync(AcpRegistrySearchAgent); +const decodeUninstallInput = Schema.decodeUnknownSync(AcpRegistryManagedBinaryUninstallInput); +const decodeUninstallResult = Schema.decodeUnknownSync(AcpRegistryManagedBinaryUninstallResult); +const decodeProbeResult = Schema.decodeUnknownSync(AcpRegistryProbeResult); +const decodeOperationError = Schema.decodeUnknownSync(AcpRegistryOperationError); + +describe("ACP Registry contracts", () => { + it("accepts only official Registry icon URLs and safe agent IDs", () => { + expect(officialAcpRegistryIconUrlForAgentId("antigravity-acp")).toBe( + "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg", + ); + expect(officialAcpRegistryIconUrlForAgentId("../antigravity-acp")).toBeNull(); + expect( + resolveOfficialAcpRegistryIconUrl( + "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg", + ), + ).toBe("https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg"); + expect( + resolveOfficialAcpRegistryIconUrl( + "https://cdn.agentclientprotocol.com.evil/registry/v1/latest/pi-acp.svg", + ), + ).toBeNull(); + expect( + resolveOfficialAcpRegistryIconUrl( + "https://user@cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg", + ), + ).toBeNull(); + }); + + it("decodes bounded search and prepare payloads", () => { + expect(decodeSearchInput({ query: " codex " })).toEqual({ + query: "codex", + }); + expect( + decodePrepareResult({ + agentId: "codex-acp", + version: "1.2.0", + distribution: "npx", + prepared: true, + }), + ).toMatchObject({ agentId: "codex-acp", distribution: "npx", prepared: true }); + }); + + it("rejects oversized queries and result metadata", () => { + expect(() => decodeSearchInput({ query: "x".repeat(121) })).toThrow(); + expect(() => + decodeSearchAgent({ + id: "agent", + name: "Agent", + version: "1.0.0", + description: "x".repeat(1_025), + authors: [], + license: null, + website: null, + repository: null, + icon: null, + distribution: "binary", + integrity: "sha256", + }), + ).toThrow(); + }); + + it("decodes managed binary uninstall payloads and rejects unsafe agent IDs", () => { + expect(decodeUninstallInput({ agentId: " example-agent " })).toEqual({ + agentId: "example-agent", + }); + expect(decodeUninstallResult({ agentId: "example-agent", removed: false })).toEqual({ + agentId: "example-agent", + removed: false, + }); + expect(() => decodeUninstallInput({ agentId: "../example-agent" })).toThrow(); + }); + + it("decodes bounded native session import metadata", () => { + expect( + decodeImportSessionInput({ + instanceId: "acpRegistry_example", + projectId: "project-1", + sessionId: "native-session-1", + title: " Native session ", + updatedAt: " 2026-08-23T00:00:00Z ", + }), + ).toEqual({ + instanceId: "acpRegistry_example", + projectId: "project-1", + sessionId: "native-session-1", + title: "Native session", + updatedAt: "2026-08-23T00:00:00Z", + }); + expect(() => + decodeImportSessionInput({ + instanceId: "acpRegistry_example", + projectId: "project-1", + sessionId: "native-session-1", + title: "x".repeat(1_025), + }), + ).toThrow(); + }); + + it("decodes valid probe metadata and rejects oversized or invalid payloads", () => { + const authMethod = { + id: "oauth", + name: "Browser login", + description: null, + type: "agent", + }; + expect( + decodeProbeResult({ + instanceId: "acpRegistry_example", + ready: true, + icon: null, + authMethods: [authMethod], + models: [{ id: "default", name: "Default", description: "Agent-selected model" }], + currentModelId: "default", + configOptions: [], + }), + ).toMatchObject({ ready: true, currentModelId: "default" }); + expect(() => + decodeProbeResult({ + instanceId: "acpRegistry_example", + ready: true, + icon: null, + authMethods: Array.from({ length: 33 }, () => authMethod), + models: [], + currentModelId: null, + }), + ).toThrow(); + expect(() => + decodeProbeResult({ + instanceId: "acpRegistry_example", + ready: true, + icon: null, + authMethods: [{ ...authMethod, type: "passive" }], + models: [], + currentModelId: null, + }), + ).toThrow(); + expect(() => + decodeProbeResult({ + instanceId: "acpRegistry_example", + ready: true, + icon: null, + authMethods: [], + models: Array.from({ length: 257 }, () => ({ + id: "default", + name: "Default", + description: null, + })), + currentModelId: null, + }), + ).toThrow(); + expect(() => + decodeOperationError({ + _tag: "AcpRegistryOperationError", + reason: "authentication_failed", + message: "Authentication required.", + authMethods: Array.from({ length: 33 }, () => authMethod), + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts new file mode 100644 index 000000000000..1c88e0a87666 --- /dev/null +++ b/packages/contracts/src/acpRegistry.ts @@ -0,0 +1,347 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { ProjectId, ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProviderOptionDescriptor } from "./model.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const AcpRegistryAgentId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/), +); + +const ACP_REGISTRY_CDN_HOSTNAME = "cdn.agentclientprotocol.com"; +const ACP_REGISTRY_AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +/** Derives the official Registry icon URL without trusting stored URL metadata. */ +export function officialAcpRegistryIconUrlForAgentId( + agentId: string | null | undefined, +): string | null { + if (agentId === null || agentId === undefined || !ACP_REGISTRY_AGENT_ID_PATTERN.test(agentId)) { + return null; + } + return `https://${ACP_REGISTRY_CDN_HOSTNAME}/registry/v1/latest/${agentId}.svg`; +} + +/** Allows provider icons only from the credential-free official Registry CDN origin. */ +export function resolveOfficialAcpRegistryIconUrl(icon: string | null | undefined): string | null { + if (!icon) return null; + try { + const url = new URL(icon); + if ( + url.protocol !== "https:" || + url.hostname !== ACP_REGISTRY_CDN_HOSTNAME || + url.port !== "" || + url.username !== "" || + url.password !== "" + ) { + return null; + } + return url.href; + } catch { + return null; + } +} + +export const AcpRegistryDistribution = Schema.Literals(["binary", "npx", "uvx"]); +export type AcpRegistryDistribution = typeof AcpRegistryDistribution.Type; + +export const AcpRegistryIntegrity = Schema.Literals(["sha256", "registry"]); +export type AcpRegistryIntegrity = typeof AcpRegistryIntegrity.Type; + +export const AcpRegistrySearchInput = Schema.Struct({ + query: TrimmedString.check(Schema.isMaxLength(120)), +}); +export type AcpRegistrySearchInput = typeof AcpRegistrySearchInput.Type; + +const AcpRegistryUrl = Schema.String.check(Schema.isMaxLength(2_048)); + +export const AcpRegistrySearchAgent = Schema.Struct({ + id: AcpRegistryAgentId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(160)), + version: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + description: Schema.String.check(Schema.isMaxLength(1_024)), + authors: Schema.Array(Schema.String.check(Schema.isMaxLength(256))).check(Schema.isMaxLength(16)), + license: Schema.NullOr(Schema.String.check(Schema.isMaxLength(128))), + website: Schema.NullOr(AcpRegistryUrl), + repository: Schema.NullOr(AcpRegistryUrl), + icon: Schema.NullOr(AcpRegistryUrl), + distribution: AcpRegistryDistribution, + integrity: AcpRegistryIntegrity, +}); +export type AcpRegistrySearchAgent = typeof AcpRegistrySearchAgent.Type; + +export const AcpRegistrySearchResult = Schema.Struct({ + agents: Schema.Array(AcpRegistrySearchAgent).check(Schema.isMaxLength(20)), +}); +export type AcpRegistrySearchResult = typeof AcpRegistrySearchResult.Type; + +export const AcpRegistryPrepareInput = Schema.Struct({ + agentId: AcpRegistryAgentId, +}); +export type AcpRegistryPrepareInput = typeof AcpRegistryPrepareInput.Type; + +export const AcpRegistryPrepareResult = Schema.Struct({ + agentId: AcpRegistryAgentId, + version: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + distribution: AcpRegistryDistribution, + prepared: Schema.Literal(true), +}); +export type AcpRegistryPrepareResult = typeof AcpRegistryPrepareResult.Type; + +export const AcpRegistryManagedBinaryUninstallInput = Schema.Struct({ + agentId: AcpRegistryAgentId, +}); +export type AcpRegistryManagedBinaryUninstallInput = + typeof AcpRegistryManagedBinaryUninstallInput.Type; + +export const AcpRegistryManagedBinaryUninstallResult = Schema.Struct({ + agentId: AcpRegistryAgentId, + removed: Schema.Boolean, +}); +export type AcpRegistryManagedBinaryUninstallResult = + typeof AcpRegistryManagedBinaryUninstallResult.Type; + +const AcpRegistryProbeText = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); +const AcpRegistryProbeDescription = Schema.NullOr(Schema.String.check(Schema.isMaxLength(1_024))); + +export const AcpRegistryProbeAuthMethod = Schema.Struct({ + id: AcpRegistryProbeText, + name: AcpRegistryProbeText, + description: AcpRegistryProbeDescription, + type: Schema.Literals(["agent", "env_var", "terminal"]), + // Terminal methods: the full command line the user runs in a thread + // terminal on the owning environment to complete interactive auth. + command: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2_048))), + // Env-var methods: variable names the user sets on the provider instance. + envVarNames: Schema.optionalKey(Schema.Array(AcpRegistryProbeText).check(Schema.isMaxLength(16))), + // Env-var methods may advertise where credentials can be created. + link: Schema.optionalKey(AcpRegistryUrl), +}); +export type AcpRegistryProbeAuthMethod = typeof AcpRegistryProbeAuthMethod.Type; + +export const AcpRegistryUrlAuthAction = Schema.Struct({ + elicitationId: AcpRegistryProbeText, + url: AcpRegistryUrl, + message: Schema.String.check(Schema.isMaxLength(1_024)), + // Optional for mixed-version clients. New servers include both timestamps + // so a pending browser login remains understandable on another device. + createdAt: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), + expiresAt: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), +}); +export type AcpRegistryUrlAuthAction = typeof AcpRegistryUrlAuthAction.Type; + +export const AcpRegistryAcceptUrlAuthInput = Schema.Struct({ + instanceId: ProviderInstanceId, + elicitationId: AcpRegistryProbeText, +}); +export type AcpRegistryAcceptUrlAuthInput = typeof AcpRegistryAcceptUrlAuthInput.Type; + +export const AcpRegistryAcceptUrlAuthResult = Schema.Struct({ + accepted: Schema.Boolean, +}); +export type AcpRegistryAcceptUrlAuthResult = typeof AcpRegistryAcceptUrlAuthResult.Type; + +const AcpRegistrySessionId = TrimmedNonEmptyString.check(Schema.isMaxLength(1_024)); +const AcpRegistrySessionPath = TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)); + +export const AcpRegistrySession = Schema.Struct({ + sessionId: AcpRegistrySessionId, + cwd: AcpRegistrySessionPath, + additionalDirectories: Schema.Array(AcpRegistrySessionPath).check(Schema.isMaxLength(32)), + title: Schema.NullOr(Schema.String.check(Schema.isMaxLength(1_024))), + updatedAt: Schema.NullOr(Schema.String.check(Schema.isMaxLength(128))), + importedThreadId: Schema.NullOr(ThreadId), +}); +export type AcpRegistrySession = typeof AcpRegistrySession.Type; + +export const AcpRegistryListSessionsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + cursor: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2_048))), +}); +export type AcpRegistryListSessionsInput = typeof AcpRegistryListSessionsInput.Type; + +export const AcpRegistryListSessionsResult = Schema.Struct({ + sessions: Schema.Array(AcpRegistrySession).check(Schema.isMaxLength(256)), + nextCursor: Schema.NullOr(Schema.String.check(Schema.isMaxLength(2_048))), + canLoad: Schema.Boolean, + canResume: Schema.Boolean, + canDelete: Schema.Boolean, +}); +export type AcpRegistryListSessionsResult = typeof AcpRegistryListSessionsResult.Type; + +export const AcpRegistryImportSessionInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + sessionId: AcpRegistrySessionId, + title: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString.check(Schema.isMaxLength(1_024)))), + updatedAt: Schema.optionalKey( + Schema.NullOr(TrimmedNonEmptyString.check(Schema.isMaxLength(128))), + ), +}); +export type AcpRegistryImportSessionInput = typeof AcpRegistryImportSessionInput.Type; + +export const AcpRegistryImportSessionResult = Schema.Struct({ + threadId: ThreadId, + imported: Schema.Boolean, +}); +export type AcpRegistryImportSessionResult = typeof AcpRegistryImportSessionResult.Type; + +export const AcpRegistryDeleteSessionInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + sessionId: AcpRegistrySessionId, +}); +export type AcpRegistryDeleteSessionInput = typeof AcpRegistryDeleteSessionInput.Type; + +export const AcpRegistryDeleteSessionResult = Schema.Struct({ deleted: Schema.Literal(true) }); +export type AcpRegistryDeleteSessionResult = typeof AcpRegistryDeleteSessionResult.Type; + +const AcpRegistryProviderId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); +const AcpRegistryProviderProtocol = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + +export const AcpRegistryConfigurableProvider = Schema.Struct({ + providerId: AcpRegistryProviderId, + supported: Schema.Array(AcpRegistryProviderProtocol).check(Schema.isMaxLength(16)), + required: Schema.Boolean, + current: Schema.NullOr( + Schema.Struct({ + apiType: AcpRegistryProviderProtocol, + baseUrl: AcpRegistryUrl, + }), + ), +}); +export type AcpRegistryConfigurableProvider = typeof AcpRegistryConfigurableProvider.Type; + +export const AcpRegistryListProvidersInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, +}); +export type AcpRegistryListProvidersInput = typeof AcpRegistryListProvidersInput.Type; + +export const AcpRegistryListProvidersResult = Schema.Struct({ + providers: Schema.Array(AcpRegistryConfigurableProvider).check(Schema.isMaxLength(64)), +}); +export type AcpRegistryListProvidersResult = typeof AcpRegistryListProvidersResult.Type; + +export const AcpRegistrySetProviderInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + providerId: AcpRegistryProviderId, + apiType: AcpRegistryProviderProtocol, + baseUrl: AcpRegistryUrl, + // Write-only secrets. Provider list responses intentionally cannot carry headers. + headers: Schema.optionalKey( + Schema.Record( + TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + Schema.String.check(Schema.isMaxLength(8_192)), + ), + ), +}); +export type AcpRegistrySetProviderInput = typeof AcpRegistrySetProviderInput.Type; + +export const AcpRegistrySetProviderResult = Schema.Struct({ configured: Schema.Literal(true) }); +export type AcpRegistrySetProviderResult = typeof AcpRegistrySetProviderResult.Type; + +export const AcpRegistryDisableProviderInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + providerId: AcpRegistryProviderId, +}); +export type AcpRegistryDisableProviderInput = typeof AcpRegistryDisableProviderInput.Type; + +export const AcpRegistryDisableProviderResult = Schema.Struct({ disabled: Schema.Literal(true) }); +export type AcpRegistryDisableProviderResult = typeof AcpRegistryDisableProviderResult.Type; + +export const AcpRegistryLogoutInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type AcpRegistryLogoutInput = typeof AcpRegistryLogoutInput.Type; + +export const AcpRegistryLogoutResult = Schema.Struct({ + loggedOut: Schema.Literal(true), +}); +export type AcpRegistryLogoutResult = typeof AcpRegistryLogoutResult.Type; + +export const AcpRegistryProbeModel = Schema.Struct({ + id: AcpRegistryProbeText, + name: AcpRegistryProbeText, + description: AcpRegistryProbeDescription, +}); +export type AcpRegistryProbeModel = typeof AcpRegistryProbeModel.Type; + +export const AcpRegistryProbeResult = Schema.Struct({ + instanceId: ProviderInstanceId, + // `ready` is only returned after a disposable ACP session/new probe completes. + // It does not imply that authentication was passively detected. + ready: Schema.Literal(true), + icon: Schema.NullOr(AcpRegistryUrl), + authMethods: Schema.Array(AcpRegistryProbeAuthMethod).check(Schema.isMaxLength(32)), + models: Schema.Array(AcpRegistryProbeModel).check(Schema.isMaxLength(256)), + currentModelId: Schema.NullOr(AcpRegistryProbeText), + // Non-model session config options and session modes, pre-mapped onto T3's + // provider option descriptors so model capabilities can carry them directly. + configOptions: Schema.Array(ProviderOptionDescriptor).check(Schema.isMaxLength(16)), + sessionManagement: Schema.Struct({ + canList: Schema.Boolean, + canLoad: Schema.Boolean, + canResume: Schema.Boolean, + canLogout: Schema.Boolean, + canDelete: Schema.Boolean, + canConfigureProviders: Schema.Boolean, + }).pipe( + Schema.withDecodingDefault( + Effect.succeed({ + canList: false, + canLoad: false, + canResume: false, + canLogout: false, + canDelete: false, + canConfigureProviders: false, + }), + ), + ), +}); +export type AcpRegistryProbeResult = typeof AcpRegistryProbeResult.Type; + +export const AcpRegistryOperationErrorReason = Schema.Literals([ + "agent_not_configured", + "agent_not_found", + "archive_invalid", + "authentication_failed", + "checksum_mismatch", + "download_failed", + "install_failed", + "instance_not_found", + "logout_unsupported", + "logout_failed", + "probe_failed", + "project_not_found", + "registry_unavailable", + "runner_unavailable", + "session_import_failed", + "session_delete_unsupported", + "session_delete_failed", + "session_list_unsupported", + "session_resume_unsupported", + "providers_unsupported", + "providers_list_failed", + "provider_configuration_failed", + "unsupported_distribution", + "unsupported_platform", +]); +export type AcpRegistryOperationErrorReason = typeof AcpRegistryOperationErrorReason.Type; + +export class AcpRegistryOperationError extends Schema.TaggedError()( + "AcpRegistryOperationError", + { + reason: AcpRegistryOperationErrorReason, + message: Schema.String, + authMethods: Schema.optionalKey( + Schema.Array(AcpRegistryProbeAuthMethod).check(Schema.isMaxLength(32)), + ), + authAction: Schema.optionalKey(AcpRegistryUrlAuthAction), + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e69b..d549211089da 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./assistantCitations.ts"; export * from "./composerContext.ts"; export * from "./composerContextClipboard.ts"; export * from "./background.ts"; +export * from "./acpRegistry.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 31e5f9d63305..bf654ee197af 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,6 +148,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -174,6 +175,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..c7c0c112dfce 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,17 @@ import { ProviderSetupError, ProviderSetupInput, } from "./providerSetup.ts"; +import { + AcpRegistryAcceptUrlAuthInput, + AcpRegistryAcceptUrlAuthResult, + AcpRegistryManagedBinaryUninstallInput, + AcpRegistryManagedBinaryUninstallResult, + AcpRegistryOperationError, + AcpRegistryPrepareInput, + AcpRegistryPrepareResult, + AcpRegistrySearchInput, + AcpRegistrySearchResult, +} from "./acpRegistry.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { @@ -373,6 +384,10 @@ export const WS_METHODS = { serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", + serverSearchAcpRegistry: "server.searchAcpRegistry", + serverPrepareAcpRegistryAgent: "server.prepareAcpRegistryAgent", + serverUninstallAcpRegistryManagedBinary: "server.uninstallAcpRegistryManagedBinary", + serverAcceptAcpRegistryUrlAuth: "server.acceptAcpRegistryUrlAuth", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetHostResources: "server.getHostResources", @@ -600,6 +615,33 @@ const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourc error: EnvironmentAuthorizationError, }); +const WsServerSearchAcpRegistryRpc = Rpc.make(WS_METHODS.serverSearchAcpRegistry, { + payload: AcpRegistrySearchInput, + success: AcpRegistrySearchResult, + error: Schema.Union([AcpRegistryOperationError, EnvironmentAuthorizationError]), +}); + +const WsServerPrepareAcpRegistryAgentRpc = Rpc.make(WS_METHODS.serverPrepareAcpRegistryAgent, { + payload: AcpRegistryPrepareInput, + success: AcpRegistryPrepareResult, + error: Schema.Union([AcpRegistryOperationError, EnvironmentAuthorizationError]), +}); + +const WsServerUninstallAcpRegistryManagedBinaryRpc = Rpc.make( + WS_METHODS.serverUninstallAcpRegistryManagedBinary, + { + payload: AcpRegistryManagedBinaryUninstallInput, + success: AcpRegistryManagedBinaryUninstallResult, + error: Schema.Union([AcpRegistryOperationError, EnvironmentAuthorizationError]), + }, +); + +const WsServerAcceptAcpRegistryUrlAuthRpc = Rpc.make(WS_METHODS.serverAcceptAcpRegistryUrlAuth, { + payload: AcpRegistryAcceptUrlAuthInput, + success: AcpRegistryAcceptUrlAuthResult, + error: EnvironmentAuthorizationError, +}); + const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { payload: Schema.Struct({}), success: ServerTraceDiagnosticsResult, @@ -1415,6 +1457,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, + WsServerSearchAcpRegistryRpc, + WsServerPrepareAcpRegistryAgentRpc, + WsServerUninstallAcpRegistryManagedBinaryRpc, + WsServerAcceptAcpRegistryUrlAuthRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetHostResourcesRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..df9170f91371 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { AcpRegistryUrlAuthAction } from "./acpRegistry.ts"; import { type EnvironmentMachineKind, ExecutionEnvironmentDescriptor, @@ -63,6 +64,8 @@ export const ServerProviderAuth = Schema.Struct({ type: Schema.optional(TrimmedNonEmptyString), label: Schema.optional(TrimmedNonEmptyString), email: Schema.optional(TrimmedNonEmptyString), + action: Schema.optional(AcpRegistryUrlAuthAction), + canLogout: Schema.optional(Schema.Boolean), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; @@ -211,6 +214,9 @@ export const ServerProvider = Schema.Struct({ driver: ProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), accentColor: Schema.optional(TrimmedNonEmptyString), + // Optional visual identity supplied by the owning provider driver. Clients + // must still validate remote URLs against that driver's trusted origin. + iconUrl: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(2_048))), badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), @@ -224,6 +230,7 @@ export const ServerProvider = Schema.Struct({ Schema.Struct({ canAuthenticate: Schema.Boolean, canInstall: Schema.Boolean, + documentationUrl: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2_048))), }), ), enabled: Schema.Boolean, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..a00818b1c51d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -886,6 +886,56 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); +export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; + +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + agentId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Registry agent ID", + description: "Agent identifier from the official ACP Registry, for example 'devin'.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, + }), + ), + commandPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Executable override", + description: + "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", + providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, + }), + ), + authMethodId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Authentication method", + description: + "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", + providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, + }), + ), + distribution: AcpRegistryDistributionPreference.pipe( + Schema.withDecodingDefault(Effect.succeed("auto")), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["agentId", "commandPath", "authMethodId"], + }, +); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; + /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the From 82ff0205c58eb19d1827316570bd14471aa23617 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:06:41 -0700 Subject: [PATCH 02/16] fix(server): ACP registry approvals ask and close correctly - Supervised mode now shows MCP tool-call approvals as approvals. Before, an elicitation with no form fields was declined without asking, and one with fields became a question whose "false" answer still accepted. - A cancelled question emits user-input.resolved, so it leaves the thread. - Stale approval and question answers now report "Unknown pending ... request", which the reactor needs to close them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Layers/AcpRegistryAdapter.test.ts | 98 ++++++++++++++++- .../src/provider/Layers/AcpRegistryAdapter.ts | 104 +++++++++++++----- 2 files changed, 170 insertions(+), 32 deletions(-) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts index a77a89ff0e3f..5f0eafcef1ef 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -214,12 +214,13 @@ function acpRegistryAdapterTests( "decline", "cancel", ]); - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(opened.requestId), - "accept", - ); + const requestId = ApprovalRequestId.make(opened.requestId); + yield* adapter.respondToRequest(threadId, requestId, "accept"); yield* Fiber.join(turn); + // A second answer is stale. The reactor closes the approval only + // when the error names it as an unknown pending request. + const stale = yield* Effect.flip(adapter.respondToRequest(threadId, requestId, "accept")); + expect(stale.message).toMatch(/unknown pending approval request/i); yield* adapter.stopSession(threadId); const requests = yield* readRequestLog(requestLogPath); @@ -229,6 +230,93 @@ function acpRegistryAdapterTests( }).pipe(Effect.scoped, TestClock.withLive), ); + it.effect("asks before an MCP tool call in approval-required mode", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mcp-approval"); + const { adapter, events, interaction, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION: "1" }, + }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Use an MCP tool" }) + .pipe(Effect.forkScoped); + const opened = yield* Deferred.await(interaction); + if (opened.type !== "request.opened" || opened.requestId === undefined) { + return assert.fail(`Expected an approval, got ${opened.type}`); + } + expect(opened.payload).toMatchObject({ + requestType: "mcp_elicitation_approval", + detail: "Approve this request?", + }); + expect(opened.payload.options?.map((option) => option.decision)).toEqual([ + "accept", + "decline", + "cancel", + ]); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(opened.requestId), + "decline", + ); + yield* Fiber.join(turn); + yield* adapter.stopSession(threadId); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "request.resolved", + requestId: opened.requestId, + payload: { requestType: "mcp_elicitation_approval", decision: "decline" }, + }), + ); + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ action: "decline" }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + it.effect("closes a question the user can no longer answer", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-question-cancel"); + const { adapter, events, interaction } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_ELICITATION: "1" }, + }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Ask me first" }) + .pipe(Effect.forkScoped); + const requested = yield* Deferred.await(interaction); + if (requested.type !== "user-input.requested" || requested.requestId === undefined) { + return assert.fail(`Expected a question, got ${requested.type}`); + } + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(turn); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "user-input.resolved", + requestId: requested.requestId, + payload: { answers: {} }, + }), + ); + const stale = yield* Effect.flip( + adapter.respondToUserInput(threadId, ApprovalRequestId.make(requested.requestId), { + approved: "true", + }), + ); + expect(stale.message).toMatch(/unknown pending user-input request/i); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, TestClock.withLive), + ); + it.effect("turns a form elicitation into questions and answers with typed values", () => Effect.gen(function* () { const threadId = ThreadId.make("acp-registry-elicitation"); diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index ab0f95a575e1..2203119c15ce 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -109,12 +109,10 @@ export interface AcpRegistryAdapterOptions { readonly nativeEventLogger?: EventNdjsonLogger | undefined; } +/** An approval shown in the thread. The user may answer only with an offered choice. */ interface PendingApproval { - readonly request: NativePermission; - readonly response: Deferred.Deferred<{ - readonly decision: ProviderApprovalDecision; - readonly result: NativePermissionResponse; - }>; + readonly options: ReadonlyArray; + readonly response: Deferred.Deferred; } interface PendingQuestion { @@ -203,6 +201,25 @@ function optionIdForDecision(request: NativePermission, decision: ProviderApprov } } +/** An MCP tool approval answers one elicitation, so it has no per-session choice. */ +const MCP_TOOL_APPROVAL_OPTIONS: ReadonlyArray = [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Deny" }, + { decision: "cancel", label: "Cancel" }, +]; + +function mcpToolApprovalResponse(decision: ProviderApprovalDecision): NativeElicitationResponse { + switch (decision) { + case "accept": + case "acceptForSession": + return { action: "accept", content: {} }; + case "decline": + return { action: "decline" }; + case "cancel": + return { action: "cancel" }; + } +} + function unknownRecord(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) @@ -379,7 +396,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi context: SessionContext, ) { for (const pending of context.approvals.values()) { - yield* Deferred.succeed(pending.response, { decision: "cancel", result: CANCELLED }); + yield* Deferred.succeed(pending.response, "cancel"); } for (const pending of context.questions.values()) { yield* Deferred.succeed(pending.response, undefined); @@ -429,11 +446,9 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi const requestId = ApprovalRequestId.make(yield* randomId); const runtimeRequestId = RuntimeRequestId.make(requestId); const turnId = context.activeTurnId; - const response = yield* Deferred.make<{ - decision: ProviderApprovalDecision; - result: NativePermissionResponse; - }>(); - context.approvals.set(requestId, { request, response }); + const approvalOptions = acpRegistryApprovalOptions(request); + const response = yield* Deferred.make(); + context.approvals.set(requestId, { options: approvalOptions, response }); const parsed = parsePermissionRequest(request); const toolCall = parsed.toolCall && isDevin ? normalizeDevinToolCall(parsed.toolCall) : parsed.toolCall; @@ -455,7 +470,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi turnId, requestId: runtimeRequestId, permissionRequest, - approvalOptions: acpRegistryApprovalOptions(request), + approvalOptions, detail: permissionRequest.detail, args: request, source: "acp.jsonrpc", @@ -463,11 +478,11 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi rawPayload: request, }), ); - const answer = yield* Deferred.await(response); - if (answer.decision === "accept" || answer.decision === "acceptForSession") { + const decision = yield* Deferred.await(response); + if (decision === "accept" || decision === "acceptForSession") { context.grants.recordApproval({ kind: permissionRequest.kind === "execute" ? "command" : "file-change", - scope: answer.decision === "acceptForSession" ? "session" : "turn", + scope: decision === "acceptForSession" ? "session" : "turn", turnKey: String(turnId), }); } @@ -479,10 +494,10 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi turnId, requestId: runtimeRequestId, permissionRequest, - decision: answer.decision, + decision, }), ); - return answer.result; + return permissionResponse(optionIdForDecision(request, decision)); }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId)))); }); @@ -499,6 +514,42 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi ); if (mcpDisposition === "allow") return { action: "accept", content: {} }; if (mcpDisposition === "deny") return { action: "decline" }; + if (mcpDisposition === "ask") { + // A tool approval is a yes or no, whatever form fields the agent attaches. + const requestId = ApprovalRequestId.make(yield* randomId); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const turnId = context.activeTurnId; + const response = yield* Deferred.make(); + context.approvals.set(requestId, { options: MCP_TOOL_APPROVAL_OPTIONS, response }); + return yield* Effect.gen(function* () { + yield* emit({ + type: "request.opened", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { + requestType: "mcp_elicitation_approval", + detail: nonEmptyText(request.message, "The agent asks to run an MCP tool."), + options: MCP_TOOL_APPROVAL_OPTIONS, + args: request, + }, + raw: { source: "acp.jsonrpc", method: "elicitation/create", payload: request }, + }); + const decision = yield* Deferred.await(response); + yield* emit({ + type: "request.resolved", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + payload: { requestType: "mcp_elicitation_approval", decision }, + }); + return mcpToolApprovalResponse(decision); + }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId)))); + } if ( request.mode === "url" && "url" in request && @@ -543,7 +594,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi raw: { source: "acp.jsonrpc", method: "elicitation/create", payload: request }, }); const answers = yield* Deferred.await(response); - if (answers === undefined) return { action: "cancel" } as const; + // A cancelled question still resolves, so the thread stops offering it. yield* emit({ type: "user-input.resolved", ...(yield* stamp), @@ -551,8 +602,9 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi threadId: context.threadId, turnId, requestId: runtimeRequestId, - payload: { answers }, + payload: { answers: answers ?? {} }, }); + if (answers === undefined) return { action: "cancel" } as const; return { action: "accept", content: elicitationContent(answers, fieldTypes) } as const; }).pipe(Effect.ensuring(Effect.sync(() => context.questions.delete(requestId)))); }); @@ -1286,14 +1338,14 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi const context = yield* requireSession(threadId); const pending = context.approvals.get(requestId); if (!pending) { + // The reactor closes the approval when the detail names it as unknown. return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/request_permission", - detail: "This approval request is no longer pending.", + detail: `Unknown pending approval request: ${requestId}`, }); } - const optionId = optionIdForDecision(pending.request, decision); - if (decision !== "cancel" && optionId === undefined) { + if (!pending.options.some((option) => option.decision === decision)) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "respondToRequest", @@ -1301,10 +1353,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi "The agent did not offer this permission choice. Select one of the offered choices.", }); } - yield* Deferred.succeed(pending.response, { - decision, - result: permissionResponse(optionId), - }); + yield* Deferred.succeed(pending.response, decision); }); const respondToUserInput: AcpRegistryAdapterShape["respondToUserInput"] = ( @@ -1316,10 +1365,11 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi const context = yield* requireSession(threadId); const pending = context.questions.get(requestId); if (!pending) { + // The reactor closes the question when the detail names it as unknown. return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "elicitation/create", - detail: "This question is no longer pending.", + detail: `Unknown pending user-input request: ${requestId}`, }); } yield* Deferred.succeed(pending.response, answers); From f34b0af4b445da6ab05ebcc33a747fcd969983a6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:06:42 -0700 Subject: [PATCH 03/16] fix(server): keep managed binaries another instance still uses The reference check compared the raw stored agent id, but the form stores untrimmed input and the driver trims it. Removing one instance could then delete binaries that a second instance of the same agent still runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/ws.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5dfbcb034d87..a8fe555977c7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2675,7 +2675,9 @@ const makeWsRpcLayer = ( ) { return false; } - return (instance.config as Record).agentId === input.agentId; + // Stored config is raw form input; the driver trims it. + const agentId = (instance.config as Record).agentId; + return typeof agentId === "string" && agentId.trim() === input.agentId; }), ), Effect.mapError( From d089b8351c6ddee4a6b1c0fa88eea85ead55ca51 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:09:19 -0700 Subject: [PATCH 04/16] fix(server): cancel pending ACP URL sign-ins when a turn stops Stop answered pending approvals and questions but left a URL sign-in open. The agent kept waiting on it for up to 10 minutes after the turn ended, and the provider card kept offering "Continue authentication". Cancelling a turn now answers the sign-in with cancel, as ACP expects. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Layers/AcpRegistryAdapter.test.ts | 29 +++++++++++++++++++ .../src/provider/Layers/AcpRegistryAdapter.ts | 29 +++++++++++++++---- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts index 5f0eafcef1ef..8b82f556551f 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -353,6 +353,35 @@ function acpRegistryAdapterTests( }).pipe(Effect.scoped, TestClock.withLive), ); + it.effect("cancels a pending URL sign-in when the turn is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-url-cancel"); + const { adapter } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_URL_ELICITATION: "1" }, + }); + const coordinator = yield* AcpRegistryRuntimeCoordinator; + const opened = yield* Deferred.make(); + const closed = yield* Deferred.make(); + yield* coordinator + .watchUrlAuthAction(instanceId, (action) => + Deferred.succeed(action === null ? closed : opened, undefined), + ) + .pipe(Effect.forkScoped); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Sign in first" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(opened); + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(turn); + // The session is still open, so only the cancel can clear the sign-in. + yield* Deferred.await(closed); + assert.isTrue(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, TestClock.withLive), + ); + it.effect("approves by policy in full access without asking", () => Effect.gen(function* () { const threadId = ThreadId.make("acp-registry-full-access"); diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index 2203119c15ce..dd59b68b221b 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -143,6 +143,8 @@ interface SessionContext { readonly stopLock: Semaphore.Semaphore; readonly approvals: Map; readonly questions: Map; + /** URL sign-ins waiting on the provider card. Completing one answers the agent with cancel. */ + readonly urlAuthentications: Set>; readonly turns: Array<{ id: TurnId; items: Array }>; readonly grants: ReturnType; session: ProviderSession; @@ -401,6 +403,9 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi for (const pending of context.questions.values()) { yield* Deferred.succeed(pending.response, undefined); } + for (const cancelled of context.urlAuthentications) { + yield* Deferred.succeed(cancelled, undefined); + } }); const stopContext = (context: SessionContext) => @@ -562,12 +567,23 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi if (url === undefined || !elicitationId || Option.isNone(coordinator)) { return { action: "decline" }; } - const accepted = yield* coordinator.value.requestUrlAuthentication(instanceId, { - elicitationId: elicitationId.slice(0, 256), - url, - message: request.message.trim().slice(0, 1_024), - }); - return accepted ? { action: "accept" } : { action: "decline" }; + // ACP expects cancel for requests still open when the turn is cancelled. + const cancelled = yield* Deferred.make(); + context.urlAuthentications.add(cancelled); + return yield* Effect.raceFirst( + coordinator.value + .requestUrlAuthentication(instanceId, { + elicitationId: elicitationId.slice(0, 256), + url, + message: request.message.trim().slice(0, 1_024), + }) + .pipe( + Effect.map((accepted): NativeElicitationResponse => ({ + action: accepted ? "accept" : "decline", + })), + ), + Deferred.await(cancelled).pipe(Effect.as({ action: "cancel" })), + ).pipe(Effect.ensuring(Effect.sync(() => context.urlAuthentications.delete(cancelled)))); } // Modes beyond form and url decline rather than guess at their meaning. if (request.mode !== "form" || !("requestedSchema" in request)) return { action: "decline" }; @@ -1054,6 +1070,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi stopLock: yield* Semaphore.make(1), approvals: new Map(), questions: new Map(), + urlAuthentications: new Set(), turns: [], grants, session, From b9a19263abf7394daa75d5a4bf67688cd56ea89e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:12:36 -0700 Subject: [PATCH 05/16] fix(server): map every approval decision for MCP tool approvals Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/Layers/AcpRegistryAdapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index dd59b68b221b..c83fd8c00e95 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -214,6 +214,7 @@ function mcpToolApprovalResponse(decision: ProviderApprovalDecision): NativeElic switch (decision) { case "accept": case "acceptForSession": + case "acceptAlways": return { action: "accept", content: {} }; case "decline": return { action: "decline" }; From 4b661eadc440f3849ece0200fb9b0c9897647517 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:16:24 -0700 Subject: [PATCH 06/16] fix(server): ACP registry auto-approvals grant one call only Auto and Full access picked the agent's allow_always option first. An agent that remembers that grant would not ask again after the thread switched to Supervised. Policy approvals now pick allow_once first. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts | 7 +++++-- apps/server/src/provider/Layers/AcpRegistryAdapter.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts index 8b82f556551f..a83139ca477e 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -394,8 +394,11 @@ function acpRegistryAdapterTests( yield* adapter.stopSession(threadId); expect(events.map((event) => event.type)).not.toContain("request.opened"); - const requests = yield* readRequestLog(requestLogPath); - expect(requests.map((request) => request.result)).toContainEqual({ + // A policy approval covers one call, so the agent cannot remember it + // after the thread switches to Supervised. + const results = (yield* readRequestLog(requestLogPath)).map((request) => request.result); + expect(results).toContainEqual({ outcome: { outcome: "selected", optionId: "allow-once" } }); + expect(results).not.toContainEqual({ outcome: { outcome: "selected", optionId: "allow-always" }, }); }).pipe(Effect.scoped, TestClock.withLive), diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index c83fd8c00e95..eb824b989294 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -444,7 +444,8 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi if (context.stopped || request.sessionId !== context.nativeSessionId) return CANCELLED; const disposition = acpPermissionDisposition(policyFor(context), request); if (disposition === "allow") { - return permissionResponse(selectOptionId(request, ["allow_always", "allow_once"])); + // Prefer a one-time grant, so a later switch to Supervised still asks. + return permissionResponse(selectOptionId(request, ["allow_once", "allow_always"])); } if (disposition === "deny") { return permissionResponse(selectOptionId(request, ["reject_once", "reject_always"])); From 3268fc74056e3e33bac71f5e9b6accf65c817517 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:17:37 -0700 Subject: [PATCH 07/16] perf(server): reuse the fetched ACP Registry index for five minutes Every search, including each debounced keystroke, fetched the full registry index again. The catalog now serves all callers from the last network fetch for five minutes. A disk fallback does not count as fresh, so a failed fetch still retries on the next search. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/acp/AcpRegistrySupport.test.ts | 12 ++++++++++-- apps/server/src/provider/acp/AcpRegistrySupport.ts | 14 ++++++++++++++ packages/client-runtime/src/state/server.ts | 5 +++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index c276dcd8362a..fd324252315c 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -597,7 +597,8 @@ describe("AcpRegistrySupport", () => { version: "1.2.3", }); - expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]); + // Prepare reuses the index the search just fetched. + expect(requests).toEqual([registryUrl, archiveUrl]); }).pipe( Effect.scoped, Effect.provide( @@ -736,7 +737,7 @@ describe("AcpRegistrySupport", () => { ); }); - it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => { + it.effect("reuses a fresh registry index for five minutes and coalesces refreshes", () => { const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); let requests = 0; return Effect.gen(function* () { @@ -755,6 +756,13 @@ describe("AcpRegistrySupport", () => { ); expect(requests).toBe(1); + // Typing a query searches often; a fresh index serves every search. + yield* resolver.search({ query: "exam" }); + yield* TestClock.adjust("4 minutes"); + yield* resolver.search({ query: "ex" }); + expect(requests).toBe(1); + + yield* TestClock.adjust("1 minute"); yield* resolver.search({ query: "example" }); expect(requests).toBe(2); }).pipe( diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index b4463116ef08..e2eacd724baf 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -49,6 +49,8 @@ const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024; const MAX_SEARCH_RESULTS = 20; const REGISTRY_REQUEST_TIMEOUT = "30 seconds"; +// Search runs as the user types. A fetched index serves every caller this long. +const REGISTRY_FRESHNESS_MS = 5 * 60 * 1_000; const ARCHIVE_REQUEST_TIMEOUT = "20 minutes"; const PACKAGE_INSTALL_TIMEOUT = "20 minutes"; const PACKAGE_QUERY_TIMEOUT = "30 seconds"; @@ -620,6 +622,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const packageReceiptsDirectory = path.join(registryDirectory, "package-installs"); const registryRef = yield* Ref.make(undefined); const registryRevision = yield* Ref.make(0); + // When the network last returned the index. A disk fallback does not count. + const registryFetchedAt = yield* Ref.make(undefined); const registrySemaphore = yield* Semaphore.make(1); const installSemaphore = yield* Semaphore.make(1); const preparedBinaryReservations = yield* Ref.make>(new Map()); @@ -806,8 +810,18 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const currentRevision = yield* Ref.get(registryRevision); const current = yield* Ref.get(registryRef); if (current !== undefined && currentRevision !== observedRevision) return current; + const now = yield* Clock.currentTimeMillis; + const fetchedAt = yield* Ref.get(registryFetchedAt); + if ( + current !== undefined && + fetchedAt !== undefined && + now - fetchedAt < REGISTRY_FRESHNESS_MS + ) { + return current; + } const registry = yield* fetchRegistry().pipe( + Effect.tap(() => Ref.set(registryFetchedAt, now)), Effect.catch((networkError) => readCachedRegistry.pipe( Effect.flatMap( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 307985b99572..b8fd31f310c5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1063,8 +1063,9 @@ export function createServerEnvironmentAtoms( searchAcpRegistry: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:acp-registry:search", tag: WS_METHODS.serverSearchAcpRegistry, - // Each submitted search refreshes the server-side registry. Dropping an - // abandoned query immediately also interrupts stale in-flight requests. + // The server keeps the fetched registry fresh for a few minutes, so + // searching per keystroke is cheap. Dropping an abandoned query + // immediately also interrupts stale in-flight requests. staleTimeMs: 0, idleTtlMs: 0, }), From 861e17a51bd5f72e81248999fb1d684d711b1578 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:22:19 -0700 Subject: [PATCH 08/16] fix(server): show Plan only for ACP registry agents with a plan mode Plan showed for every registry agent. For agents without a plan or architect mode it did nothing, so the agent ran normally while the user thought it was planning. The probe and live session now report whether the agent has a plan mode, using the same rule the adapter uses to switch, and the snapshot hides Plan until one of them says so. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Drivers/AcpRegistryDriver.test.ts | 58 ++++++++++++++++++- .../src/provider/Drivers/AcpRegistryDriver.ts | 4 ++ .../src/provider/Layers/AcpRegistryAdapter.ts | 9 +-- .../src/provider/acp/AcpRegistryProbe.test.ts | 39 +++++++++++++ .../src/provider/acp/AcpRegistryProbe.ts | 44 ++++++++++++-- .../acp/AcpRegistryRuntimeCoordinator.test.ts | 2 + docs/user/providers-acp.md | 5 +- 7 files changed, 148 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts index 9462d1d5aa32..609c189866e0 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -70,6 +70,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [{ name: "stale" }], skills: [{ name: "stale-skill", path: "stale", enabled: true }], + supportsPlanMode: false, }, }); @@ -115,6 +116,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [], skills: [], + supportsPlanMode: false, }, }); expect(snapshot.auth.status).toBe("unknown"); @@ -122,7 +124,7 @@ describe("acpRegistrySnapshotReadiness", () => { expect( applyAcpRegistryLiveConfiguration( snapshot, - { models: [], currentModelId: null, configOptions: [] }, + { models: [], currentModelId: null, configOptions: [], supportsPlanMode: false }, [], ).auth.status, ).toBe("unknown"); @@ -159,6 +161,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [], skills: [], + supportsPlanMode: false, }, }); @@ -169,6 +172,7 @@ describe("acpRegistrySnapshotReadiness", () => { models: [{ id: "live-model", name: "Live model", description: null }], currentModelId: "live-model", configOptions: [], + supportsPlanMode: false, }, [], ), @@ -178,6 +182,55 @@ describe("acpRegistrySnapshotReadiness", () => { }); }); + it("shows Plan only after the agent reports a plan mode", () => { + const ready = { + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }, + } as const; + const probe = (supportsPlanMode: boolean) => ({ + probe: { + instanceId: identity.instanceId, + ready: true as const, + icon: null, + authMethods: [], + models: [], + currentModelId: null, + configOptions: [], + sessionManagement: noSessionManagement, + }, + slashCommands: [], + skills: [], + supportsPlanMode, + }); + const live = (supportsPlanMode: boolean) => ({ + models: [], + currentModelId: null, + configOptions: [], + supportsPlanMode, + }); + + // Before discovery nothing says the agent has a plan mode. + expect(buildCheckedAcpRegistrySnapshot(ready).showInteractionModeToggle).toBe(false); + const withoutPlan = buildCheckedAcpRegistrySnapshot({ ...ready, probe: probe(false) }); + expect(withoutPlan.showInteractionModeToggle).toBe(false); + expect( + buildCheckedAcpRegistrySnapshot({ ...ready, probe: probe(true) }).showInteractionModeToggle, + ).toBe(true); + // A live session's modes replace the probe's. + const withPlan = applyAcpRegistryLiveConfiguration(withoutPlan, live(true), []); + expect(withPlan.showInteractionModeToggle).toBe(true); + expect( + applyAcpRegistryLiveConfiguration(withPlan, live(false), []).showInteractionModeToggle, + ).toBe(false); + }); + it("maps registry inspection status to provider readiness", () => { expect( acpRegistrySnapshotReadiness({ @@ -244,6 +297,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }], skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], + supportsPlanMode: false, }, }); @@ -302,6 +356,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [], skills: [], + supportsPlanMode: false, }, }); @@ -381,6 +436,7 @@ describe("acpRegistrySnapshotReadiness", () => { }, slashCommands: [{ name: "review" }], skills: [], + supportsPlanMode: false, }; }), ).pipe( diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 43684782b88b..3b2b1dbd3237 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -206,6 +206,9 @@ function baseSnapshot( supportsTextGeneration: false, // ACP has no portable conversation rewind, so V1 clients hide revert. supportsConversationRollback: false, + // Plan switches the agent to its plan mode. Hide it until a probe or a + // live session reports one. + showInteractionModeToggle: input.probe?.supportsPlanMode ?? false, enabled: input.settings.enabled, installed: input.installed, version: input.version, @@ -247,6 +250,7 @@ export function applyAcpRegistryLiveConfiguration( return { ...snapshot, status: provider.enabled ? "ready" : provider.status, + showInteractionModeToggle: configuration.supportsPlanMode, models: modelsFromDiscovery(configuration, customModels), }; } diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index eb824b989294..555e6b17d318 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -73,6 +73,7 @@ import { } from "../acp/AcpCoreRuntimeEvents.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + isAcpRegistryPlanModeId, normalizeAcpRegistryCommands, normalizeAcpRegistryLiveConfiguration, normalizeAcpRegistryWebUrl, @@ -807,16 +808,12 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi option.type === "select" ? [{ id: option.id, value: option.currentValue }] : [], ), }; - const planMode = modeState?.availableModes.find( - (mode) => mode.id === "plan" || mode.id === "architect", - ); + const planMode = modeState?.availableModes.find((mode) => isAcpRegistryPlanModeId(mode.id)); if (planMode && modeState?.currentModeId !== planMode.id) { yield* runtime.setMode(planMode.id); } for (const option of planSensitive) { - const planValue = selectChoices(option).find( - (choice) => choice === "plan" || choice === "architect", - ); + const planValue = selectChoices(option).find(isAcpRegistryPlanModeId); if (option.type === "select" && planValue && option.currentValue !== planValue) { yield* runtime.setConfigOption(option.id, planValue); } diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts index 5451bab0365a..36dd052f945f 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -11,6 +11,7 @@ import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; import { @@ -18,6 +19,7 @@ import { acpRegistryProbeResult, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, + normalizeAcpRegistryLiveConfiguration, probeAcpRegistryConfiguration, } from "./AcpRegistryProbe.ts"; import { AcpRegistryCatalog } from "./AcpRegistrySupport.ts"; @@ -193,6 +195,43 @@ describe("ACP Registry probe", () => { expect(result.currentModelId).toBe("sonnet"); }); + it("reports a plan mode only when the agent can switch to one", () => { + const modeState = (ids: ReadonlyArray) => ({ + currentModeId: ids[0]!, + availableModes: ids.map((id) => ({ id, name: id })), + }); + const collaborationMode = ( + values: ReadonlyArray, + ): EffectAcpSchema.SessionConfigOption => ({ + id: "collaboration", + name: "Collaboration", + category: "collaboration_mode", + type: "select", + currentValue: values[0]!, + options: [ + { + groupId: "modes", + name: "Modes", + options: values.map((value) => ({ value, name: value })), + }, + ], + }); + + expect( + normalizeAcpRegistryLiveConfiguration([], modeState(["code", "architect"])).supportsPlanMode, + ).toBe(true); + expect( + normalizeAcpRegistryLiveConfiguration([collaborationMode(["default", "plan"])]) + .supportsPlanMode, + ).toBe(true); + expect( + normalizeAcpRegistryLiveConfiguration( + [collaborationMode(["default", "review"])], + modeState(["default", "yolo"]), + ).supportsPlanMode, + ).toBe(false); + }); + it("omits overlong opaque model ids instead of publishing mutated ids", () => { const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts index 11810ac575c1..18a7e1d47479 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -168,6 +168,34 @@ export interface AcpRegistryLiveConfiguration { readonly models: ReadonlyArray; readonly currentModelId: string | null; readonly configOptions: AcpRegistryProbeResult["configOptions"]; + /** Whether T3's Plan toggle has an agent mode to switch to. */ + readonly supportsPlanMode: boolean; +} + +/** The agent mode ids T3's Plan toggle switches to. */ +export function isAcpRegistryPlanModeId(id: string): boolean { + return id === "plan" || id === "architect"; +} + +/** + * Whether the adapter can put this agent in a plan mode: a session mode, or a + * mode or collaboration-mode choice, with a plan id. + */ +function acpRegistrySupportsPlanMode( + configOptions: ReadonlyArray, + modeState: Parameters[0]["modeState"], +): boolean { + return ( + modeState?.availableModes.some((mode) => isAcpRegistryPlanModeId(mode.id)) === true || + configOptions.some( + (option) => + option.type === "select" && + (option.category === "mode" || option.category === "collaboration_mode") && + option.options + .flatMap((entry) => ("value" in entry ? [entry] : entry.options)) + .some((choice) => isAcpRegistryPlanModeId(choice.value)), + ) + ); } /** Normalizes volatile session configuration using the same bounds as discovery probes. */ @@ -189,6 +217,7 @@ export function normalizeAcpRegistryLiveConfiguration( ? boundedCurrentModelId : null, configOptions: acpProviderOptionDescriptors({ configOptions, modeState }), + supportsPlanMode: acpRegistrySupportsPlanMode(configOptions, modeState), }; } @@ -243,10 +272,12 @@ export function acpRegistryProbeResult( icon: string | null = null, spawn?: AcpRegistryAuthSpawnContext, ): AcpRegistryProbeResult { - const liveConfiguration = normalizeAcpRegistryLiveConfiguration( - started.sessionSetupResult.configOptions ?? [], - parseSessionModeState(started.sessionSetupResult), - ); + // The plan signal is server state, not part of the wire result. + const { supportsPlanMode: _supportsPlanMode, ...liveConfiguration } = + normalizeAcpRegistryLiveConfiguration( + started.sessionSetupResult.configOptions ?? [], + parseSessionModeState(started.sessionSetupResult), + ); return AcpRegistryProbeResult.make({ instanceId, ready: true, @@ -295,6 +326,7 @@ export interface AcpRegistryConfigurationProbeResult { readonly probe: AcpRegistryProbeResult; readonly slashCommands: ReadonlyArray; readonly skills: ReadonlyArray; + readonly supportsPlanMode: boolean; } export type AcpRegistryConfigurationProbe = ( @@ -441,6 +473,10 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo ), slashCommands: result.commands.slashCommands, skills: result.commands.skills, + supportsPlanMode: acpRegistrySupportsPlanMode( + result.started.sessionSetupResult.configOptions ?? [], + parseSessionModeState(result.started.sessionSetupResult), + ), }; }, ); diff --git a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts index 39a40633e3d8..3765191a8f45 100644 --- a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts @@ -123,11 +123,13 @@ describe("AcpRegistryRuntimeCoordinator", () => { models: [{ id: "sonnet", name: "Sonnet", description: null }], currentModelId: "sonnet", configOptions: [], + supportsPlanMode: false, } satisfies AcpRegistryLiveConfiguration; const replacement = { models: [{ id: "opus", name: "Opus", description: null }], currentModelId: "opus", configOptions: [], + supportsPlanMode: false, } satisfies AcpRegistryLiveConfiguration; yield* coordinator.publishLiveConfiguration(instanceId, first); diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index c45c71b71395..1a463b1f8ab3 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -44,8 +44,9 @@ choose **Sign out**. Signing out stops running threads of the same agent on that ## Models and options The model picker lists the models the agent reports. Other settings the agent offers, such as -reasoning effort or its own mode picker, appear in the composer's model options menu. Agents -with a plan or architect mode follow T3 Code's Plan toggle. Slash commands the agent provides +reasoning effort or its own mode picker, appear in the composer's model options menu. The Plan +toggle appears only for agents with a plan or architect mode, after T3 Code has checked the agent +or started a session with it. Slash commands the agent provides appear in the `/` menu, and commands it names with a `$` prefix appear in the `$` skill menu. ## Permissions From aa19543dd2a6cf2deeed68c6b7dfe0e01877c162 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:23:17 -0700 Subject: [PATCH 09/16] refactor(server): ACP registry uses the shared auto-approval picker Use acpAutoApprovalOptionId from AcpClientPolicy instead of a local option list, and cover Auto-accept edits asking before MCP tool calls. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Layers/AcpRegistryAdapter.test.ts | 95 ++++++++++--------- .../src/provider/Layers/AcpRegistryAdapter.ts | 4 +- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts index a83139ca477e..8f8306f498fe 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -230,53 +230,56 @@ function acpRegistryAdapterTests( }).pipe(Effect.scoped, TestClock.withLive), ); - it.effect("asks before an MCP tool call in approval-required mode", () => - Effect.gen(function* () { - const threadId = ThreadId.make("acp-registry-mcp-approval"); - const { adapter, events, interaction, requestLogPath } = yield* makeHarness({ - wire, - env: { T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION: "1" }, - }); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - const turn = yield* adapter - .sendTurn({ threadId, input: "Use an MCP tool" }) - .pipe(Effect.forkScoped); - const opened = yield* Deferred.await(interaction); - if (opened.type !== "request.opened" || opened.requestId === undefined) { - return assert.fail(`Expected an approval, got ${opened.type}`); - } - expect(opened.payload).toMatchObject({ - requestType: "mcp_elicitation_approval", - detail: "Approve this request?", - }); - expect(opened.payload.options?.map((option) => option.decision)).toEqual([ - "accept", - "decline", - "cancel", - ]); - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(opened.requestId), - "decline", - ); - yield* Fiber.join(turn); - yield* adapter.stopSession(threadId); + // Auto-accept edits approves edits only, so MCP tool calls still ask. + for (const runtimeMode of ["approval-required", "auto-accept-edits"] as const) { + it.effect(`asks before an MCP tool call in ${runtimeMode} mode`, () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mcp-approval"); + const { adapter, events, interaction, requestLogPath } = yield* makeHarness({ + wire, + env: { T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION: "1" }, + }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode, + }); + const turn = yield* adapter + .sendTurn({ threadId, input: "Use an MCP tool" }) + .pipe(Effect.forkScoped); + const opened = yield* Deferred.await(interaction); + if (opened.type !== "request.opened" || opened.requestId === undefined) { + return assert.fail(`Expected an approval, got ${opened.type}`); + } + expect(opened.payload).toMatchObject({ + requestType: "mcp_elicitation_approval", + detail: "Approve this request?", + }); + expect(opened.payload.options?.map((option) => option.decision)).toEqual([ + "accept", + "decline", + "cancel", + ]); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(opened.requestId), + "decline", + ); + yield* Fiber.join(turn); + yield* adapter.stopSession(threadId); - expect(events).toContainEqual( - expect.objectContaining({ - type: "request.resolved", - requestId: opened.requestId, - payload: { requestType: "mcp_elicitation_approval", decision: "decline" }, - }), - ); - const requests = yield* readRequestLog(requestLogPath); - expect(requests.map((request) => request.result)).toContainEqual({ action: "decline" }); - }).pipe(Effect.scoped, TestClock.withLive), - ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "request.resolved", + requestId: opened.requestId, + payload: { requestType: "mcp_elicitation_approval", decision: "decline" }, + }), + ); + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ action: "decline" }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + } it.effect("closes a question the user can no longer answer", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index 555e6b17d318..76c4a4ec142f 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -52,6 +52,7 @@ import { import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { + acpAutoApprovalOptionId, acpClientExecuteDisposition, acpMcpToolApprovalElicitationDisposition, acpPermissionDisposition, @@ -445,8 +446,7 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi if (context.stopped || request.sessionId !== context.nativeSessionId) return CANCELLED; const disposition = acpPermissionDisposition(policyFor(context), request); if (disposition === "allow") { - // Prefer a one-time grant, so a later switch to Supervised still asks. - return permissionResponse(selectOptionId(request, ["allow_once", "allow_always"])); + return permissionResponse(acpAutoApprovalOptionId(request)); } if (disposition === "deny") { return permissionResponse(selectOptionId(request, ["reject_once", "reject_always"])); From 5531ead03f42510620077ca413129227c0c127b0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:07:33 -0700 Subject: [PATCH 10/16] fix(server): harden ACP registry installs and runner choice - Resolve an archive command inside its staging directory before chmod, so a link in the archive cannot change the mode of an outside file. - Look up an installed npm or uv command only in the managed bin directory, not in a same-named command elsewhere on PATH. - Refresh the install lock every minute, so another process cannot take a live lock as stale during a long download. - Keep only the last 4,000 characters of installer stderr in errors. - Auto distribution prefers a package runner the environment has, so an agent with npx and uvx recipes works on a host with only uv. - A cached index that stands in for a failed fetch counts as fresh, so offline searches do not wait on the network each time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider/acp/AcpRegistrySupport.test.ts | 155 +++++++++++++++++- .../src/provider/acp/AcpRegistrySupport.ts | 117 ++++++++----- 2 files changed, 228 insertions(+), 44 deletions(-) diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index fd324252315c..37f17c2448e1 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -252,6 +252,29 @@ describe("AcpRegistrySupport", () => { platformTarget: "darwin-aarch64", }), ).toBeUndefined(); + + const packageAgent = makeAgent({ + npx: { package: "@example/acp@1.2.3" }, + uvx: { package: "example-acp==1.2.3" }, + }); + // Auto skips a package runner the host lacks, and keeps the first recipe + // when no runner is available so its missing runner can be reported. + expect( + resolveAcpRegistryDistribution({ + agent: packageAgent, + preference: "auto", + platformTarget: "linux-x86_64", + isRunnerAvailable: (runner) => runner === "uv", + }), + ).toMatchObject({ kind: "uvx", packageName: "example-acp==1.2.3" }); + expect( + resolveAcpRegistryDistribution({ + agent: packageAgent, + preference: "auto", + platformTarget: "linux-x86_64", + isRunnerAvailable: () => false, + }), + ).toMatchObject({ kind: "npx" }); }); it.effect("resolves command overrides while preserving registry args and environment", () => { @@ -397,6 +420,10 @@ describe("AcpRegistrySupport", () => { prefix: "t3-acp-registry-global-uv-tool-", }); const toolchain = yield* makeFakeUvToolchain(cacheDir); + // A command named like the agent elsewhere on PATH is not the uv tool. + const decoy = `${cacheDir}/fake-uv/bin/example-agent`; + yield* fileSystem.writeFileString(decoy, "#!/bin/sh\n"); + yield* fileSystem.chmod(decoy, 0o755); const resolver = yield* makeAcpRegistryCatalog({ cacheDir, toolsDir: `${cacheDir}/tools`, @@ -671,6 +698,58 @@ describe("AcpRegistrySupport", () => { ); }); + it.effect("rejects an archive command that links outside without changing its target", () => { + const downloadUrl = "https://registry.test/agent.tar.gz"; + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-link-entry-" }); + const secret = `${cacheDir}/secret`; + yield* fileSystem.writeFileString(secret, "private"); + yield* fileSystem.chmod(secret, 0o600); + const source = `${cacheDir}/source`; + yield* fileSystem.makeDirectory(source); + yield* fileSystem.symlink(secret, `${source}/agent`); + const archivePath = `${cacheDir}/agent.tar.gz`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tar = yield* spawner.spawn( + ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]), + ); + expect(yield* tar.exitCode).toBe(0); + const archive = yield* fileSystem.readFile(archivePath); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(archive), + ), + ), + ), + ), + ); + + const failure = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip); + expect(failure).toMatchObject({ reason: "archive_invalid" }); + expect((yield* fileSystem.stat(secret)).mode & 0o777).toBe(0o600); + }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), + ); + }); + it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => { const exact = { ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }), @@ -1023,6 +1102,7 @@ describe("AcpRegistrySupport", () => { args: ["--stdio"], }, }); + let registryRequests = 0; return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -1043,14 +1123,20 @@ describe("AcpRegistrySupport", () => { command: toolchain.executablePath, args: ["--stdio"], }); + + // The fallback index stays fresh, so offline searches do not refetch. + yield* resolver.search({ query: "example" }); + yield* resolver.search({ query: "example" }); + expect(registryRequests).toBe(1); }).pipe( Effect.scoped, Effect.provide( - resolverLayer((request) => - Effect.succeed( + resolverLayer((request) => { + registryRequests += 1; + return Effect.succeed( HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), - ), - ), + ); + }), ), ); }); @@ -1394,6 +1480,67 @@ describe("AcpRegistrySupport", () => { }); }); + it.effect("keeps its install lock fresh while a download runs", () => { + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: archiveUrl, cmd: "./bin/example-agent" } }, + }); + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + return Effect.gen(function* () { + const downloadStarted = yield* Deferred.make(); + const releaseDownload = yield* Deferred.make(); + return yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-lock-refresh-", + }); + const touched: Array = []; + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + utimes: (path, atime, mtime) => + Effect.sync(() => touched.push(path)).pipe( + Effect.andThen(fileSystem.utimes(path, atime, mtime)), + ), + }), + ); + + const prepareFiber = yield* resolver + .prepare({ agentId: agent.id }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(downloadStarted); + expect(touched).toEqual([]); + yield* TestClock.adjust("1 minute"); + expect(touched).toEqual([`${cacheDir}/tools/${agent.id}/1.2.3/linux-x86_64.lock`]); + + yield* Deferred.succeed(releaseDownload, undefined); + expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + request.url === registryUrl + ? Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), + ) + : Deferred.succeed(downloadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseDownload)), + Effect.as( + HttpClientResponse.fromWeb( + request, + new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ), + ), + ), + ); + }); + }); + it.effect("reserves a prepared binary until a configured instance inspects it", () => { const agent = makeAgent({ binary: { diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index e2eacd724baf..4656eeacff9c 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -47,6 +47,7 @@ const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest const MAX_REGISTRY_BYTES = 1024 * 1024; const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024; +const MAX_INSTALL_ERROR_OUTPUT_CHARS = 4_000; const MAX_SEARCH_RESULTS = 20; const REGISTRY_REQUEST_TIMEOUT = "30 seconds"; // Search runs as the user types. A fetched index serves every caller this long. @@ -405,39 +406,31 @@ export function resolveAcpRegistryDistribution(input: { readonly agent: AcpRegistryAgent; readonly preference: AcpRegistryDistributionPreference; readonly platformTarget: AcpRegistryPlatformTarget | undefined; + readonly isRunnerAvailable?: (runner: "npm" | "uv") => boolean; }): ResolvedAcpRegistryDistribution | undefined { const { agent, platformTarget } = input; const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined; const candidates: ReadonlyArray = input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference]; - for (const kind of candidates) { + const resolved = candidates.flatMap((kind): ReadonlyArray => { if (kind === "binary" && binary) { - return { - kind, - args: binary.args ?? [], - env: binary.env ?? {}, - binaryTarget: binary, - }; - } - if (kind === "npx" && agent.distribution.npx) { - return { - kind, - args: agent.distribution.npx.args ?? [], - env: agent.distribution.npx.env ?? {}, - packageName: agent.distribution.npx.package, - }; - } - if (kind === "uvx" && agent.distribution.uvx) { - return { - kind, - args: agent.distribution.uvx.args ?? [], - env: agent.distribution.uvx.env ?? {}, - packageName: agent.distribution.uvx.package, - }; + return [{ kind, args: binary.args ?? [], env: binary.env ?? {}, binaryTarget: binary }]; } - } - return undefined; + const recipe = + kind === "npx" ? agent.distribution.npx : kind === "uvx" ? agent.distribution.uvx : undefined; + return recipe + ? [{ kind, args: recipe.args ?? [], env: recipe.env ?? {}, packageName: recipe.package }] + : []; + }); + // Prefer a distribution whose runner is available. Otherwise keep the first, + // so callers can still report its missing runner. + return ( + resolved.find((distribution) => { + const runner = packageManagerFor(distribution.kind); + return runner === undefined || (input.isRunnerAvailable?.(runner) ?? true); + }) ?? resolved[0] + ); } export interface ResolvedAcpRegistryAgent { @@ -513,6 +506,7 @@ export interface AcpRegistryCatalogOptions { const INSTALL_LOCK_RETRY_COUNT = 300; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +const INSTALL_LOCK_REFRESH_INTERVAL = "1 minute"; const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000; function isAlreadyExists(error: PlatformError.PlatformError): boolean { @@ -820,8 +814,9 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct return current; } + // A cached index that stands in for a failed fetch is also fresh, so + // offline searches do not wait on the network each time. const registry = yield* fetchRegistry().pipe( - Effect.tap(() => Ref.set(registryFetchedAt, now)), Effect.catch((networkError) => readCachedRegistry.pipe( Effect.flatMap( @@ -833,6 +828,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ), ), ); + yield* Ref.set(registryFetchedAt, now); yield* Ref.set(registryRef, registry); yield* Ref.update(registryRevision, (revision) => revision + 1); return registry; @@ -892,9 +888,11 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct { concurrency: "unbounded" }, ); if (Number(exitCode) !== 0) { + // The installer's own error is how users fix a failed install. Its + // tail usually holds the reason, and keeps the message bounded. return yield* new AcpRegistryError({ reason: "install_failed", - detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`, + detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim().slice(-MAX_INSTALL_ERROR_OUTPUT_CHARS)}`, }); } // Archive listings feed validateArchiveEntries; a truncated listing would let @@ -1095,11 +1093,12 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const commandName = npmCommandName(agent, packageIdentity.name, manifest.value); if (commandName === undefined) return Option.none(); const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin"); - const executablePath = resolveExecutable( - commandName, - platform, - withPreferredPath(environment, binDirectory, platform), - ); + // Only the managed bin directory: a same-named command elsewhere on PATH + // is not this package. + const executablePath = resolveExecutable(commandName, platform, { + ...environment, + PATH: binDirectory, + }); return executablePath === undefined ? Option.none() : Option.some({ @@ -1143,7 +1142,9 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct timeout: PACKAGE_QUERY_TIMEOUT, truncatedOutputReason: "install_failed", }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); - const commandEnvironment = withPreferredPath(environment, binDirectory, platform); + // Only the managed bin directory: a same-named command elsewhere on PATH + // is not this package. + const commandEnvironment = { ...environment, PATH: binDirectory }; const executablePath = packageCommandCandidates(agent, packageIdentity.name) .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment)) .find((candidate): candidate is string => candidate !== undefined); @@ -1293,7 +1294,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct }); }); - const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* ( + const resolveCommandInRoot = Effect.fn("AcpRegistryCatalog.resolveCommandInRoot")(function* ( root: string, executablePath: string, ) { @@ -1306,6 +1307,14 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct detail: "ACP Registry archive command resolves outside its installation directory.", }); } + return executableRealPath; + }); + + const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* ( + root: string, + executablePath: string, + ) { + const executableRealPath = yield* resolveCommandInRoot(root, executablePath); const info = yield* fileSystem.stat(executableRealPath); const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0; if (info.type !== "File" || !hasExecutableMode) { @@ -1384,6 +1393,18 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ); return yield* Effect.gen(function* () { + // Waiters take a lock older than INSTALL_LOCK_STALE_MS as left by a + // crashed process. A download can run longer, so keep the lock fresh. + yield* Effect.forkScoped( + Effect.forever( + Effect.sleep(INSTALL_LOCK_REFRESH_INTERVAL).pipe( + Effect.andThen(Clock.currentTimeMillis), + // Node reads numeric file times as seconds. + Effect.map((nowMillis) => nowMillis / 1_000), + Effect.flatMap((now) => fileSystem.utimes(lockPath, now, now).pipe(Effect.ignore)), + ), + ), + ); if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { return yield* assertExecutableInRoot(installRoot, executablePath); } @@ -1501,7 +1522,10 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`, }); } - if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755); + // chmod follows links, so a command linking outside the archive must be + // rejected before its target's mode can change. + const stagedRealPath = yield* resolveCommandInRoot(extractionRoot, stagedExecutable); + if (platform !== "win32") yield* fileSystem.chmod(stagedRealPath, 0o755); yield* assertExecutableInRoot(extractionRoot, stagedExecutable); yield* fileSystem.remove(installRoot, { recursive: true, force: true }); yield* fileSystem.rename(extractionRoot, installRoot); @@ -1535,14 +1559,21 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct return agent; }); + const runnerAvailableIn = + (environment: NodeJS.ProcessEnv) => + (runner: "npm" | "uv"): boolean => + resolveExecutable(runner, platform, environment) !== undefined; + const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* ( agent: AcpRegistryAgent, preference: AcpRegistryDistributionPreference, + environment: NodeJS.ProcessEnv, ) { const distribution = resolveAcpRegistryDistribution({ agent, preference, platformTarget, + isRunnerAvailable: runnerAvailableIn(environment), }); if (distribution === undefined) { return yield* new AcpRegistryError({ @@ -1556,18 +1587,19 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const search: AcpRegistryCatalog["Service"]["search"] = (input) => Effect.gen(function* () { const registry = yield* refreshRegistry(); + const isRunnerAvailable = runnerAvailableIn(hostEnvironment); const ranked = registry.agents.flatMap((agent) => { const distribution = resolveAcpRegistryDistribution({ agent, preference: "auto", platformTarget, + isRunnerAvailable, }); const rank = searchRank(agent, input.query); const packageManager = distribution === undefined ? undefined : packageManagerFor(distribution.kind); const packageManagerAvailable = - packageManager === undefined || - resolveExecutable(packageManager, platform, hostEnvironment) !== undefined; + packageManager === undefined || isRunnerAvailable(packageManager); return distribution === undefined || rank === undefined || !packageManagerAvailable ? [] : [{ agent, distribution, rank }]; @@ -1602,7 +1634,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct Effect.gen(function* () { const registry = yield* refreshRegistry(); const agent = yield* findAgent(registry, input.agentId); - const distribution = yield* compatibleDistribution(agent, "auto"); + const distribution = yield* compatibleDistribution(agent, "auto", hostEnvironment); if (distribution.kind === "binary") { // A PATH executable can be a different version with different ACP // capabilities. Only an explicit command override opts into that copy. @@ -1636,6 +1668,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct agent, preference: settings.distribution, platformTarget, + isRunnerAvailable: runnerAvailableIn(environment ?? hostEnvironment), }); if (distribution === undefined) { return { status: "unsupported", agentId, version: agent.version } as const; @@ -1736,12 +1769,16 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } const registry = yield* loadRegistry(); const agent = yield* findAgent(registry, agentId); - const distribution = yield* compatibleDistribution(agent, settings.distribution); + const effectiveEnvironment = environment ?? hostEnvironment; + const distribution = yield* compatibleDistribution( + agent, + settings.distribution, + effectiveEnvironment, + ); let command: string; let args: ReadonlyArray; let commandBinDirectory: string | undefined; - const effectiveEnvironment = environment ?? hostEnvironment; const commandOverride = settings.commandPath.trim(); if (commandOverride.length > 0) { const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment); From b7f386a2c112856c61b4b9ff9ae66a6da5e1fecb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:09:38 -0700 Subject: [PATCH 11/16] fix(server): ACP registry form answers follow the agent's schema Fixed choices no longer take typed answers, array fields with item choices become multi-select questions and send string arrays, and a fraction for an integer field is left out instead of sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/acp-mock-agent.ts | 3 ++ .../Layers/AcpRegistryAdapter.test.ts | 27 +++++++++++- .../src/provider/Layers/AcpRegistryAdapter.ts | 41 ++++++++++++------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 8ed565ca4e0a..526674400412 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1718,6 +1718,9 @@ const program = Effect.gen(function* () { type: "object", properties: { approved: { type: "boolean", title: "Approved" }, + color: { type: "string", title: "Color", enum: ["red", "blue"] }, + tags: { type: "array", title: "Tags", items: { type: "string", enum: ["a", "b"] } }, + count: { type: "integer", title: "Count" }, }, }, ...(emitMcpToolApprovalElicitation diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts index 8f8306f498fe..3ca414788b8a 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -340,10 +340,33 @@ function acpRegistryAdapterTests( return assert.fail(`Expected a question, got ${requested.type}`); } expect(requested.payload.questions).toMatchObject([ - { id: "approved", header: "Approved", options: [{ label: "true" }, { label: "false" }] }, + { + id: "approved", + header: "Approved", + options: [{ label: "true" }, { label: "false" }], + allowCustomAnswer: false, + multiSelect: false, + }, + { + id: "color", + options: [{ label: "red" }, { label: "blue" }], + allowCustomAnswer: false, + multiSelect: false, + }, + { + id: "tags", + options: [{ label: "a" }, { label: "b" }], + allowCustomAnswer: false, + multiSelect: true, + }, + { id: "count", options: [], allowCustomAnswer: true, multiSelect: false }, ]); yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(requested.requestId), { approved: "true", + color: "red", + tags: ["a"], + // A fraction does not fit an integer field, so it is left out. + count: "1.5", }); yield* Fiber.join(turn); yield* adapter.stopSession(threadId); @@ -351,7 +374,7 @@ function acpRegistryAdapterTests( const requests = yield* readRequestLog(requestLogPath); expect(requests.map((request) => request.result)).toContainEqual({ action: "accept", - content: { approved: true }, + content: { approved: true, color: "red", tags: ["a"] }, }); }).pipe(Effect.scoped, TestClock.withLive), ); diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index 76c4a4ec142f..635e8a4baa5b 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -243,8 +243,11 @@ function acpRegistryElicitationQuestions(request: { const properties = unknownRecord(unknownRecord(request.requestedSchema)?.properties) ?? {}; return Object.entries(properties).map(([id, property], index) => { const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") + // ACP multi-select fields are string arrays with their choices on `items`. + const isMultiSelect = record?.type === "array"; + const choices = isMultiSelect ? unknownRecord(record?.items)?.enum : record?.enum; + const enumValues = Array.isArray(choices) + ? choices.filter((value): value is string => typeof value === "string") : []; const options = enumValues.length > 0 @@ -260,8 +263,9 @@ function acpRegistryElicitationQuestions(request: { header: nonEmptyText(record?.title, `Question ${index + 1}`), question: nonEmptyText(record?.description, nonEmptyText(request.message, "Answer")), options, - ...(options.length === 0 ? { allowCustomAnswer: true } : {}), - multiSelect: false, + // Fixed choices come from the agent's schema, so typed answers are off. + allowCustomAnswer: options.length === 0, + multiSelect: isMultiSelect && options.length > 0, }; }); } @@ -274,7 +278,10 @@ function elicitationFieldTypes(requestedSchema: unknown): ReadonlyMap, @@ -283,15 +290,21 @@ function elicitationContent( for (const [key, value] of Object.entries(answers)) { if (!fieldTypes.has(key)) continue; const type = fieldTypes.get(key); - if (type === "boolean" && (value === "true" || value === "false")) { - content[key] = value === "true"; - } else if ( - (type === "number" || type === "integer") && - typeof value === "string" && - value.trim() !== "" && - Number.isFinite(Number(value)) - ) { - content[key] = Number(value); + if (type === "boolean") { + if (value === "true" || value === "false") content[key] = value === "true"; + } else if (type === "number" || type === "integer") { + const number = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : Number.NaN; + if (type === "integer" ? Number.isInteger(number) : Number.isFinite(number)) { + content[key] = number; + } + } else if (type === "array") { + const entries = Array.isArray(value) ? value : [value]; + content[key] = entries.filter((entry): entry is string => typeof entry === "string"); } else if ( typeof value === "string" || typeof value === "number" || From 2e7a70c9335811943d9c0837107e63b28339f47e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:09:40 -0700 Subject: [PATCH 12/16] fix(web): middle-clicking Continue authentication records consent Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/ProviderInstanceCard.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index d8a3b5fec4b9..88961f314929 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -902,6 +902,10 @@ export function ProviderInstanceCard({ target="_blank" rel="noopener noreferrer" onClick={() => onAcceptUrlAuth(urlAuthAction)} + // A middle click opens the link without a click event. + onAuxClick={(event) => { + if (event.button === 1) onAcceptUrlAuth(urlAuthAction); + }} /> } size="xs" From 8c01806b366fd999e2dc803c09e0495abcb6fe99 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:10:25 -0700 Subject: [PATCH 13/16] fix(server): a past ACP registry session no longer hides a failed sign-in Live configuration from an earlier session marked every later snapshot ready and dropped its message, even when a new probe reported failed sign-in or local inspection failed. It now promotes the status only for an enabled, installed agent that is not unauthenticated. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Drivers/AcpRegistryDriver.test.ts | 11 +++++++++++ .../src/provider/Drivers/AcpRegistryDriver.ts | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts index 609c189866e0..4902eb7199dd 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -406,6 +406,17 @@ describe("acpRegistrySnapshotReadiness", () => { }, message: 'Sign in in provider settings using "Log in with Grok".', }); + // Live configuration from an earlier session does not hide the failed sign-in. + expect( + applyAcpRegistryLiveConfiguration( + snapshot, + { models: [], currentModelId: null, configOptions: [], supportsPlanMode: false }, + [], + ), + ).toMatchObject({ + status: "warning", + message: 'Sign in in provider settings using "Log in with Grok".', + }); }); it.effect("runs the disposable probe only after local inspection is ready", () => diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 3b2b1dbd3237..466c1547e9cc 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -246,13 +246,18 @@ export function applyAcpRegistryLiveConfiguration( configuration: AcpRegistryLiveConfiguration, customModels: ReadonlyArray, ): ServerProvider { - const { message: _staleProbeMessage, ...snapshot } = provider; - return { - ...snapshot, - status: provider.enabled ? "ready" : provider.status, + const configured = { + ...provider, showInteractionModeToggle: configuration.supportsPlanMode, models: modelsFromDiscovery(configuration, customModels), }; + // A running session proves the agent starts, so it replaces the probe's + // status. It must not hide a later failed sign-in or local check. + if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { + return configured; + } + const { message: _staleProbeMessage, ...snapshot } = configured; + return { ...snapshot, status: "ready" }; } function applyAcpRegistryUrlAuthAction( From 84b8de7566ea7b58a5a04f996a256cb36c0ae2e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:13:43 -0700 Subject: [PATCH 14/16] fix(mobile): thread rows and fallback picks show the ACP Registry agent icon Thread rows drew the generic ACP glyph because the row's provider instance had no icon URL, and a selected model outside the provider's list lost the icon too. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/components/ProviderIcon.tsx | 3 ++- .../features/threads/thread-list-v2-items.tsx | 1 + .../threads/thread-provider-instance.test.ts | 13 ++++++++++ .../threads/thread-provider-instance.ts | 3 +++ apps/mobile/src/lib/modelOptions.test.ts | 26 +++++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 1 + 6 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index cf9905896f0d..0d0dea0df9d8 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -152,6 +152,7 @@ export function ProviderIcon(props: ProviderIconProps) { */ export function ProviderInstanceIcon(props: { readonly provider: string | null | undefined; + readonly iconUrl?: string | undefined; readonly size?: number; readonly displayName: string; readonly accentColor?: string; @@ -161,7 +162,7 @@ export function ProviderInstanceIcon(props: { return ( - + {props.showBadge ? ( , ): ServerConfig { return { providers } as unknown as ServerConfig; @@ -98,6 +99,18 @@ describe("resolveThreadProviderInstance", () => { expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); }); + + it("carries an ACP Registry agent's icon to the thread row", () => { + const environmentId = EnvironmentId.make("environment-a"); + const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg"; + const serverConfigs = new Map([ + [environmentId, makeConfig([{ instanceId: "acp_kimi", driver: "acpRegistry", iconUrl }])], + ]); + + expect( + resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "acp_kimi"))?.iconUrl, + ).toBe(iconUrl); + }); }); describe("createThreadRowProviderInstanceResolver", () => { diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index cd07f636820c..85574c7652b3 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -13,6 +13,8 @@ export interface ThreadRowProviderInstance { readonly driverKind: ProviderDriverKind; readonly displayName: string; readonly accentColor?: string | undefined; + /** ACP Registry agent icon; other drivers draw their built-in glyph. */ + readonly iconUrl?: string | undefined; readonly showBadge: boolean; } @@ -36,6 +38,7 @@ export function resolveThreadProviderInstance( }; return { ...entry, + ...(snapshot.iconUrl ? { iconUrl: snapshot.iconUrl } : {}), showBadge: shouldShowInstanceBadge( entry, providers.map((provider) => ({ driverKind: provider.driver })), diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..037061db192f 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -197,6 +197,32 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + it("keeps an ACP Registry agent's icon on a selection outside its model list", () => { + const config = { + providers: [ + { + instanceId: "acp_kimi", + driver: "acpRegistry", + displayName: "Kimi", + iconUrl: "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + const selection = { instanceId: ProviderInstanceId.make("acp_kimi"), model: "removed-model" }; + + expect(buildModelOptions(config, selection)).toMatchObject([ + { + providerDriver: "acpRegistry", + providerIconUrl: "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg", + selection, + }, + ]); + }); + describe("Antigravity selections", () => { const selection = { instanceId: ProviderInstanceId.make("google_work"), diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index dc25043ab297..6a5fd39f1d7d 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -222,6 +222,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver, + ...(provider?.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: false, isLegacy: model?.isLegacy === true, ...(isModelSelectionUnavailable(config, fallbackModelSelection) From 2a6754638964fc7f4aeb683338a704d532e96ff6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:21:07 -0700 Subject: [PATCH 15/16] fix(server): ACP registry accepts commands in directories named like ..tools The containment check rejected any relative path starting with "..", so a command inside a directory such as ..tools was refused. Only a ".." segment leaves the install root. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider/acp/AcpRegistrySupport.test.ts | 35 +++++++++++++++++++ .../src/provider/acp/AcpRegistrySupport.ts | 3 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index 37f17c2448e1..53e1af3a4629 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -560,6 +560,41 @@ describe("AcpRegistrySupport", () => { ); }); + it.effect("installs a command in a directory whose name starts with two dots", () => { + const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: archiveUrl, cmd: "./..tools/example-agent" } }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-dot-dir-", + }); + const resolver = yield* makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const resolved = yield* resolver.resolve(settings(), "/workspace"); + + expect(resolved.spawn.command).toContain("/linux-x86_64/..tools/example-agent"); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === registryUrl + ? new Response(makeRegistry(agent)) + : new Response(binaryBytes.buffer as ArrayBuffer), + ), + ), + ), + ), + ); + }); + it.effect( "prepares the registry version despite an older PATH binary and permits explicit overrides", () => { diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index 4656eeacff9c..9d29b36306d5 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -1301,7 +1301,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const rootRealPath = yield* fileSystem.realPath(root); const executableRealPath = yield* fileSystem.realPath(executablePath); const relative = path.relative(rootRealPath, executableRealPath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + // A name like `..tools` is inside the root; only a `..` segment leaves it. + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { return yield* new AcpRegistryError({ reason: "archive_invalid", detail: "ACP Registry archive command resolves outside its installation directory.", From c36ec3c2110f28e10dc9d45467cf2a2eef6249f6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 03:21:09 -0700 Subject: [PATCH 16/16] fix(server): a queued ACP registry turn reads the model under the prompt lock A turn queued behind a model change read the session model before the lock, then wrote that stale model back to the session and its turn metadata. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/Layers/AcpRegistryAdapter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts index 635e8a4baa5b..4b97f3c20c41 100644 --- a/apps/server/src/provider/Layers/AcpRegistryAdapter.ts +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -1205,8 +1205,6 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi issue: "The selected model belongs to another provider instance.", }); } - const model = input.modelSelection?.model ?? context.session.model; - const prompt = yield* buildPrompt(context, input, model); let intent: TurnIntent | undefined; // The caller holds promptLock while it changes or settles the active turn. const finishTurn = (turn: TurnIntent, payload: TurnCompletedPayload) => @@ -1238,6 +1236,10 @@ export const makeAcpRegistryAdapter = Effect.fn("makeAcpRegistryAdapter")(functi const launch = yield* context.promptLock.withPermit( Effect.gen(function* () { yield* requireSession(input.threadId); + // Read the model under the lock, so a turn queued behind a model + // change reports and records the model the agent runs. + const model = input.modelSelection?.model ?? context.session.model; + const prompt = yield* buildPrompt(context, input, model); const turnId = context.activeTurnId ?? TurnId.make(yield* randomId); const steering = context.activeTurnId !== undefined; const turn: TurnIntent = { turnId, generation: ++context.generation, settled: false };