diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 49de96a8d74c..0d0dea0df9d8 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 ( @@ -95,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; @@ -104,7 +162,7 @@ export function ProviderInstanceIcon(props: { return ( - + {props.showBadge ? ( - + {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/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index eb089b95060a..40799639c44e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1055,6 +1055,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.providerInstance ? ( , ): 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 4e8295733141..6a5fd39f1d7d 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, @@ -220,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) 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/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..4902eb7199dd --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -0,0 +1,502 @@ +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 }], + supportsPlanMode: false, + }, + }); + + 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: [], + supportsPlanMode: false, + }, + }); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.setup?.canAuthenticate).toBe(true); + expect( + applyAcpRegistryLiveConfiguration( + snapshot, + { models: [], currentModelId: null, configOptions: [], supportsPlanMode: false }, + [], + ).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: [], + supportsPlanMode: false, + }, + }); + + expect( + applyAcpRegistryLiveConfiguration( + provider, + { + models: [{ id: "live-model", name: "Live model", description: null }], + currentModelId: "live-model", + configOptions: [], + supportsPlanMode: false, + }, + [], + ), + ).toMatchObject({ + auth: { status: "unknown", canLogout: true }, + models: [{ slug: "live-model", isDefault: true }], + }); + }); + + 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({ + 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 }], + supportsPlanMode: false, + }, + }); + + 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: [], + supportsPlanMode: false, + }, + }); + + 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".', + }); + // 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", () => + 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: [], + supportsPlanMode: false, + }; + }), + ).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..466c1547e9cc --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,767 @@ +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, + // 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, + 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 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( + 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..3ca414788b8a --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.test.ts @@ -0,0 +1,469 @@ +// @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", + ]); + 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); + expect(requests.map((request) => request.result)).toContainEqual({ + outcome: { outcome: "selected", optionId: "allow-once" }, + }); + }).pipe(Effect.scoped, TestClock.withLive), + ); + + // 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), + ); + } + + 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"); + 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" }], + 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); + + const requests = yield* readRequestLog(requestLogPath); + expect(requests.map((request) => request.result)).toContainEqual({ + action: "accept", + content: { approved: true, color: "red", tags: ["a"] }, + }); + }).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"); + 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"); + // 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), + ); + + 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..4b97f3c20c41 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryAdapter.ts @@ -0,0 +1,1454 @@ +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 { + acpAutoApprovalOptionId, + 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 { + isAcpRegistryPlanModeId, + 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; +} + +/** An approval shown in the thread. The user may answer only with an offered choice. */ +interface PendingApproval { + readonly options: ReadonlyArray; + readonly response: Deferred.Deferred; +} + +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; + /** 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; + 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; + } +} + +/** 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": + case "acceptAlways": + 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) + : 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); + // 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 + ? 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, + // Fixed choices come from the agent's schema, so typed answers are off. + allowCustomAnswer: options.length === 0, + multiSelect: isMultiSelect && options.length > 0, + }; + }); +} + +/** 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. An answer that does not fit its field's type is left out. + */ +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") { + 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" || + 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, "cancel"); + } + 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) => + 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(acpAutoApprovalOptionId(request)); + } + 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 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; + 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, + detail: permissionRequest.detail, + args: request, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: request, + }), + ); + const decision = yield* Deferred.await(response); + if (decision === "accept" || decision === "acceptForSession") { + context.grants.recordApproval({ + kind: permissionRequest.kind === "execute" ? "command" : "file-change", + scope: decision === "acceptForSession" ? "session" : "turn", + turnKey: String(turnId), + }); + } + yield* emit( + makeAcpRequestResolvedEvent({ + stamp: yield* stamp, + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision, + }), + ); + return permissionResponse(optionIdForDecision(request, decision)); + }).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 (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 && + 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" }; + } + // 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" }; + 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); + // A cancelled question still resolves, so the thread stops offering it. + yield* emit({ + type: "user-input.resolved", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.threadId, + turnId, + requestId: runtimeRequestId, + 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)))); + }); + + 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) => isAcpRegistryPlanModeId(mode.id)); + if (planMode && modeState?.currentModeId !== planMode.id) { + yield* runtime.setMode(planMode.id); + } + for (const option of planSensitive) { + const planValue = selectChoices(option).find(isAcpRegistryPlanModeId); + 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(), + urlAuthentications: new Set(), + 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.", + }); + } + 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); + // 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 }; + 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) { + // The reactor closes the approval when the detail names it as unknown. + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + if (!pending.options.some((option) => option.decision === decision)) { + 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); + }); + + const respondToUserInput: AcpRegistryAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + 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: `Unknown pending user-input request: ${requestId}`, + }); + } + 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..36dd052f945f --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -0,0 +1,473 @@ +// @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 * as EffectAcpSchema from "effect-acp/compat"; + +import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; +import { + acpRegistryProbeFailure, + acpRegistryProbeResult, + normalizeAcpRegistryAuthMethods, + normalizeAcpRegistryCommands, + normalizeAcpRegistryLiveConfiguration, + 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("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", + 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..18a7e1d47479 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -0,0 +1,482 @@ +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"]; + /** 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. */ +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 }), + supportsPlanMode: acpRegistrySupportsPlanMode(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 { + // 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, + 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; + readonly supportsPlanMode: boolean; +} + +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, + 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 new file mode 100644 index 000000000000..3765191a8f45 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts @@ -0,0 +1,314 @@ +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: [], + 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); + 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..53e1af3a4629 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -0,0 +1,1774 @@ +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(); + + 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", () => { + 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); + // 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`, + 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("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", + () => { + 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", + }); + + // Prepare reuses the index the search just fetched. + expect(requests).toEqual([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("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" } }), + 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("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* () { + 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); + + // 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( + 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"], + }, + }); + let registryRequests = 0; + 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"], + }); + + // 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) => { + registryRequests += 1; + return 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("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: { + "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..9d29b36306d5 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -0,0 +1,1911 @@ +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_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. +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"; + +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; + 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]; + + const resolved = candidates.flatMap((kind): ReadonlyArray => { + if (kind === "binary" && binary) { + return [{ kind, args: binary.args ?? [], env: binary.env ?? {}, binaryTarget: binary }]; + } + 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 { + 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 INSTALL_LOCK_REFRESH_INTERVAL = "1 minute"; +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); + // 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()); + + 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 now = yield* Clock.currentTimeMillis; + const fetchedAt = yield* Ref.get(registryFetchedAt); + if ( + current !== undefined && + fetchedAt !== undefined && + now - fetchedAt < REGISTRY_FRESHNESS_MS + ) { + 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.catch((networkError) => + readCachedRegistry.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(networkError), + onSome: Effect.succeed, + }), + ), + ), + ), + ); + yield* Ref.set(registryFetchedAt, now); + 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) { + // 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().slice(-MAX_INSTALL_ERROR_OUTPUT_CHARS)}`, + }); + } + // 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"); + // 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({ + 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))); + // 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); + 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 resolveCommandInRoot = Effect.fn("AcpRegistryCatalog.resolveCommandInRoot")(function* ( + root: string, + executablePath: string, + ) { + const rootRealPath = yield* fileSystem.realPath(root); + const executableRealPath = yield* fileSystem.realPath(executablePath); + const relative = path.relative(rootRealPath, executableRealPath); + // 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.", + }); + } + 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) { + 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* () { + // 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); + } + 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}'.`, + }); + } + // 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); + 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 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({ + 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 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 || isRunnerAvailable(packageManager); + 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", 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. + 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, + isRunnerAvailable: runnerAvailableIn(environment ?? hostEnvironment), + }); + 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 effectiveEnvironment = environment ?? hostEnvironment; + const distribution = yield* compatibleDistribution( + agent, + settings.distribution, + effectiveEnvironment, + ); + + let command: string; + let args: ReadonlyArray; + let commandBinDirectory: string | undefined; + 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..a8fe555977c7 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,71 @@ 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; + } + // 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( + (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..88961f314929 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,43 @@ 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..1a463b1f8ab3 --- /dev/null +++ b/docs/user/providers-acp.md @@ -0,0 +1,70 @@ +# 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. 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 + +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..b8fd31f310c5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1060,6 +1060,15 @@ export function createServerEnvironmentAtoms( staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId), }), + searchAcpRegistry: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:acp-registry:search", + tag: WS_METHODS.serverSearchAcpRegistry, + // 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, + }), configProjection, welcome, consumeResetCredit: createEnvironmentRpcCommand(runtime, { @@ -1110,6 +1119,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