| author | |
| committer | |
| log | 43f8a8de17a7ac1baa7a3cf36d681856de2d8add |
| tree | 066b5c970c708992025a52b80146cd2e50021e7c |
| parent | cb46c62504aca99295a4a8879644625a20685b7f |
| signature | Signed by PGP key B5690EEEBB952194 |
77 files changed, 9969 insertions(+), 9798 deletions(-)
apps/mobile/package.json+1| ... | ... | @@ -69,6 +69,7 @@ |
| 69 | 69 | "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", |
| 70 | 70 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 71 | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | "@t3tools/provider-acp-registry": "workspace:*", | |
| 72 | 73 | "@t3tools/provider-core": "workspace:*", |
| 73 | 74 | "@t3tools/provider-cursor": "workspace:*", |
| 74 | 75 | "@t3tools/provider-grok": "workspace:*", |
apps/mobile/src/lib/providerClients.ts+2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { isProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; | |
| 2 | 3 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 3 | 4 | import { cursorClient } from "@t3tools/provider-cursor/client"; |
| 4 | 5 | import { grokClient } from "@t3tools/provider-grok/client"; |
| ... | ... | @@ -13,6 +14,7 @@ const providerClients = makeProviderClientRegistry([ |
| 13 | 14 | museClient, |
| 14 | 15 | openCodeClient, |
| 15 | 16 | piClient, |
| 17 | acpRegistryClient, | |
| 16 | 18 | ]); |
| 17 | 19 | |
| 18 | 20 | /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ |
apps/server/package.json+1-1| ... | ... | @@ -36,9 +36,9 @@ |
| 36 | 36 | "@ff-labs/fff-node": "0.9.4", |
| 37 | 37 | "@muse-code/sdk": "1.4.2", |
| 38 | 38 | "@napi-rs/keyring": "^1.3.0", |
| 39 | "@noble/hashes": "catalog:", | |
| 40 | 39 | "@opencode-ai/sdk": "^1.3.15", |
| 41 | 40 | "@t3tools/provider-acp": "workspace:*", |
| 41 | "@t3tools/provider-acp-registry": "workspace:*", | |
| 42 | 42 | "@t3tools/provider-core": "workspace:*", |
| 43 | 43 | "@t3tools/provider-cursor": "workspace:*", |
| 44 | 44 | "@t3tools/provider-grok": "workspace:*", |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+2-2| ... | ... | @@ -2,7 +2,7 @@ import { |
| 2 | 2 | normalizeDevinSessionUpdate, |
| 3 | 3 | normalizeDevinToolCall, |
| 4 | 4 | extractDevinSubagentUpdate, |
| 5 | } from "./DevinAcp.ts"; | |
| 5 | } from "@t3tools/provider-acp-registry/testing"; | |
| 6 | 6 | // @effect-diagnostics nodeBuiltinImport:off |
| 7 | 7 | import * as NodeChildProcess from "node:child_process"; |
| 8 | 8 | import * as NodeFS from "node:fs"; |
| ... | ... | @@ -95,7 +95,7 @@ import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing"; |
| 95 | 95 | import { |
| 96 | 96 | acpRegistryPromptFailure, |
| 97 | 97 | registerMistralVibeAcpExtensions, |
| 98 | } from "./AcpRegistryAdapterV2.ts"; | |
| 98 | } from "@t3tools/provider-acp-registry/testing"; | |
| 99 | 99 | |
| 100 | 100 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); |
| 101 | 101 |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+4-4| ... | ... | @@ -19,8 +19,8 @@ import * as ServerSettings from "../../serverSettings.ts"; |
| 19 | 19 | import type { |
| 20 | 20 | AcpRegistryAvailableCommands, |
| 21 | 21 | AcpRegistryLiveConfiguration, |
| 22 | } from "../../provider/acp/AcpRegistryProbe.ts"; | |
| 23 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; | |
| 22 | } from "@t3tools/provider-acp-registry/testing"; | |
| 23 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; | |
| 24 | 24 | import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; |
| 25 | 25 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 26 | 26 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| ... | ... | @@ -31,12 +31,12 @@ import { |
| 31 | 31 | } from "./AcpAdapterV2.testkit.ts"; |
| 32 | 32 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 33 | 33 | import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; |
| 34 | import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server"; | |
| 34 | 35 | import { |
| 35 | 36 | ACP_REGISTRY_PROVIDER, |
| 36 | AcpRegistryAdapterV2Driver, | |
| 37 | 37 | makeAcpRegistryAdapterV2, |
| 38 | 38 | acpRegistryPromptFailure, |
| 39 | } from "./AcpRegistryAdapterV2.ts"; | |
| 39 | } from "@t3tools/provider-acp-registry/testing"; | |
| 40 | 40 | |
| 41 | 41 | const registryUrl = "https://registry.test/registry.json"; |
| 42 | 42 | const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts+2-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { AcpRegistrySettings } from "@t3tools/contracts"; | |
| 2 | import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; | |
| 3 | 3 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | ACP_REGISTRY_DEFAULT_INSTANCE_ID, |
| 25 | 25 | ACP_REGISTRY_PROVIDER, |
| 26 | 26 | makeAcpRegistryAdapterV2, |
| 27 | } from "./AcpRegistryAdapterV2.ts"; | |
| 27 | } from "@t3tools/provider-acp-registry/testing"; | |
| 28 | 28 | |
| 29 | 29 | const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({ |
| 30 | 30 | agentId: "replay-agent", |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts deleted-317| ... | ... | @@ -1,317 +0,0 @@ |
| 1 | import { | |
| 2 | normalizeDevinSessionUpdate, | |
| 3 | normalizeDevinToolCall, | |
| 4 | extractDevinSubagentUpdate, | |
| 5 | } from "./DevinAcp.ts"; | |
| 6 | import { | |
| 7 | AcpRegistrySettings, | |
| 8 | defaultInstanceIdForDriver, | |
| 9 | ProviderDriverKind, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 12 | import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as FileSystem from "effect/FileSystem"; | |
| 16 | import * as Path from "effect/Path"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import type * as Scope from "effect/Scope"; | |
| 21 | import { ChildProcessSpawner } from "effect/process"; | |
| 22 | import * as EffectAcpErrors from "effect-acp/errors"; | |
| 23 | ||
| 24 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 25 | import { | |
| 26 | normalizeAcpRegistryCommands, | |
| 27 | normalizeAcpRegistryLiveConfiguration, | |
| 28 | normalizeAcpRegistryWebUrl, | |
| 29 | } from "../../provider/acp/AcpRegistryProbe.ts"; | |
| 30 | import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; | |
| 31 | import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; | |
| 32 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 33 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; | |
| 34 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | |
| 35 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 37 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 38 | import { | |
| 39 | ProviderAdapterDriverCreateError, | |
| 40 | type ProviderAdapterDriver, | |
| 41 | type ProviderAdapterDriverCreateInput, | |
| 42 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 43 | import { | |
| 44 | AcpProviderCapabilitiesV2, | |
| 45 | makeAcpAdapterV2, | |
| 46 | type AcpAdapterV2ExtensionContext, | |
| 47 | type AcpAdapterV2Flavor, | |
| 48 | type AcpAdapterV2RuntimeInput, | |
| 49 | } from "@t3tools/provider-acp/server/adapter"; | |
| 50 | ||
| 51 | export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); | |
| 52 | export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); | |
| 53 | ||
| 54 | const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({}); | |
| 55 | const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); | |
| 56 | ||
| 57 | export interface AcpRegistryAdapterV2Options { | |
| 58 | readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"]; | |
| 59 | readonly settings: AcpRegistrySettings; | |
| 60 | readonly environment: NodeJS.ProcessEnv; | |
| 61 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 62 | readonly selfInvocation: SelfInvocation; | |
| 63 | readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">; | |
| 64 | readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; | |
| 65 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; | |
| 66 | readonly makeRuntime?: ( | |
| 67 | input: AcpAdapterV2RuntimeInput, | |
| 68 | ) => Effect.Effect< | |
| 69 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 70 | EffectAcpErrors.AcpError, | |
| 71 | Crypto.Crypto | Scope.Scope | |
| 72 | >; | |
| 73 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 74 | } | |
| 75 | ||
| 76 | // ─── Per-agent exceptions ──────────────────────────────────────────────────── | |
| 77 | // This adapter serves every ACP registry agent through the plain ACP spec. | |
| 78 | // Agent-specific behavior does not belong here: an agent that needs it gets a | |
| 79 | // dedicated driver (as Grok and Antigravity have). The few exceptions below | |
| 80 | // predate that rule and are small presentation hooks, not permission or tool | |
| 81 | // behavior. | |
| 82 | // | |
| 83 | // Agents changing this file: do NOT add another `agentId === "..."` branch, | |
| 84 | // agent table, or agent-specific hook without explicit approval from the | |
| 85 | // maintainer in the conversation. Propose a dedicated driver instead. | |
| 86 | ||
| 87 | // Mistral Vibe: its application error code for rate limits (not an ACP code). | |
| 88 | const MISTRAL_VIBE_RATE_LIMITED = -31001; | |
| 89 | ||
| 90 | const MistralVibeSessionRetrying = Schema.Struct({ | |
| 91 | sessionId: Schema.String, | |
| 92 | category: Schema.Literals(["rate_limited", "server_error", "timed_out", "connection", "unknown"]), | |
| 93 | detail: Schema.String, | |
| 94 | }); | |
| 95 | ||
| 96 | /** Mistral Vibe (v2.25.5) reports SDK backoff through this ACP extension. */ | |
| 97 | export function registerMistralVibeAcpExtensions(context: AcpAdapterV2ExtensionContext) { | |
| 98 | return context.runtime.handleExtNotification( | |
| 99 | "_session/retrying", | |
| 100 | MistralVibeSessionRetrying, | |
| 101 | (notice) => | |
| 102 | context.reportProviderRetry({ | |
| 103 | sessionId: notice.sessionId, | |
| 104 | failure: makeProviderFailure({ | |
| 105 | message: notice.detail, | |
| 106 | class: | |
| 107 | notice.category === "rate_limited" | |
| 108 | ? "usage_limit" | |
| 109 | : notice.category === "unknown" | |
| 110 | ? "provider_error" | |
| 111 | : "transport_error", | |
| 112 | retryable: true, | |
| 113 | }), | |
| 114 | }), | |
| 115 | ); | |
| 116 | } | |
| 117 | // ─── End per-agent exceptions (Devin's gates are marked in makeAcpRegistryAdapterV2) ─── | |
| 118 | ||
| 119 | export function acpRegistryPromptFailure(agentId: string, cause: unknown) { | |
| 120 | return makeProviderFailure({ | |
| 121 | cause, | |
| 122 | ...(isAcpRequestError(cause) | |
| 123 | ? { | |
| 124 | message: cause.errorMessage, | |
| 125 | code: String(cause.code), | |
| 126 | class: | |
| 127 | // Per-agent exception: see the note above registerMistralVibeAcpExtensions. | |
| 128 | agentId === "mistral-vibe" && cause.code === MISTRAL_VIBE_RATE_LIMITED | |
| 129 | ? ("usage_limit" as const) | |
| 130 | : ("provider_error" as const), | |
| 131 | } | |
| 132 | : { class: "provider_error" as const }), | |
| 133 | }); | |
| 134 | } | |
| 135 | ||
| 136 | function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { | |
| 137 | return ( | |
| 138 | input: AcpAdapterV2RuntimeInput, | |
| 139 | ): Effect.Effect< | |
| 140 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 141 | EffectAcpErrors.AcpError, | |
| 142 | Crypto.Crypto | Scope.Scope | |
| 143 | > => | |
| 144 | Effect.gen(function* () { | |
| 145 | const { processEnvironment, ...runtimeInput } = input; | |
| 146 | const resolved = yield* options.resolver | |
| 147 | .resolve(options.settings, input.cwd, options.environment) | |
| 148 | .pipe( | |
| 149 | Effect.mapError( | |
| 150 | (cause) => | |
| 151 | new EffectAcpErrors.AcpSpawnError({ | |
| 152 | command: options.settings.agentId || ACP_REGISTRY_PROVIDER, | |
| 153 | cause, | |
| 154 | }), | |
| 155 | ), | |
| 156 | ); | |
| 157 | const context = yield* Layer.build( | |
| 158 | AcpSessionRuntime.layer({ | |
| 159 | ...runtimeInput, | |
| 160 | spawn: | |
| 161 | processEnvironment === undefined | |
| 162 | ? resolved.spawn | |
| 163 | : { | |
| 164 | ...resolved.spawn, | |
| 165 | env: { ...resolved.spawn.env, ...processEnvironment }, | |
| 166 | }, | |
| 167 | ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), | |
| 168 | }).pipe( | |
| 169 | Layer.provide( | |
| 170 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner), | |
| 171 | ), | |
| 172 | ), | |
| 173 | ); | |
| 174 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 175 | Effect.provide(context), | |
| 176 | ); | |
| 177 | }); | |
| 178 | } | |
| 179 | ||
| 180 | export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(function* ( | |
| 181 | options: AcpRegistryAdapterV2Options, | |
| 182 | ) { | |
| 183 | const runtimeCoordinator = options.runtimeCoordinator; | |
| 184 | const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; | |
| 185 | const startupKey = | |
| 186 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; | |
| 187 | const isDevin = registryAgentId === "devin"; | |
| 188 | const flavor: AcpAdapterV2Flavor = { | |
| 189 | driver: ACP_REGISTRY_PROVIDER, | |
| 190 | capabilities: AcpProviderCapabilitiesV2, | |
| 191 | promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), | |
| 192 | // Per-agent exceptions (Mistral Vibe, Devin): see the note above | |
| 193 | // registerMistralVibeAcpExtensions before adding any more. | |
| 194 | ...(registryAgentId === "mistral-vibe" | |
| 195 | ? { registerExtensions: registerMistralVibeAcpExtensions } | |
| 196 | : {}), | |
| 197 | ...(isDevin | |
| 198 | ? { | |
| 199 | clientCapabilitiesMeta: { | |
| 200 | "cognition.ai/subagentSupport": true, | |
| 201 | "cognition.ai/messageGrouping": true, | |
| 202 | }, | |
| 203 | normalizeSessionUpdate: normalizeDevinSessionUpdate, | |
| 204 | normalizeToolCall: normalizeDevinToolCall, | |
| 205 | extractSubagentUpdate: extractDevinSubagentUpdate, | |
| 206 | } | |
| 207 | : {}), | |
| 208 | makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options), | |
| 209 | ...(runtimeCoordinator === undefined | |
| 210 | ? {} | |
| 211 | : { | |
| 212 | onAvailableCommandsUpdate: (commands) => | |
| 213 | runtimeCoordinator.publishAvailableCommands( | |
| 214 | options.instanceId, | |
| 215 | normalizeAcpRegistryCommands(commands), | |
| 216 | ), | |
| 217 | onSessionConfigurationUpdate: (configOptions, modeState) => | |
| 218 | runtimeCoordinator.publishLiveConfiguration( | |
| 219 | options.instanceId, | |
| 220 | normalizeAcpRegistryLiveConfiguration(configOptions, modeState), | |
| 221 | ), | |
| 222 | onUrlElicitation: ({ elicitationId, url, message }) => { | |
| 223 | const normalizedUrl = normalizeAcpRegistryWebUrl(url); | |
| 224 | if (normalizedUrl === undefined || elicitationId.trim().length === 0) { | |
| 225 | return Effect.succeed(false); | |
| 226 | } | |
| 227 | return runtimeCoordinator.requestUrlAuthentication(options.instanceId, { | |
| 228 | elicitationId: elicitationId.trim().slice(0, 256), | |
| 229 | url: normalizedUrl, | |
| 230 | message: message.trim().slice(0, 1_024), | |
| 231 | }); | |
| 232 | }, | |
| 233 | withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 234 | runtimeCoordinator.withForegroundStartup(startupKey, effect), | |
| 235 | }), | |
| 236 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | |
| 237 | }; | |
| 238 | return yield* makeAcpAdapterV2({ | |
| 239 | instanceId: options.instanceId, | |
| 240 | flavor, | |
| 241 | selfInvocation: options.selfInvocation, | |
| 242 | // Per-agent exception (see the note above registerMistralVibeAcpExtensions): | |
| 243 | // Devin runs commands through client terminals and has no ask mode over | |
| 244 | // ACP to fall back on. Every other registry agent runs its own. | |
| 245 | ...(isDevin | |
| 246 | ? { | |
| 247 | clientTerminals: { | |
| 248 | childProcessSpawner: options.childProcessSpawner, | |
| 249 | environment: options.environment, | |
| 250 | shellCommands: true, | |
| 251 | }, | |
| 252 | } | |
| 253 | : {}), | |
| 254 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), | |
| 255 | }); | |
| 256 | }); | |
| 257 | ||
| 258 | export type AcpRegistryAdapterV2DriverEnv = | |
| 259 | | ChildProcessSpawner.ChildProcessSpawner | |
| 260 | | Crypto.Crypto | |
| 261 | | FileSystem.FileSystem | |
| 262 | | AcpRegistrySupport.AcpRegistryCatalog | |
| 263 | | IdAllocator.IdAllocatorV2 | |
| 264 | | Path.Path | |
| 265 | | ProviderEventLoggers.ProviderEventLoggers | |
| 266 | | ProviderHost.ProviderHost; | |
| 267 | ||
| 268 | export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | |
| 269 | AcpRegistrySettings, | |
| 270 | AcpRegistryAdapterV2DriverEnv | |
| 271 | > = { | |
| 272 | driverKind: ACP_REGISTRY_PROVIDER, | |
| 273 | configSchema: AcpRegistrySettings, | |
| 274 | defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS, | |
| 275 | create: Effect.fn("AcpRegistryAdapterV2Driver.create")( | |
| 276 | function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) { | |
| 277 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 278 | const selfInvocation = yield* resolveSelfInvocation(); | |
| 279 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 280 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 281 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 282 | const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 283 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 284 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 285 | ); | |
| 286 | return yield* makeAcpRegistryAdapterV2({ | |
| 287 | instanceId: input.instanceId, | |
| 288 | settings: { ...input.config, enabled: input.enabled }, | |
| 289 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 290 | childProcessSpawner, | |
| 291 | resolver, | |
| 292 | ...(Option.isSome(runtimeCoordinator) | |
| 293 | ? { runtimeCoordinator: runtimeCoordinator.value } | |
| 294 | : {}), | |
| 295 | selfInvocation, | |
| 296 | nativeLogging: (threadId) => | |
| 297 | makeNativeLogger({ | |
| 298 | nativeEventLogger: providerEventLoggers.native, | |
| 299 | provider: ACP_REGISTRY_PROVIDER, | |
| 300 | threadId, | |
| 301 | }), | |
| 302 | }); | |
| 303 | }, | |
| 304 | (effect, input) => | |
| 305 | effect.pipe( | |
| 306 | Effect.mapError( | |
| 307 | (cause) => | |
| 308 | new ProviderAdapterDriverCreateError({ | |
| 309 | driver: ACP_REGISTRY_PROVIDER, | |
| 310 | instanceId: input.instanceId, | |
| 311 | detail: "Failed to create ACP Registry adapter.", | |
| 312 | cause, | |
| 313 | }), | |
| 314 | ), | |
| 315 | ), | |
| 316 | ), | |
| 317 | }; |
apps/server/src/orchestration-v2/Adapters/DevinAcp.ts deleted-57| ... | ... | @@ -1,57 +0,0 @@ |
| 1 | import type * as AcpSchema from "effect-acp/compat"; | |
| 2 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; | |
| 3 | import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter"; | |
| 4 | ||
| 5 | function record(value: unknown): Record<string, unknown> | undefined { | |
| 6 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 7 | ? (value as Record<string, unknown>) | |
| 8 | : undefined; | |
| 9 | } | |
| 10 | ||
| 11 | /** Devin's typed extension metadata, captured from its native ACP stream. */ | |
| 12 | export function normalizeDevinSessionUpdate( | |
| 13 | notification: AcpSchema.SessionNotification, | |
| 14 | ): AcpSchema.SessionNotification { | |
| 15 | const update = notification.update; | |
| 16 | const meta = record("_meta" in update ? update._meta : undefined); | |
| 17 | const parentAgentId = record(meta?.["cognition.ai/subagent_context"])?.parentAgentId; | |
| 18 | const routed = | |
| 19 | typeof parentAgentId === "string" && parentAgentId !== "root" | |
| 20 | ? { ...notification, sessionId: parentAgentId } | |
| 21 | : notification; | |
| 22 | const messageId = meta?.["cognition.ai/streamingMessageId"]; | |
| 23 | if ( | |
| 24 | (update.sessionUpdate === "agent_message_chunk" || | |
| 25 | update.sessionUpdate === "agent_thought_chunk") && | |
| 26 | typeof messageId === "string" | |
| 27 | ) { | |
| 28 | return { ...routed, update: { ...update, messageId } }; | |
| 29 | } | |
| 30 | return routed; | |
| 31 | } | |
| 32 | ||
| 33 | export function normalizeDevinToolCall(tool: AcpToolCallState): AcpToolCallState { | |
| 34 | const name = record(tool.data.meta)?.["cognition.ai/inferenceToolName"]; | |
| 35 | return typeof name === "string" && (!tool.title || tool.title === "Tool") | |
| 36 | ? { ...tool, title: name } | |
| 37 | : tool; | |
| 38 | } | |
| 39 | ||
| 40 | export function extractDevinSubagentUpdate( | |
| 41 | tool: AcpToolCallState, | |
| 42 | ): AcpAdapterV2SubagentUpdate | undefined { | |
| 43 | const meta = record(tool.data.meta); | |
| 44 | const completed = record(meta?.["cognition.ai/subagent_completed"]); | |
| 45 | const started = record(meta?.["cognition.ai/subagent_started"]); | |
| 46 | const data = completed ?? started; | |
| 47 | if (typeof data?.agentId !== "string") return undefined; | |
| 48 | return { | |
| 49 | nativeTaskId: data.agentId, | |
| 50 | childSessionId: data.agentId, | |
| 51 | prompt: typeof started?.task === "string" ? started.task : "", | |
| 52 | title: typeof started?.title === "string" ? started.title : null, | |
| 53 | model: typeof started?.model === "string" ? started.model : null, | |
| 54 | status: completed ? (completed.success === false ? "failed" : "completed") : "running", | |
| 55 | result: typeof completed?.summary === "string" ? completed.summary : null, | |
| 56 | }; | |
| 57 | } |
apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts+1-1| ... | ... | @@ -18,7 +18,7 @@ import * as Schema from "effect/Schema"; |
| 18 | 18 | import type * as Scope from "effect/Scope"; |
| 19 | 19 | import * as Stream from "effect/Stream"; |
| 20 | 20 | |
| 21 | import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; | |
| 21 | import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; | |
| 22 | 22 | import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; |
| 23 | 23 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| 24 | 24 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1| ... | ... | @@ -3,7 +3,7 @@ import type { ProviderDriverKind } from "@t3tools/contracts"; |
| 3 | 3 | import { |
| 4 | 4 | AcpRegistryAdapterV2Driver, |
| 5 | 5 | type AcpRegistryAdapterV2DriverEnv, |
| 6 | } from "./Adapters/AcpRegistryAdapterV2.ts"; | |
| 6 | } from "@t3tools/provider-acp-registry/server"; | |
| 7 | 7 | import { |
| 8 | 8 | ClaudeAdapterV2Driver, |
| 9 | 9 | type ClaudeAdapterV2DriverEnv, |
apps/server/src/provider/AcpRegistryCatalog.ts deleted-22| ... | ... | @@ -1,22 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Layer from "effect/Layer"; | |
| 3 | import * as Path from "effect/Path"; | |
| 4 | ||
| 5 | import * as ServerConfig from "../config.ts"; | |
| 6 | import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; | |
| 7 | import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts"; | |
| 8 | ||
| 9 | /** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ | |
| 10 | export const layer = Layer.merge( | |
| 11 | Layer.unwrap( | |
| 12 | Effect.gen(function* () { | |
| 13 | const config = yield* ServerConfig.ServerConfig; | |
| 14 | const path = yield* Path.Path; | |
| 15 | return AcpRegistrySupport.AcpRegistryCatalog.layer({ | |
| 16 | cacheDir: config.providerStatusCacheDir, | |
| 17 | toolsDir: path.join(config.baseDir, "tools"), | |
| 18 | }); | |
| 19 | }), | |
| 20 | ), | |
| 21 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, | |
| 22 | ); |
apps/server/src/provider/AcpRegistryProbe.test.ts created+606| ... | ... | @@ -0,0 +1,606 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodePath from "node:path"; | |
| 3 | import * as NodeURL from "node:url"; | |
| 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 7 | import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; | |
| 8 | import { describe, expect, it } from "@effect/vitest"; | |
| 9 | import * as Deferred from "effect/Deferred"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | import * as EffectAcpErrors from "effect-acp/errors"; | |
| 15 | ||
| 16 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 17 | import { | |
| 18 | acpRegistryProbeFailure, | |
| 19 | acpRegistryProbeResult, | |
| 20 | listAcpRegistrySessions, | |
| 21 | logoutAcpRegistry, | |
| 22 | normalizeAcpRegistryAuthMethods, | |
| 23 | normalizeAcpRegistryCommands, | |
| 24 | probeAcpRegistryConfiguration, | |
| 25 | } from "@t3tools/provider-acp-registry/testing"; | |
| 26 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; | |
| 27 | ||
| 28 | const instanceId = ProviderInstanceId.make("acpRegistry_codex"); | |
| 29 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | |
| 30 | const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); | |
| 31 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 32 | ||
| 33 | describe("ACP Registry probe", () => { | |
| 34 | it("returns advertised auth methods and de-duplicated models", () => { | |
| 35 | const result = acpRegistryProbeResult(instanceId, { | |
| 36 | sessionId: "probe-session", | |
| 37 | initializeResult: { | |
| 38 | protocolVersion: 1, | |
| 39 | agentCapabilities: { | |
| 40 | loadSession: true, | |
| 41 | auth: { logout: {} }, | |
| 42 | sessionCapabilities: { list: {}, resume: {} }, | |
| 43 | }, | |
| 44 | authMethods: [ | |
| 45 | { id: "chatgpt", name: "ChatGPT" }, | |
| 46 | { | |
| 47 | id: "api-key", | |
| 48 | name: "API key", | |
| 49 | type: "env_var", | |
| 50 | vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }], | |
| 51 | }, | |
| 52 | { id: "login", name: "Terminal login", type: "terminal" }, | |
| 53 | ], | |
| 54 | }, | |
| 55 | sessionSetupResult: { | |
| 56 | sessionId: "probe-session", | |
| 57 | configOptions: [ | |
| 58 | { | |
| 59 | id: "model", | |
| 60 | name: "Model", | |
| 61 | category: "model", | |
| 62 | type: "select", | |
| 63 | currentValue: "gpt-5.4", | |
| 64 | options: [ | |
| 65 | { value: "gpt-5.4", name: "GPT-5.4" }, | |
| 66 | { value: "gpt-5.4", name: "Duplicate" }, | |
| 67 | ], | |
| 68 | }, | |
| 69 | ], | |
| 70 | }, | |
| 71 | modelConfigId: "model", | |
| 72 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 73 | ||
| 74 | expect(result).toEqual({ | |
| 75 | instanceId, | |
| 76 | ready: true, | |
| 77 | icon: null, | |
| 78 | authMethods: [ | |
| 79 | { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" }, | |
| 80 | { | |
| 81 | id: "api-key", | |
| 82 | name: "API key", | |
| 83 | description: null, | |
| 84 | type: "env_var", | |
| 85 | envVarNames: ["OPENAI_API_KEY"], | |
| 86 | }, | |
| 87 | { id: "login", name: "Terminal login", description: null, type: "terminal" }, | |
| 88 | ], | |
| 89 | models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }], | |
| 90 | currentModelId: "gpt-5.4", | |
| 91 | configOptions: [], | |
| 92 | sessionManagement: { | |
| 93 | canList: true, | |
| 94 | canLoad: true, | |
| 95 | canResume: true, | |
| 96 | canLogout: true, | |
| 97 | canDelete: false, | |
| 98 | canConfigureProviders: false, | |
| 99 | }, | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | it("composes runnable terminal auth commands from the resolved spawn recipe", () => { | |
| 104 | const methods = normalizeAcpRegistryAuthMethods( | |
| 105 | [ | |
| 106 | { | |
| 107 | id: "login", | |
| 108 | name: "Terminal login", | |
| 109 | type: "terminal", | |
| 110 | args: ["auth", "log in"], | |
| 111 | env: { FORCE_TTY: "1" }, | |
| 112 | }, | |
| 113 | ], | |
| 114 | { command: "/opt/agents/devin", args: ["--acp"] }, | |
| 115 | ); | |
| 116 | ||
| 117 | expect(methods).toEqual([ | |
| 118 | { | |
| 119 | id: "login", | |
| 120 | name: "Terminal login", | |
| 121 | description: null, | |
| 122 | type: "terminal", | |
| 123 | command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'", | |
| 124 | }, | |
| 125 | ]); | |
| 126 | }); | |
| 127 | ||
| 128 | it("omits unsafe truncated auth commands instead of publishing broken shell", () => { | |
| 129 | const methods = normalizeAcpRegistryAuthMethods( | |
| 130 | [ | |
| 131 | { | |
| 132 | id: "login", | |
| 133 | name: "Terminal login", | |
| 134 | type: "terminal", | |
| 135 | args: ["x".repeat(2_048)], | |
| 136 | }, | |
| 137 | ], | |
| 138 | { command: "/opt/agents/devin", args: ["--acp"] }, | |
| 139 | ); | |
| 140 | ||
| 141 | expect(methods[0]).not.toHaveProperty("command"); | |
| 142 | }); | |
| 143 | ||
| 144 | it("omits overlong opaque auth method and environment variable ids", () => { | |
| 145 | const methods = normalizeAcpRegistryAuthMethods([ | |
| 146 | { id: "x".repeat(129), name: "Invalid" }, | |
| 147 | { | |
| 148 | id: "api-key", | |
| 149 | name: "API key", | |
| 150 | type: "env_var", | |
| 151 | vars: [ | |
| 152 | { name: "y".repeat(129), label: "Invalid" }, | |
| 153 | { name: "OPENAI_API_KEY", label: "OpenAI API key" }, | |
| 154 | ], | |
| 155 | }, | |
| 156 | ]); | |
| 157 | ||
| 158 | expect(methods).toEqual([ | |
| 159 | { | |
| 160 | id: "api-key", | |
| 161 | name: "API key", | |
| 162 | description: null, | |
| 163 | type: "env_var", | |
| 164 | envVarNames: ["OPENAI_API_KEY"], | |
| 165 | }, | |
| 166 | ]); | |
| 167 | }); | |
| 168 | ||
| 169 | it("falls back to model-category configuration options", () => { | |
| 170 | const result = acpRegistryProbeResult(instanceId, { | |
| 171 | sessionId: "probe-session", | |
| 172 | initializeResult: { protocolVersion: 1 }, | |
| 173 | sessionSetupResult: { | |
| 174 | sessionId: "probe-session", | |
| 175 | configOptions: [ | |
| 176 | { | |
| 177 | id: "model", | |
| 178 | name: "Model", | |
| 179 | category: "model", | |
| 180 | type: "select", | |
| 181 | currentValue: "sonnet", | |
| 182 | options: [ | |
| 183 | { value: "sonnet", name: "Sonnet", description: "Balanced" }, | |
| 184 | { value: "haiku", name: "Haiku" }, | |
| 185 | ], | |
| 186 | }, | |
| 187 | ], | |
| 188 | }, | |
| 189 | modelConfigId: "model", | |
| 190 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 191 | ||
| 192 | expect(result.models).toEqual([ | |
| 193 | { id: "sonnet", name: "Sonnet", description: "Balanced" }, | |
| 194 | { id: "haiku", name: "Haiku", description: null }, | |
| 195 | ]); | |
| 196 | expect(result.currentModelId).toBe("sonnet"); | |
| 197 | }); | |
| 198 | ||
| 199 | it("omits overlong opaque model ids instead of publishing mutated ids", () => { | |
| 200 | const result = acpRegistryProbeResult(instanceId, { | |
| 201 | sessionId: "probe-session", | |
| 202 | initializeResult: { protocolVersion: 1 }, | |
| 203 | sessionSetupResult: { | |
| 204 | sessionId: "probe-session", | |
| 205 | configOptions: [ | |
| 206 | { | |
| 207 | id: "model", | |
| 208 | name: "Model", | |
| 209 | category: "model", | |
| 210 | type: "select", | |
| 211 | currentValue: "x".repeat(129), | |
| 212 | options: [ | |
| 213 | { value: "x".repeat(129), name: "Too long" }, | |
| 214 | { value: "valid", name: "Valid" }, | |
| 215 | ], | |
| 216 | }, | |
| 217 | ], | |
| 218 | }, | |
| 219 | modelConfigId: "model", | |
| 220 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 221 | ||
| 222 | expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); | |
| 223 | expect(result.currentModelId).toBeNull(); | |
| 224 | }); | |
| 225 | ||
| 226 | it("preserves grouped model values and advertises reasoning choices", () => { | |
| 227 | const codexModel = '["codex","gpt-6.1-sol"]'; | |
| 228 | const goModel = '["opencode-go","deepseek-v4.1-flash"]'; | |
| 229 | const result = acpRegistryProbeResult(instanceId, { | |
| 230 | sessionId: "probe-session", | |
| 231 | initializeResult: { protocolVersion: 1 }, | |
| 232 | sessionSetupResult: { | |
| 233 | sessionId: "probe-session", | |
| 234 | configOptions: [ | |
| 235 | { | |
| 236 | id: "model", | |
| 237 | name: "Model", | |
| 238 | category: "model", | |
| 239 | type: "select", | |
| 240 | currentValue: goModel, | |
| 241 | options: [ | |
| 242 | { | |
| 243 | groupId: "codex", | |
| 244 | name: "Codex", | |
| 245 | options: [{ value: codexModel, name: "GPT-6.1 Sol" }], | |
| 246 | }, | |
| 247 | { | |
| 248 | groupId: "opencode-go", | |
| 249 | name: "OpenCode Go", | |
| 250 | options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }], | |
| 251 | }, | |
| 252 | ], | |
| 253 | }, | |
| 254 | { | |
| 255 | id: "reasoning", | |
| 256 | name: "Reasoning", | |
| 257 | category: "thought_level", | |
| 258 | type: "select", | |
| 259 | currentValue: "high", | |
| 260 | options: [ | |
| 261 | { | |
| 262 | groupId: "effort", | |
| 263 | name: "Effort", | |
| 264 | options: [ | |
| 265 | { value: "medium", name: "Medium" }, | |
| 266 | { value: "high", name: "High" }, | |
| 267 | ], | |
| 268 | }, | |
| 269 | ], | |
| 270 | }, | |
| 271 | ], | |
| 272 | }, | |
| 273 | modelConfigId: "model", | |
| 274 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 275 | ||
| 276 | expect(result.models).toEqual([ | |
| 277 | { id: codexModel, name: "GPT-6.1 Sol", description: null }, | |
| 278 | { id: goModel, name: "DeepSeek V4.1 Flash", description: null }, | |
| 279 | ]); | |
| 280 | expect(result.currentModelId).toBe(goModel); | |
| 281 | expect(result.configOptions).toEqual([ | |
| 282 | { | |
| 283 | id: "reasoning", | |
| 284 | label: "Reasoning", | |
| 285 | type: "select", | |
| 286 | currentValue: "high", | |
| 287 | options: [ | |
| 288 | { id: "medium", label: "Medium" }, | |
| 289 | { id: "high", label: "High" }, | |
| 290 | ], | |
| 291 | }, | |
| 292 | ]); | |
| 293 | }); | |
| 294 | ||
| 295 | it("omits a current model that falls outside the bounded model catalog", () => { | |
| 296 | const result = acpRegistryProbeResult(instanceId, { | |
| 297 | sessionId: "probe-session", | |
| 298 | initializeResult: { protocolVersion: 1 }, | |
| 299 | sessionSetupResult: { | |
| 300 | sessionId: "probe-session", | |
| 301 | configOptions: [ | |
| 302 | { | |
| 303 | id: "model", | |
| 304 | name: "Model", | |
| 305 | category: "model", | |
| 306 | type: "select", | |
| 307 | currentValue: "model-256", | |
| 308 | options: Array.from({ length: 257 }, (_, index) => ({ | |
| 309 | value: `model-${index}`, | |
| 310 | name: `Model ${index}`, | |
| 311 | })), | |
| 312 | }, | |
| 313 | ], | |
| 314 | }, | |
| 315 | modelConfigId: "model", | |
| 316 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 317 | ||
| 318 | expect(result.models).toHaveLength(256); | |
| 319 | expect(result.currentModelId).toBeNull(); | |
| 320 | }); | |
| 321 | ||
| 322 | it("bounds and de-duplicates advertised commands", () => { | |
| 323 | const commands = normalizeAcpRegistryCommands([ | |
| 324 | { | |
| 325 | name: "create_plan", | |
| 326 | description: " Create a plan ", | |
| 327 | input: { type: "text", hint: " topic " }, | |
| 328 | }, | |
| 329 | { name: "CREATE_PLAN", description: "duplicate" }, | |
| 330 | ...Array.from({ length: 140 }, (_, index) => ({ | |
| 331 | name: `command_${index}`, | |
| 332 | description: "", | |
| 333 | })), | |
| 334 | ]); | |
| 335 | ||
| 336 | expect(commands.slashCommands).toHaveLength(128); | |
| 337 | expect(commands.slashCommands[0]).toEqual({ | |
| 338 | name: "create_plan", | |
| 339 | description: "Create a plan", | |
| 340 | input: { hint: "topic" }, | |
| 341 | }); | |
| 342 | expect( | |
| 343 | commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"), | |
| 344 | ).toHaveLength(1); | |
| 345 | expect(commands.skills).toEqual([]); | |
| 346 | }); | |
| 347 | ||
| 348 | it("routes dollar-prefixed ACP commands to the provider skill menu", () => { | |
| 349 | expect( | |
| 350 | normalizeAcpRegistryCommands([ | |
| 351 | { name: "$workspace-skill", description: "Run the workspace skill" }, | |
| 352 | { name: "$WORKSPACE-SKILL", description: "duplicate" }, | |
| 353 | { name: "$", description: "empty" }, | |
| 354 | { name: "review", description: "Review changes" }, | |
| 355 | ]), | |
| 356 | ).toEqual({ | |
| 357 | slashCommands: [{ name: "review", description: "Review changes" }], | |
| 358 | skills: [ | |
| 359 | { | |
| 360 | name: "workspace-skill", | |
| 361 | description: "Run the workspace skill", | |
| 362 | path: "acp://skill/workspace-skill", | |
| 363 | scope: "agent", | |
| 364 | enabled: true, | |
| 365 | }, | |
| 366 | ], | |
| 367 | }); | |
| 368 | }); | |
| 369 | ||
| 370 | it.effect("captures commands advertised just after session creation", () => | |
| 371 | Effect.gen(function* () { | |
| 372 | const result = yield* probeAcpRegistryConfiguration({ | |
| 373 | instanceId, | |
| 374 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 375 | cwd: process.cwd(), | |
| 376 | environment: process.env, | |
| 377 | }); | |
| 378 | ||
| 379 | expect(result.slashCommands).toEqual([ | |
| 380 | { | |
| 381 | name: "review", | |
| 382 | description: "Review the current changes", | |
| 383 | input: { hint: "focus" }, | |
| 384 | }, | |
| 385 | ]); | |
| 386 | expect(result.skills).toEqual([ | |
| 387 | { | |
| 388 | name: "workspace-skill", | |
| 389 | description: "Run the workspace skill", | |
| 390 | path: "acp://skill/workspace-skill", | |
| 391 | scope: "agent", | |
| 392 | enabled: true, | |
| 393 | }, | |
| 394 | ]); | |
| 395 | }).pipe( | |
| 396 | Effect.provideService( | |
| 397 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 398 | AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 399 | search: () => Effect.die("unused search"), | |
| 400 | prepare: () => Effect.die("unused prepare"), | |
| 401 | inspect: () => Effect.die("unused inspect"), | |
| 402 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 403 | resolve: () => | |
| 404 | Effect.succeed({ | |
| 405 | agent: { | |
| 406 | id: "mock-agent", | |
| 407 | name: "Mock Agent", | |
| 408 | version: "1.0.0", | |
| 409 | description: "ACP probe test agent", | |
| 410 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 411 | }, | |
| 412 | distribution: "npx", | |
| 413 | spawn: { | |
| 414 | command: "node", | |
| 415 | args: [mockAgentPath], | |
| 416 | env: { | |
| 417 | ...process.env, | |
| 418 | T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25", | |
| 419 | }, | |
| 420 | }, | |
| 421 | }), | |
| 422 | }), | |
| 423 | ), | |
| 424 | Effect.provide(NodeServices.layer), | |
| 425 | Effect.scoped, | |
| 426 | ), | |
| 427 | ); | |
| 428 | ||
| 429 | it.effect("includes package resolution in the probe timeout", () => | |
| 430 | Effect.gen(function* () { | |
| 431 | const resolveStarted = yield* Deferred.make<void>(); | |
| 432 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 433 | search: () => Effect.die("unused search"), | |
| 434 | prepare: () => Effect.die("unused prepare"), | |
| 435 | inspect: () => Effect.die("unused inspect"), | |
| 436 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 437 | resolve: () => | |
| 438 | Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)), | |
| 439 | }); | |
| 440 | const probe = yield* probeAcpRegistryConfiguration({ | |
| 441 | instanceId, | |
| 442 | settings: decodeSettings({ agentId: "slow-agent" }), | |
| 443 | cwd: process.cwd(), | |
| 444 | environment: process.env, | |
| 445 | }).pipe( | |
| 446 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 447 | Effect.flip, | |
| 448 | Effect.forkChild({ startImmediately: true }), | |
| 449 | ); | |
| 450 | ||
| 451 | yield* Deferred.await(resolveStarted); | |
| 452 | yield* TestClock.adjust("60 seconds"); | |
| 453 | ||
| 454 | expect(yield* Fiber.join(probe)).toMatchObject({ | |
| 455 | reason: "probe_failed", | |
| 456 | message: expect.stringContaining("within 60 seconds"), | |
| 457 | }); | |
| 458 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 459 | ); | |
| 460 | ||
| 461 | it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => { | |
| 462 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 463 | search: () => Effect.die("unused search"), | |
| 464 | prepare: () => Effect.die("unused prepare"), | |
| 465 | inspect: () => Effect.die("unused inspect"), | |
| 466 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 467 | resolve: () => | |
| 468 | Effect.succeed({ | |
| 469 | agent: { | |
| 470 | id: "mock-agent", | |
| 471 | name: "Mock Agent", | |
| 472 | version: "1.0.0", | |
| 473 | description: "ACP session management test agent", | |
| 474 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 475 | }, | |
| 476 | distribution: "npx" as const, | |
| 477 | spawn: { | |
| 478 | command: "node", | |
| 479 | args: [mockAgentPath], | |
| 480 | env: { | |
| 481 | ...process.env, | |
| 482 | T3_ACP_SESSION_LIFECYCLE: "1", | |
| 483 | T3_ACP_AUTH_METHOD_ID: "mock-login", | |
| 484 | }, | |
| 485 | }, | |
| 486 | }), | |
| 487 | }); | |
| 488 | const input = { | |
| 489 | instanceId, | |
| 490 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 491 | cwd: process.cwd(), | |
| 492 | environment: process.env, | |
| 493 | }; | |
| 494 | ||
| 495 | return Effect.gen(function* () { | |
| 496 | const listed = yield* listAcpRegistrySessions(input); | |
| 497 | expect(listed).toMatchObject({ | |
| 498 | canLoad: true, | |
| 499 | canResume: true, | |
| 500 | nextCursor: null, | |
| 501 | sessions: [ | |
| 502 | { | |
| 503 | sessionId: "mock-session-1", | |
| 504 | cwd: process.cwd(), | |
| 505 | importedThreadId: null, | |
| 506 | }, | |
| 507 | ], | |
| 508 | }); | |
| 509 | yield* logoutAcpRegistry(input); | |
| 510 | }).pipe( | |
| 511 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 512 | Effect.provide(NodeServices.layer), | |
| 513 | Effect.scoped, | |
| 514 | ); | |
| 515 | }); | |
| 516 | ||
| 517 | it.effect("preserves the ACP method error for unsupported session management", () => { | |
| 518 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 519 | search: () => Effect.die("unused search"), | |
| 520 | prepare: () => Effect.die("unused prepare"), | |
| 521 | inspect: () => Effect.die("unused inspect"), | |
| 522 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 523 | resolve: () => | |
| 524 | Effect.succeed({ | |
| 525 | agent: { | |
| 526 | id: "mock-agent", | |
| 527 | name: "Mock Agent", | |
| 528 | version: "1.0.0", | |
| 529 | description: "ACP unsupported management test agent", | |
| 530 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 531 | }, | |
| 532 | distribution: "npx" as const, | |
| 533 | spawn: { | |
| 534 | command: "node", | |
| 535 | args: [mockAgentPath], | |
| 536 | env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" }, | |
| 537 | }, | |
| 538 | }), | |
| 539 | }); | |
| 540 | ||
| 541 | return Effect.gen(function* () { | |
| 542 | const failure = yield* listAcpRegistrySessions({ | |
| 543 | instanceId, | |
| 544 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 545 | cwd: process.cwd(), | |
| 546 | environment: process.env, | |
| 547 | }).pipe(Effect.flip); | |
| 548 | ||
| 549 | expect(failure).toMatchObject({ | |
| 550 | reason: "session_list_unsupported", | |
| 551 | message: "The ACP agent does not advertise session listing.", | |
| 552 | cause: { _tag: "AcpRequestError", code: -32601 }, | |
| 553 | }); | |
| 554 | }).pipe( | |
| 555 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 556 | Effect.provide(NodeServices.layer), | |
| 557 | Effect.scoped, | |
| 558 | ); | |
| 559 | }); | |
| 560 | ||
| 561 | it("distinguishes authentication failures from generic probe failures", () => { | |
| 562 | const advertised = normalizeAcpRegistryAuthMethods([ | |
| 563 | { id: "grok-login", name: "Log in with Grok" }, | |
| 564 | ]); | |
| 565 | const cause = new EffectAcpErrors.AcpRequestError({ | |
| 566 | code: -32000, | |
| 567 | errorMessage: "login required", | |
| 568 | }); | |
| 569 | const authFailure = acpRegistryProbeFailure(cause, advertised); | |
| 570 | expect(authFailure.reason).toBe("authentication_failed"); | |
| 571 | expect(authFailure.message).toBe("The ACP agent could not complete authentication."); | |
| 572 | expect(authFailure.cause).toBe(cause); | |
| 573 | expect(authFailure.authMethods).toEqual([ | |
| 574 | { | |
| 575 | id: "grok-login", | |
| 576 | name: "Log in with Grok", | |
| 577 | description: null, | |
| 578 | type: "agent", | |
| 579 | }, | |
| 580 | ]); | |
| 581 | expect( | |
| 582 | acpRegistryProbeFailure( | |
| 583 | new EffectAcpErrors.AcpTransportError({ | |
| 584 | detail: "Authentication required", | |
| 585 | cause: "credentials missing", | |
| 586 | }), | |
| 587 | ).reason, | |
| 588 | ).toBe("authentication_failed"); | |
| 589 | const genericCause = new EffectAcpErrors.AcpTransportError({ | |
| 590 | detail: "connection closed", | |
| 591 | cause: "closed", | |
| 592 | }); | |
| 593 | const genericFailure = acpRegistryProbeFailure(genericCause); | |
| 594 | expect(genericFailure.reason).toBe("probe_failed"); | |
| 595 | expect(genericFailure.message).toBe("The ACP agent could not create a test session."); | |
| 596 | expect(genericFailure.cause).toBe(genericCause); | |
| 597 | expect( | |
| 598 | acpRegistryProbeFailure( | |
| 599 | new EffectAcpErrors.AcpTransportError({ | |
| 600 | detail: "Failed while logging request", | |
| 601 | cause: "logging failed", | |
| 602 | }), | |
| 603 | ).reason, | |
| 604 | ).toBe("probe_failed"); | |
| 605 | }); | |
| 606 | }); |
apps/server/src/provider/CodexChatGptAuth.ts+2-2| ... | ... | @@ -19,8 +19,8 @@ import * as Effect from "effect/Effect"; |
| 19 | 19 | import * as Option from "effect/Option"; |
| 20 | 20 | import * as Schema from "effect/Schema"; |
| 21 | 21 | import * as Semaphore from "effect/Semaphore"; |
| 22 | import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; | |
| 23 | import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts"; | |
| 22 | import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; | |
| 23 | import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/ProviderAuthFlow"; | |
| 24 | 24 | import { HttpClient, HttpClientRequest } from "effect/http"; |
| 25 | 25 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 26 | 26 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; |
apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts deleted-459| ... | ... | @@ -1,459 +0,0 @@ |
| 1 | import { | |
| 2 | AcpRegistryOperationError, | |
| 3 | AcpRegistrySettings, | |
| 4 | ProviderInstanceId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "@effect/vitest"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | ||
| 11 | import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; | |
| 12 | import { | |
| 13 | acpRegistrySnapshotReadiness, | |
| 14 | applyAcpRegistryAvailableCommands, | |
| 15 | applyAcpRegistryLiveConfiguration, | |
| 16 | buildCheckedAcpRegistrySnapshot, | |
| 17 | checkAcpRegistryProviderReadiness, | |
| 18 | checkAcpRegistryProviderStatus, | |
| 19 | } from "./AcpRegistryDriver.ts"; | |
| 20 | ||
| 21 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 22 | const identity = { | |
| 23 | instanceId: ProviderInstanceId.make("acpRegistry_test"), | |
| 24 | displayName: "Test ACP", | |
| 25 | accentColor: undefined, | |
| 26 | continuationKey: "acpRegistry:instance:acpRegistry_test", | |
| 27 | }; | |
| 28 | const noSessionManagement = { | |
| 29 | canList: false, | |
| 30 | canLoad: false, | |
| 31 | canResume: false, | |
| 32 | canLogout: false, | |
| 33 | canDelete: false, | |
| 34 | canConfigureProviders: false, | |
| 35 | } as const; | |
| 36 | ||
| 37 | function catalogWithInspection( | |
| 38 | inspection: AcpRegistrySupport.AcpRegistryInspection, | |
| 39 | ): AcpRegistrySupport.AcpRegistryCatalog["Service"] { | |
| 40 | return { | |
| 41 | search: () => Effect.die("unused search"), | |
| 42 | prepare: () => Effect.die("unused prepare"), | |
| 43 | inspect: () => Effect.succeed(inspection), | |
| 44 | resolve: () => Effect.die("unused resolve"), | |
| 45 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | describe("acpRegistrySnapshotReadiness", () => { | |
| 50 | it("treats a live empty command advertisement as an authoritative replacement", () => { | |
| 51 | const provider = buildCheckedAcpRegistrySnapshot({ | |
| 52 | ...identity, | |
| 53 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 54 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 55 | inspection: { | |
| 56 | status: "ready", | |
| 57 | agentId: "test-agent", | |
| 58 | version: "1.0.0", | |
| 59 | distribution: "npx", | |
| 60 | documentationUrl: "https://example.test/agent/setup", | |
| 61 | }, | |
| 62 | probe: { | |
| 63 | probe: { | |
| 64 | instanceId: identity.instanceId, | |
| 65 | ready: true, | |
| 66 | icon: null, | |
| 67 | authMethods: [], | |
| 68 | models: [], | |
| 69 | currentModelId: null, | |
| 70 | configOptions: [], | |
| 71 | sessionManagement: noSessionManagement, | |
| 72 | }, | |
| 73 | slashCommands: [{ name: "stale" }], | |
| 74 | skills: [{ name: "stale-skill", path: "stale", enabled: true }], | |
| 75 | }, | |
| 76 | }); | |
| 77 | ||
| 78 | const replaced = applyAcpRegistryAvailableCommands( | |
| 79 | provider, | |
| 80 | Option.some({ slashCommands: [], skills: [] }), | |
| 81 | ); | |
| 82 | expect(replaced.slashCommands).toEqual([]); | |
| 83 | expect(replaced.skills).toEqual([]); | |
| 84 | expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([ | |
| 85 | { name: "stale" }, | |
| 86 | ]); | |
| 87 | expect(provider.iconUrl).toBe( | |
| 88 | "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg", | |
| 89 | ); | |
| 90 | expect(provider.setup).toMatchObject({ | |
| 91 | canAuthenticate: false, | |
| 92 | documentationUrl: "https://example.test/agent/setup", | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 96 | it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => { | |
| 97 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 98 | ...identity, | |
| 99 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 100 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 101 | inspection: { | |
| 102 | status: "ready", | |
| 103 | agentId: "test-agent", | |
| 104 | version: "1.0.0", | |
| 105 | distribution: "binary", | |
| 106 | }, | |
| 107 | probe: { | |
| 108 | probe: { | |
| 109 | instanceId: identity.instanceId, | |
| 110 | ready: true, | |
| 111 | icon: null, | |
| 112 | authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }], | |
| 113 | models: [], | |
| 114 | currentModelId: null, | |
| 115 | configOptions: [], | |
| 116 | sessionManagement: noSessionManagement, | |
| 117 | }, | |
| 118 | slashCommands: [], | |
| 119 | skills: [], | |
| 120 | }, | |
| 121 | }); | |
| 122 | expect(snapshot.auth.status).toBe("unknown"); | |
| 123 | expect(snapshot.setup?.canAuthenticate).toBe(true); | |
| 124 | expect( | |
| 125 | applyAcpRegistryLiveConfiguration( | |
| 126 | snapshot, | |
| 127 | { models: [], currentModelId: null, configOptions: [] }, | |
| 128 | [], | |
| 129 | ).auth.status, | |
| 130 | ).toBe("unknown"); | |
| 131 | }); | |
| 132 | ||
| 133 | it("overlays live configuration without dropping probe-owned session capabilities", () => { | |
| 134 | const provider = buildCheckedAcpRegistrySnapshot({ | |
| 135 | ...identity, | |
| 136 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 137 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 138 | inspection: { | |
| 139 | status: "ready", | |
| 140 | agentId: "test-agent", | |
| 141 | version: "1.0.0", | |
| 142 | distribution: "npx", | |
| 143 | }, | |
| 144 | probe: { | |
| 145 | probe: { | |
| 146 | instanceId: identity.instanceId, | |
| 147 | ready: true, | |
| 148 | icon: null, | |
| 149 | authMethods: [], | |
| 150 | models: [{ id: "probe-model", name: "Probe model", description: null }], | |
| 151 | currentModelId: "probe-model", | |
| 152 | configOptions: [], | |
| 153 | sessionManagement: { | |
| 154 | canList: true, | |
| 155 | canLoad: true, | |
| 156 | canResume: true, | |
| 157 | canLogout: true, | |
| 158 | canDelete: true, | |
| 159 | canConfigureProviders: true, | |
| 160 | }, | |
| 161 | }, | |
| 162 | slashCommands: [], | |
| 163 | skills: [], | |
| 164 | }, | |
| 165 | }); | |
| 166 | ||
| 167 | expect( | |
| 168 | applyAcpRegistryLiveConfiguration( | |
| 169 | provider, | |
| 170 | { | |
| 171 | models: [{ id: "live-model", name: "Live model", description: null }], | |
| 172 | currentModelId: "live-model", | |
| 173 | configOptions: [], | |
| 174 | }, | |
| 175 | [], | |
| 176 | ), | |
| 177 | ).toMatchObject({ | |
| 178 | auth: { status: "unknown", canLogout: true }, | |
| 179 | nativeSessions: { canList: true, canLoad: true, canResume: true }, | |
| 180 | models: [{ slug: "live-model", isDefault: true }], | |
| 181 | }); | |
| 182 | }); | |
| 183 | ||
| 184 | it("maps registry inspection status to provider readiness", () => { | |
| 185 | expect( | |
| 186 | acpRegistrySnapshotReadiness({ | |
| 187 | status: "ready", | |
| 188 | agentId: "gemini-cli", | |
| 189 | version: "1.2.3", | |
| 190 | distribution: "npx", | |
| 191 | }), | |
| 192 | ).toEqual({ installed: true, version: "1.2.3", status: "ready" }); | |
| 193 | ||
| 194 | expect( | |
| 195 | acpRegistrySnapshotReadiness({ | |
| 196 | status: "missing_runner", | |
| 197 | agentId: "gemini-cli", | |
| 198 | version: "1.2.3", | |
| 199 | distribution: "npx", | |
| 200 | runner: "npx", | |
| 201 | }), | |
| 202 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); | |
| 203 | ||
| 204 | expect( | |
| 205 | acpRegistrySnapshotReadiness({ | |
| 206 | status: "missing_runner", | |
| 207 | version: null, | |
| 208 | distribution: "local", | |
| 209 | }), | |
| 210 | ).toEqual({ | |
| 211 | installed: false, | |
| 212 | version: null, | |
| 213 | status: "error", | |
| 214 | message: "Local ACP executable is not available on this environment's PATH.", | |
| 215 | }); | |
| 216 | ||
| 217 | expect( | |
| 218 | acpRegistrySnapshotReadiness({ | |
| 219 | status: "unprepared", | |
| 220 | agentId: "zed-agent", | |
| 221 | version: "2.0.0", | |
| 222 | distribution: "binary", | |
| 223 | }), | |
| 224 | ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" }); | |
| 225 | ||
| 226 | expect( | |
| 227 | acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }), | |
| 228 | ).toEqual({ | |
| 229 | installed: false, | |
| 230 | version: null, | |
| 231 | status: "error", | |
| 232 | message: "Registry unavailable.", | |
| 233 | }); | |
| 234 | }); | |
| 235 | ||
| 236 | it("projects discovery without claiming authentication when no login methods are advertised", () => { | |
| 237 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 238 | ...identity, | |
| 239 | settings: decodeSettings({ | |
| 240 | agentId: "test-agent", | |
| 241 | customModels: [" custom-model ", "gpt-discovered"], | |
| 242 | }), | |
| 243 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 244 | inspection: { | |
| 245 | status: "ready", | |
| 246 | agentId: "test-agent", | |
| 247 | version: "1.0.0", | |
| 248 | distribution: "npx", | |
| 249 | }, | |
| 250 | probe: { | |
| 251 | probe: { | |
| 252 | instanceId: identity.instanceId, | |
| 253 | ready: true, | |
| 254 | icon: null, | |
| 255 | authMethods: [], | |
| 256 | models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }], | |
| 257 | currentModelId: "gpt-discovered", | |
| 258 | configOptions: [], | |
| 259 | sessionManagement: noSessionManagement, | |
| 260 | }, | |
| 261 | slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }], | |
| 262 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 263 | }, | |
| 264 | }); | |
| 265 | ||
| 266 | expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false }); | |
| 267 | expect(snapshot.supportsTextGeneration).toBe(false); | |
| 268 | expect( | |
| 269 | snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({ | |
| 270 | slug, | |
| 271 | name, | |
| 272 | isCustom, | |
| 273 | isDefault, | |
| 274 | })), | |
| 275 | ).toEqual([ | |
| 276 | { | |
| 277 | slug: "gpt-discovered", | |
| 278 | name: "GPT Discovered", | |
| 279 | isCustom: false, | |
| 280 | isDefault: true, | |
| 281 | }, | |
| 282 | { | |
| 283 | slug: "custom-model", | |
| 284 | name: "custom-model", | |
| 285 | isCustom: true, | |
| 286 | isDefault: undefined, | |
| 287 | }, | |
| 288 | ]); | |
| 289 | expect(snapshot.slashCommands).toEqual([ | |
| 290 | { name: "plan", description: "Create a plan", input: { hint: "topic" } }, | |
| 291 | ]); | |
| 292 | expect(snapshot.skills).toEqual([ | |
| 293 | { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }, | |
| 294 | ]); | |
| 295 | }); | |
| 296 | ||
| 297 | it("keeps a default model only when the agent advertises none", () => { | |
| 298 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 299 | ...identity, | |
| 300 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 301 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 302 | inspection: { | |
| 303 | status: "ready", | |
| 304 | agentId: "test-agent", | |
| 305 | version: "1.0.0", | |
| 306 | distribution: "uvx", | |
| 307 | }, | |
| 308 | probe: { | |
| 309 | probe: { | |
| 310 | instanceId: identity.instanceId, | |
| 311 | ready: true, | |
| 312 | icon: null, | |
| 313 | authMethods: [], | |
| 314 | models: [], | |
| 315 | currentModelId: null, | |
| 316 | configOptions: [], | |
| 317 | sessionManagement: noSessionManagement, | |
| 318 | }, | |
| 319 | slashCommands: [], | |
| 320 | skills: [], | |
| 321 | }, | |
| 322 | }); | |
| 323 | ||
| 324 | expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); | |
| 325 | expect(snapshot.models[0]?.isDefault).toBe(true); | |
| 326 | }); | |
| 327 | ||
| 328 | it.each(["registry", "local"] as const)( | |
| 329 | "reports failed authentication after successful %s inspection", | |
| 330 | (source) => { | |
| 331 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 332 | ...identity, | |
| 333 | settings: decodeSettings({ | |
| 334 | source, | |
| 335 | ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), | |
| 336 | authMethodId: "grok-login", | |
| 337 | }), | |
| 338 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 339 | inspection: { | |
| 340 | status: "ready", | |
| 341 | agentId: "test-agent", | |
| 342 | version: source === "local" ? null : "1.0.0", | |
| 343 | distribution: source === "local" ? "local" : "binary", | |
| 344 | }, | |
| 345 | probeError: new AcpRegistryOperationError({ | |
| 346 | reason: "authentication_failed", | |
| 347 | message: "Login required.", | |
| 348 | authMethods: [ | |
| 349 | { | |
| 350 | id: "api-key", | |
| 351 | name: "API key", | |
| 352 | description: null, | |
| 353 | type: "env_var", | |
| 354 | }, | |
| 355 | { | |
| 356 | id: "grok-login", | |
| 357 | name: "Log in with Grok", | |
| 358 | description: null, | |
| 359 | type: "agent", | |
| 360 | }, | |
| 361 | ], | |
| 362 | }), | |
| 363 | }); | |
| 364 | ||
| 365 | expect(snapshot).toMatchObject({ | |
| 366 | installed: true, | |
| 367 | version: source === "local" ? null : "1.0.0", | |
| 368 | status: "warning", | |
| 369 | setup: { canAuthenticate: true }, | |
| 370 | auth: { | |
| 371 | status: "unauthenticated", | |
| 372 | type: "agent", | |
| 373 | label: "Log in with Grok", | |
| 374 | }, | |
| 375 | message: 'Sign in in provider settings using "Log in with Grok".', | |
| 376 | }); | |
| 377 | }, | |
| 378 | ); | |
| 379 | ||
| 380 | it.effect("runs the disposable probe only after local inspection is ready", () => | |
| 381 | Effect.gen(function* () { | |
| 382 | const settings = decodeSettings({ agentId: "test-agent" }); | |
| 383 | const environment = { PATH: "/provider/bin" }; | |
| 384 | let receivedEnvironment: NodeJS.ProcessEnv | undefined; | |
| 385 | const snapshot = yield* checkAcpRegistryProviderStatus( | |
| 386 | { | |
| 387 | ...identity, | |
| 388 | settings, | |
| 389 | cwd: "/workspace", | |
| 390 | environment, | |
| 391 | }, | |
| 392 | (input) => | |
| 393 | Effect.sync(() => { | |
| 394 | receivedEnvironment = input.environment; | |
| 395 | return { | |
| 396 | probe: { | |
| 397 | instanceId: identity.instanceId, | |
| 398 | ready: true as const, | |
| 399 | icon: null, | |
| 400 | authMethods: [], | |
| 401 | models: [{ id: "agent-model", name: "Agent Model", description: null }], | |
| 402 | currentModelId: "agent-model", | |
| 403 | configOptions: [], | |
| 404 | sessionManagement: noSessionManagement, | |
| 405 | }, | |
| 406 | slashCommands: [{ name: "review" }], | |
| 407 | skills: [], | |
| 408 | }; | |
| 409 | }), | |
| 410 | ).pipe( | |
| 411 | Effect.provideService( | |
| 412 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 413 | catalogWithInspection({ | |
| 414 | status: "ready", | |
| 415 | agentId: "test-agent", | |
| 416 | version: "1.0.0", | |
| 417 | distribution: "npx", | |
| 418 | }), | |
| 419 | ), | |
| 420 | ); | |
| 421 | ||
| 422 | expect(receivedEnvironment).toBe(environment); | |
| 423 | expect(snapshot).toMatchObject({ | |
| 424 | auth: { status: "unknown" }, | |
| 425 | models: [{ slug: "agent-model" }], | |
| 426 | slashCommands: [{ name: "review" }], | |
| 427 | skills: [], | |
| 428 | }); | |
| 429 | }), | |
| 430 | ); | |
| 431 | ||
| 432 | it.effect("publishes concrete local readiness before background ACP discovery", () => | |
| 433 | Effect.gen(function* () { | |
| 434 | const snapshot = yield* checkAcpRegistryProviderReadiness({ | |
| 435 | ...identity, | |
| 436 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 437 | environment: { PATH: "/provider/bin" }, | |
| 438 | }).pipe( | |
| 439 | Effect.provideService( | |
| 440 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 441 | catalogWithInspection({ | |
| 442 | status: "ready", | |
| 443 | agentId: "test-agent", | |
| 444 | version: "1.0.0", | |
| 445 | distribution: "npx", | |
| 446 | }), | |
| 447 | ), | |
| 448 | ); | |
| 449 | ||
| 450 | expect(snapshot).toMatchObject({ | |
| 451 | installed: true, | |
| 452 | status: "ready", | |
| 453 | version: "1.0.0", | |
| 454 | auth: { status: "unknown" }, | |
| 455 | message: "Checking ACP authentication, models, and commands in the background...", | |
| 456 | }); | |
| 457 | }), | |
| 458 | ); | |
| 459 | }); |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts deleted-906| ... | ... | @@ -1,906 +0,0 @@ |
| 1 | import { | |
| 2 | AcpRegistrySettings, | |
| 3 | officialAcpRegistryIconUrlForAgentId, | |
| 4 | ProviderDriverKind, | |
| 5 | resolveOfficialAcpRegistryIconUrl, | |
| 6 | TextGenerationError, | |
| 7 | type AcpRegistryOperationError, | |
| 8 | type ServerProvider, | |
| 9 | type ServerProviderModel, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 12 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 13 | import * as Clock from "effect/Clock"; | |
| 14 | import * as Crypto from "effect/Crypto"; | |
| 15 | import * as DateTime from "effect/DateTime"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Ref from "effect/Ref"; | |
| 19 | import * as Result from "effect/Result"; | |
| 20 | import * as Schema from "effect/Schema"; | |
| 21 | import * as Semaphore from "effect/Semaphore"; | |
| 22 | import { ChildProcessSpawner } from "effect/process"; | |
| 23 | ||
| 24 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 25 | import { | |
| 26 | AcpRegistryAdapterV2Driver, | |
| 27 | type AcpRegistryAdapterV2DriverEnv, | |
| 28 | } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; | |
| 29 | import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | |
| 30 | import { ProviderDriverError } from "../Errors.ts"; | |
| 31 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 32 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 33 | import { | |
| 34 | defaultProviderContinuationIdentity, | |
| 35 | type ProviderDriver, | |
| 36 | type ProviderInstance, | |
| 37 | } from "@t3tools/provider-core/server/driver"; | |
| 38 | import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 39 | import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 40 | import { | |
| 41 | haveProviderSnapshotSettingsChanged, | |
| 42 | makeProviderSnapshotSettingsSource, | |
| 43 | type ProviderSnapshotSettings, | |
| 44 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 45 | import { | |
| 46 | type AcpRegistryAvailableCommands, | |
| 47 | type AcpRegistryConfigurationProbe, | |
| 48 | type AcpRegistryConfigurationProbeResult, | |
| 49 | type AcpRegistryLiveConfiguration, | |
| 50 | deleteAcpRegistrySession, | |
| 51 | disableAcpRegistryProvider, | |
| 52 | listAcpRegistrySessions, | |
| 53 | listAcpRegistryProviders, | |
| 54 | logoutAcpRegistry, | |
| 55 | probeAcpRegistryConfiguration, | |
| 56 | setAcpRegistryProvider, | |
| 57 | } from "../acp/AcpRegistryProbe.ts"; | |
| 58 | import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; | |
| 59 | import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts"; | |
| 60 | import * as AcpRegistryAuth from "../acp/AcpRegistryAuth.ts"; | |
| 61 | import * as AcpRegistryAuthenticationState from "../acp/AcpRegistryAuthenticationState.ts"; | |
| 62 | ||
| 63 | const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); | |
| 64 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 65 | // How long a successful discovery probe stays valid. Periodic health | |
| 66 | // refreshes reuse it instead of spawning a fresh disposable ACP session; | |
| 67 | // failed or unauthenticated probes are never cached so a completed sign-in | |
| 68 | // is detected on the next refresh. | |
| 69 | const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000; | |
| 70 | const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | |
| 71 | const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 72 | provider: DRIVER_KIND, | |
| 73 | packageName: null, | |
| 74 | }); | |
| 75 | ||
| 76 | const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => { | |
| 77 | const unsupported = (operation: string) => | |
| 78 | Effect.fail( | |
| 79 | new TextGenerationError({ | |
| 80 | operation, | |
| 81 | detail: "ACP Registry instances do not provide application text generation.", | |
| 82 | }), | |
| 83 | ); | |
| 84 | return { | |
| 85 | generateCommitMessage: () => unsupported("generateCommitMessage"), | |
| 86 | generatePrContent: () => unsupported("generatePrContent"), | |
| 87 | generateBranchName: () => unsupported("generateBranchName"), | |
| 88 | generateThreadTitle: () => unsupported("generateThreadTitle"), | |
| 89 | }; | |
| 90 | }; | |
| 91 | ||
| 92 | function modelsFromDiscovery( | |
| 93 | discovery: | |
| 94 | | Pick<AcpRegistryLiveConfiguration, "models" | "currentModelId" | "configOptions"> | |
| 95 | | undefined, | |
| 96 | customModels: ReadonlyArray<string>, | |
| 97 | ): ReadonlyArray<ServerProviderModel> { | |
| 98 | const discovered = discovery?.models ?? []; | |
| 99 | // Discovered session config options and modes ride on every model so the | |
| 100 | // composer's generic option controls can drive them per thread. | |
| 101 | const capabilities = | |
| 102 | discovery === undefined || discovery.configOptions.length === 0 | |
| 103 | ? EMPTY_CAPABILITIES | |
| 104 | : createModelCapabilities({ optionDescriptors: discovery.configOptions }); | |
| 105 | const builtInModels: ReadonlyArray<ServerProviderModel> = | |
| 106 | discovered.length === 0 | |
| 107 | ? [ | |
| 108 | { | |
| 109 | slug: "default", | |
| 110 | name: "Default", | |
| 111 | isCustom: false, | |
| 112 | isDefault: true, | |
| 113 | capabilities, | |
| 114 | }, | |
| 115 | ] | |
| 116 | : discovered.map((model) => ({ | |
| 117 | slug: model.id, | |
| 118 | name: model.name, | |
| 119 | isCustom: false, | |
| 120 | ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}), | |
| 121 | capabilities, | |
| 122 | })); | |
| 123 | return providerModelsFromSettings(builtInModels, customModels, capabilities); | |
| 124 | } | |
| 125 | ||
| 126 | export function acpRegistrySnapshotReadiness( | |
| 127 | inspection: | |
| 128 | | AcpRegistrySupport.AcpRegistryInspection | |
| 129 | | { readonly status: "failed"; readonly message: string }, | |
| 130 | ): Pick<ServerProvider, "installed" | "version" | "status" | "message"> { | |
| 131 | switch (inspection.status) { | |
| 132 | case "ready": | |
| 133 | return { installed: true, version: inspection.version, status: "ready" }; | |
| 134 | case "unconfigured": | |
| 135 | return { | |
| 136 | installed: false, | |
| 137 | version: null, | |
| 138 | status: "warning", | |
| 139 | message: | |
| 140 | "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", | |
| 141 | }; | |
| 142 | case "not_found": | |
| 143 | return { | |
| 144 | installed: false, | |
| 145 | version: null, | |
| 146 | status: "error", | |
| 147 | message: `ACP Registry does not contain agent '${inspection.agentId}'.`, | |
| 148 | }; | |
| 149 | case "unsupported": | |
| 150 | return { | |
| 151 | installed: false, | |
| 152 | version: inspection.version, | |
| 153 | status: "error", | |
| 154 | message: `ACP Registry agent '${inspection.agentId}' has no compatible distribution for this environment.`, | |
| 155 | }; | |
| 156 | case "missing_runner": | |
| 157 | return { | |
| 158 | installed: false, | |
| 159 | version: inspection.version, | |
| 160 | status: "error", | |
| 161 | message: | |
| 162 | inspection.distribution === "local" | |
| 163 | ? "Local ACP executable is not available on this environment's PATH." | |
| 164 | : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, | |
| 165 | }; | |
| 166 | case "unprepared": | |
| 167 | return { | |
| 168 | installed: false, | |
| 169 | version: inspection.version, | |
| 170 | status: "warning", | |
| 171 | message: `ACP Registry agent '${inspection.agentId}' has not been prepared on this environment.`, | |
| 172 | }; | |
| 173 | case "failed": | |
| 174 | return { | |
| 175 | installed: false, | |
| 176 | version: null, | |
| 177 | status: "error", | |
| 178 | message: inspection.message, | |
| 179 | }; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | interface SnapshotIdentity { | |
| 184 | readonly instanceId: ProviderInstance["instanceId"]; | |
| 185 | readonly displayName: string | undefined; | |
| 186 | readonly accentColor: string | undefined; | |
| 187 | readonly continuationKey: string; | |
| 188 | } | |
| 189 | ||
| 190 | function baseSnapshot( | |
| 191 | input: SnapshotIdentity & { | |
| 192 | readonly settings: AcpRegistrySettings; | |
| 193 | readonly checkedAt: string; | |
| 194 | readonly installed: boolean; | |
| 195 | readonly version: string | null; | |
| 196 | readonly status: ServerProvider["status"]; | |
| 197 | readonly auth: ServerProvider["auth"]; | |
| 198 | readonly documentationUrl?: string; | |
| 199 | readonly message?: string; | |
| 200 | readonly probe?: AcpRegistryConfigurationProbeResult; | |
| 201 | readonly probeError?: AcpRegistryOperationError; | |
| 202 | }, | |
| 203 | ): ServerProvider { | |
| 204 | const iconUrl = | |
| 205 | input.settings.source === "local" | |
| 206 | ? null | |
| 207 | : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | |
| 208 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); | |
| 209 | return { | |
| 210 | instanceId: input.instanceId, | |
| 211 | driver: DRIVER_KIND, | |
| 212 | ...(input.displayName ? { displayName: input.displayName } : {}), | |
| 213 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 214 | ...(iconUrl ? { iconUrl } : {}), | |
| 215 | continuation: { groupKey: input.continuationKey }, | |
| 216 | // The registry driver rejects every application text-generation operation, | |
| 217 | // so selectors must not offer these instances for commit, PR, branch, or | |
| 218 | // title generation. | |
| 219 | supportsTextGeneration: false, | |
| 220 | enabled: input.settings.enabled, | |
| 221 | installed: input.installed, | |
| 222 | version: input.version, | |
| 223 | status: input.settings.enabled ? input.status : "disabled", | |
| 224 | auth: { ...input.auth, canLogout: input.auth.canLogout ?? false }, | |
| 225 | checkedAt: input.checkedAt, | |
| 226 | setup: { | |
| 227 | canInstall: false, | |
| 228 | ...(input.documentationUrl ? { documentationUrl: input.documentationUrl } : {}), | |
| 229 | canAuthenticate: | |
| 230 | input.installed && | |
| 231 | (input.probe | |
| 232 | ? input.probe.probe.authMethods.length > 0 | |
| 233 | : input.settings.source === "local" | |
| 234 | ? (input.probeError?.authMethods?.length ?? 0) > 0 | |
| 235 | : input.settings.agentId.length > 0), | |
| 236 | }, | |
| 237 | ...(input.message ? { message: input.message } : {}), | |
| 238 | models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), | |
| 239 | ...(input.probe === undefined | |
| 240 | ? {} | |
| 241 | : { | |
| 242 | nativeSessions: { | |
| 243 | canList: input.probe.probe.sessionManagement.canList, | |
| 244 | canLoad: input.probe.probe.sessionManagement.canLoad, | |
| 245 | canResume: input.probe.probe.sessionManagement.canResume, | |
| 246 | canDelete: input.probe.probe.sessionManagement.canDelete, | |
| 247 | }, | |
| 248 | configurableProviders: input.probe.probe.sessionManagement.canConfigureProviders, | |
| 249 | }), | |
| 250 | slashCommands: input.probe?.slashCommands ?? [], | |
| 251 | skills: input.probe?.skills ?? [], | |
| 252 | }; | |
| 253 | } | |
| 254 | ||
| 255 | export function applyAcpRegistryAvailableCommands( | |
| 256 | provider: ServerProvider, | |
| 257 | commands: Option.Option<AcpRegistryAvailableCommands>, | |
| 258 | ): ServerProvider { | |
| 259 | return Option.match(commands, { | |
| 260 | onNone: () => provider, | |
| 261 | onSome: ({ slashCommands, skills }) => ({ ...provider, slashCommands, skills }), | |
| 262 | }); | |
| 263 | } | |
| 264 | ||
| 265 | export function applyAcpRegistryLiveConfiguration( | |
| 266 | provider: ServerProvider, | |
| 267 | configuration: AcpRegistryLiveConfiguration, | |
| 268 | customModels: ReadonlyArray<string>, | |
| 269 | ): ServerProvider { | |
| 270 | const { message: _staleProbeMessage, ...snapshot } = provider; | |
| 271 | return { | |
| 272 | ...snapshot, | |
| 273 | status: provider.enabled ? "ready" : provider.status, | |
| 274 | models: modelsFromDiscovery(configuration, customModels), | |
| 275 | }; | |
| 276 | } | |
| 277 | ||
| 278 | function applyAcpRegistryUrlAuthAction( | |
| 279 | provider: ServerProvider, | |
| 280 | action: Option.Option<NonNullable<ServerProvider["auth"]["action"]>>, | |
| 281 | ): ServerProvider { | |
| 282 | const { action: _previousAction, ...auth } = provider.auth; | |
| 283 | return { | |
| 284 | ...provider, | |
| 285 | auth: Option.match(action, { | |
| 286 | onNone: () => auth, | |
| 287 | onSome: (nextAction) => ({ ...auth, action: nextAction }), | |
| 288 | }), | |
| 289 | }; | |
| 290 | } | |
| 291 | ||
| 292 | const buildInitialAcpRegistrySnapshot = Effect.fn("AcpRegistryDriver.buildInitialSnapshot")( | |
| 293 | function* (input: SnapshotIdentity & { readonly settings: AcpRegistrySettings }) { | |
| 294 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 295 | return baseSnapshot({ | |
| 296 | ...input, | |
| 297 | checkedAt, | |
| 298 | installed: false, | |
| 299 | version: null, | |
| 300 | status: "warning", | |
| 301 | auth: { status: "unknown" }, | |
| 302 | message: input.settings.enabled | |
| 303 | ? "Checking ACP Registry agent readiness..." | |
| 304 | : "ACP Registry is disabled in T3 Code settings.", | |
| 305 | }); | |
| 306 | }, | |
| 307 | ); | |
| 308 | ||
| 309 | export function buildCheckedAcpRegistrySnapshot( | |
| 310 | input: SnapshotIdentity & { | |
| 311 | readonly settings: AcpRegistrySettings; | |
| 312 | readonly checkedAt: string; | |
| 313 | readonly inspection: | |
| 314 | | AcpRegistrySupport.AcpRegistryInspection | |
| 315 | | { readonly status: "failed"; readonly message: string }; | |
| 316 | readonly probe?: AcpRegistryConfigurationProbeResult; | |
| 317 | readonly probeError?: AcpRegistryOperationError; | |
| 318 | }, | |
| 319 | ): ServerProvider { | |
| 320 | const readiness = acpRegistrySnapshotReadiness(input.inspection); | |
| 321 | const probeFailed = input.probeError !== undefined; | |
| 322 | const advertisedAuthMethod = | |
| 323 | input.probeError?.reason === "authentication_failed" | |
| 324 | ? (input.probeError.authMethods?.find( | |
| 325 | (method) => method.id === input.settings.authMethodId, | |
| 326 | ) ?? input.probeError.authMethods?.[0]) | |
| 327 | : undefined; | |
| 328 | const authenticationMessage = advertisedAuthMethod | |
| 329 | ? advertisedAuthMethod.type === "terminal" && advertisedAuthMethod.command | |
| 330 | ? `Sign in in provider settings using "${advertisedAuthMethod.name}". The login terminal runs on this environment.` | |
| 331 | : advertisedAuthMethod.type === "env_var" && | |
| 332 | (advertisedAuthMethod.envVarNames?.length ?? 0) > 0 | |
| 333 | ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.` | |
| 334 | : `Sign in in provider settings using "${advertisedAuthMethod.name}".` | |
| 335 | : undefined; | |
| 336 | return baseSnapshot({ | |
| 337 | ...input, | |
| 338 | ...(input.inspection.status === "ready" && input.inspection.documentationUrl | |
| 339 | ? { documentationUrl: input.inspection.documentationUrl } | |
| 340 | : {}), | |
| 341 | installed: readiness.installed, | |
| 342 | version: readiness.version, | |
| 343 | // A failed discovery probe on a ready installation is a warning, not an | |
| 344 | // outage: the agent binary is present and a real turn may still work | |
| 345 | // (for example after the user completes authentication). | |
| 346 | status: probeFailed ? "warning" : readiness.status, | |
| 347 | auth: input.probe | |
| 348 | ? { | |
| 349 | // Discovery sessions and an empty auth-method list do not prove sign-in. | |
| 350 | status: "unknown", | |
| 351 | canLogout: input.probe.probe.sessionManagement.canLogout, | |
| 352 | } | |
| 353 | : input.probeError?.reason === "authentication_failed" | |
| 354 | ? { | |
| 355 | status: "unauthenticated", | |
| 356 | ...(advertisedAuthMethod | |
| 357 | ? { type: advertisedAuthMethod.type, label: advertisedAuthMethod.name } | |
| 358 | : {}), | |
| 359 | ...(input.probeError.authAction === undefined | |
| 360 | ? {} | |
| 361 | : { action: input.probeError.authAction }), | |
| 362 | } | |
| 363 | : { status: "unknown" }, | |
| 364 | ...(input.probeError | |
| 365 | ? { message: authenticationMessage ?? input.probeError.message } | |
| 366 | : readiness.message | |
| 367 | ? { message: readiness.message } | |
| 368 | : {}), | |
| 369 | }); | |
| 370 | } | |
| 371 | ||
| 372 | export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")( | |
| 373 | function* <Requirements>( | |
| 374 | input: SnapshotIdentity & { | |
| 375 | readonly settings: AcpRegistrySettings; | |
| 376 | readonly cwd: string; | |
| 377 | readonly environment: NodeJS.ProcessEnv; | |
| 378 | }, | |
| 379 | probeConfiguration: AcpRegistryConfigurationProbe<Requirements>, | |
| 380 | ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog | Requirements> { | |
| 381 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 382 | if (!input.settings.enabled) { | |
| 383 | return yield* buildInitialAcpRegistrySnapshot(input); | |
| 384 | } | |
| 385 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 386 | const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); | |
| 387 | if (Result.isFailure(inspected)) { | |
| 388 | return buildCheckedAcpRegistrySnapshot({ | |
| 389 | ...input, | |
| 390 | checkedAt, | |
| 391 | inspection: { | |
| 392 | status: "failed", | |
| 393 | message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, | |
| 394 | }, | |
| 395 | }); | |
| 396 | } | |
| 397 | if (inspected.success.status !== "ready") { | |
| 398 | return buildCheckedAcpRegistrySnapshot({ | |
| 399 | ...input, | |
| 400 | checkedAt, | |
| 401 | inspection: inspected.success, | |
| 402 | }); | |
| 403 | } | |
| 404 | const probed = yield* Effect.result( | |
| 405 | probeConfiguration({ | |
| 406 | instanceId: input.instanceId, | |
| 407 | settings: input.settings, | |
| 408 | cwd: input.cwd, | |
| 409 | environment: input.environment, | |
| 410 | }), | |
| 411 | ); | |
| 412 | return Result.isFailure(probed) | |
| 413 | ? buildCheckedAcpRegistrySnapshot({ | |
| 414 | ...input, | |
| 415 | checkedAt, | |
| 416 | inspection: inspected.success, | |
| 417 | probeError: probed.failure, | |
| 418 | }) | |
| 419 | : buildCheckedAcpRegistrySnapshot({ | |
| 420 | ...input, | |
| 421 | checkedAt, | |
| 422 | inspection: inspected.success, | |
| 423 | probe: probed.success, | |
| 424 | }); | |
| 425 | }, | |
| 426 | ); | |
| 427 | ||
| 428 | /** Publishes local executable readiness without starting an ACP process. */ | |
| 429 | export const checkAcpRegistryProviderReadiness = Effect.fn( | |
| 430 | "AcpRegistryDriver.checkProviderReadiness", | |
| 431 | )(function* ( | |
| 432 | input: SnapshotIdentity & { | |
| 433 | readonly settings: AcpRegistrySettings; | |
| 434 | readonly environment: NodeJS.ProcessEnv; | |
| 435 | }, | |
| 436 | ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog> { | |
| 437 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 438 | if (!input.settings.enabled) { | |
| 439 | return yield* buildInitialAcpRegistrySnapshot(input); | |
| 440 | } | |
| 441 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 442 | const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); | |
| 443 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 444 | ...input, | |
| 445 | checkedAt, | |
| 446 | inspection: Result.isFailure(inspected) | |
| 447 | ? { | |
| 448 | status: "failed", | |
| 449 | message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, | |
| 450 | } | |
| 451 | : inspected.success, | |
| 452 | }); | |
| 453 | return inspected._tag === "Success" && inspected.success.status === "ready" | |
| 454 | ? { | |
| 455 | ...snapshot, | |
| 456 | message: "Checking ACP authentication, models, and commands in the background...", | |
| 457 | } | |
| 458 | : snapshot; | |
| 459 | }); | |
| 460 | ||
| 461 | export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost; | |
| 462 | ||
| 463 | /** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */ | |
| 464 | export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryDriverEnv> = { | |
| 465 | driverKind: DRIVER_KIND, | |
| 466 | metadata: { | |
| 467 | displayName: "ACP Registry", | |
| 468 | supportsMultipleInstances: true, | |
| 469 | hasDefaultInstance: false, | |
| 470 | }, | |
| 471 | configSchema: AcpRegistrySettings, | |
| 472 | defaultConfig: () => decodeSettings({}), | |
| 473 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 474 | Effect.gen(function* () { | |
| 475 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 476 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 477 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 478 | ); | |
| 479 | if (Option.isSome(runtimeCoordinator)) { | |
| 480 | yield* runtimeCoordinator.value.clearAvailableCommands(instanceId); | |
| 481 | yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId); | |
| 482 | yield* Effect.addFinalizer(() => | |
| 483 | runtimeCoordinator.value | |
| 484 | .clearAvailableCommands(instanceId) | |
| 485 | .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))), | |
| 486 | ); | |
| 487 | } | |
| 488 | const crypto = yield* Crypto.Crypto; | |
| 489 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 490 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 491 | const host = yield* ProviderHost; | |
| 492 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 493 | driverKind: DRIVER_KIND, | |
| 494 | instanceId, | |
| 495 | }); | |
| 496 | const identity = { | |
| 497 | instanceId, | |
| 498 | displayName, | |
| 499 | accentColor, | |
| 500 | continuationKey: continuationIdentity.continuationKey, | |
| 501 | }; | |
| 502 | const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; | |
| 503 | const processEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); | |
| 504 | const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({ | |
| 505 | instanceId, | |
| 506 | displayName, | |
| 507 | accentColor, | |
| 508 | environment, | |
| 509 | enabled, | |
| 510 | config, | |
| 511 | }).pipe( | |
| 512 | Effect.mapError( | |
| 513 | (cause) => | |
| 514 | new ProviderDriverError({ | |
| 515 | driver: DRIVER_KIND, | |
| 516 | instanceId, | |
| 517 | detail: "Failed to build ACP Registry orchestration adapter.", | |
| 518 | cause, | |
| 519 | }), | |
| 520 | ), | |
| 521 | ); | |
| 522 | const readinessInput = { | |
| 523 | ...identity, | |
| 524 | settings: effectiveConfig, | |
| 525 | environment: processEnvironment, | |
| 526 | }; | |
| 527 | const confirmedAuthentication = | |
| 528 | yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ | |
| 529 | cacheDir: host.paths.providerStatusCacheDir, | |
| 530 | instanceId, | |
| 531 | settings: effectiveConfig, | |
| 532 | environment, | |
| 533 | processEnvironment, | |
| 534 | }); | |
| 535 | const withLiveRuntimeState = (input: ServerProvider) => | |
| 536 | Effect.gen(function* () { | |
| 537 | if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false); | |
| 538 | const provider = | |
| 539 | input.enabled && | |
| 540 | input.installed && | |
| 541 | input.auth.status === "unknown" && | |
| 542 | (yield* confirmedAuthentication.get) | |
| 543 | ? { ...input, auth: { ...input.auth, status: "authenticated" as const } } | |
| 544 | : input; | |
| 545 | return yield* Option.isNone(runtimeCoordinator) | |
| 546 | ? Effect.succeed(provider) | |
| 547 | : Effect.all({ | |
| 548 | commands: runtimeCoordinator.value.getAvailableCommands(instanceId), | |
| 549 | configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId), | |
| 550 | authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId), | |
| 551 | }).pipe( | |
| 552 | Effect.map(({ commands, configuration, authAction }) => { | |
| 553 | const withCommands = applyAcpRegistryAvailableCommands(provider, commands); | |
| 554 | const withConfiguration = Option.match(configuration, { | |
| 555 | onNone: () => withCommands, | |
| 556 | onSome: (liveConfiguration) => | |
| 557 | applyAcpRegistryLiveConfiguration( | |
| 558 | withCommands, | |
| 559 | liveConfiguration, | |
| 560 | effectiveConfig.customModels, | |
| 561 | ), | |
| 562 | }); | |
| 563 | return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); | |
| 564 | }), | |
| 565 | ); | |
| 566 | }); | |
| 567 | const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe( | |
| 568 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 569 | Effect.flatMap(withLiveRuntimeState), | |
| 570 | ); | |
| 571 | const enrichProvider = checkAcpRegistryProviderStatus( | |
| 572 | { | |
| 573 | ...readinessInput, | |
| 574 | cwd: host.paths.cwd, | |
| 575 | }, | |
| 576 | probeAcpRegistryConfiguration, | |
| 577 | ).pipe( | |
| 578 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 579 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 580 | Effect.provideService(Crypto.Crypto, crypto), | |
| 581 | ); | |
| 582 | const enrichmentCache = yield* Ref.make<{ | |
| 583 | readonly generation: number; | |
| 584 | readonly entry: { | |
| 585 | readonly provider: ServerProvider; | |
| 586 | readonly expiresAt: number; | |
| 587 | } | null; | |
| 588 | }>({ generation: 0, entry: null }); | |
| 589 | const liveSnapshotSemaphore = yield* Semaphore.make(1); | |
| 590 | const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ | |
| 591 | generation: current.generation + 1, | |
| 592 | entry: null, | |
| 593 | })); | |
| 594 | const enrichProviderCached = (baseSnapshot: ServerProvider) => | |
| 595 | Effect.gen(function* () { | |
| 596 | const now = yield* Clock.currentTimeMillis; | |
| 597 | const cacheState = yield* Ref.get(enrichmentCache); | |
| 598 | const cached = cacheState.entry; | |
| 599 | if ( | |
| 600 | cached !== null && | |
| 601 | cached.expiresAt > now && | |
| 602 | cached.provider.version === baseSnapshot.version | |
| 603 | ) { | |
| 604 | return { | |
| 605 | provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt }, | |
| 606 | generation: cacheState.generation, | |
| 607 | }; | |
| 608 | } | |
| 609 | const enriched = yield* enrichProvider; | |
| 610 | if (enriched.status === "ready") { | |
| 611 | yield* Ref.update(enrichmentCache, (current) => | |
| 612 | current.generation === cacheState.generation | |
| 613 | ? { | |
| 614 | ...current, | |
| 615 | entry: { | |
| 616 | provider: enriched, | |
| 617 | expiresAt: now + PROBE_SUCCESS_TTL_MS, | |
| 618 | }, | |
| 619 | } | |
| 620 | : current, | |
| 621 | ); | |
| 622 | } | |
| 623 | return { provider: enriched, generation: cacheState.generation }; | |
| 624 | }); | |
| 625 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 626 | const snapshot = yield* makeManagedServerProvider< | |
| 627 | ProviderSnapshotSettings<AcpRegistrySettings> | |
| 628 | >({ | |
| 629 | resolveMaintenance: () => Effect.succeed(MAINTENANCE), | |
| 630 | getSettings: snapshotSettings.getSettings, | |
| 631 | streamSettings: snapshotSettings.streamSettings, | |
| 632 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 633 | initialSnapshot: () => checkProvider, | |
| 634 | checkProvider, | |
| 635 | enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => { | |
| 636 | if (!snapshot.installed) return Effect.void; | |
| 637 | const publishEnrichment = ( | |
| 638 | Option.isSome(runtimeCoordinator) | |
| 639 | ? runtimeCoordinator.value.runBackgroundProbe( | |
| 640 | effectiveConfig.source === "local" | |
| 641 | ? `local:${instanceId}` | |
| 642 | : effectiveConfig.agentId, | |
| 643 | enrichProviderCached(snapshot), | |
| 644 | ) | |
| 645 | : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) | |
| 646 | ).pipe( | |
| 647 | Effect.flatMap( | |
| 648 | Option.match({ | |
| 649 | onNone: () => Effect.void, | |
| 650 | onSome: ({ provider, generation }) => | |
| 651 | liveSnapshotSemaphore.withPermit( | |
| 652 | Ref.get(enrichmentCache).pipe( | |
| 653 | Effect.flatMap((current) => | |
| 654 | current.generation === generation | |
| 655 | ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot)) | |
| 656 | : Effect.void, | |
| 657 | ), | |
| 658 | ), | |
| 659 | ), | |
| 660 | }), | |
| 661 | ), | |
| 662 | ); | |
| 663 | if (Option.isNone(runtimeCoordinator)) return publishEnrichment; | |
| 664 | ||
| 665 | const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands( | |
| 666 | instanceId, | |
| 667 | ({ slashCommands, skills }) => | |
| 668 | liveSnapshotSemaphore.withPermit( | |
| 669 | getSnapshot.pipe( | |
| 670 | Effect.flatMap((current) => | |
| 671 | publishSnapshot({ | |
| 672 | ...current, | |
| 673 | slashCommands, | |
| 674 | skills, | |
| 675 | }), | |
| 676 | ), | |
| 677 | ), | |
| 678 | ), | |
| 679 | ); | |
| 680 | const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration( | |
| 681 | instanceId, | |
| 682 | (configuration) => | |
| 683 | liveSnapshotSemaphore.withPermit( | |
| 684 | getSnapshot.pipe( | |
| 685 | Effect.flatMap((current) => | |
| 686 | publishSnapshot( | |
| 687 | applyAcpRegistryLiveConfiguration( | |
| 688 | current, | |
| 689 | configuration, | |
| 690 | effectiveConfig.customModels, | |
| 691 | ), | |
| 692 | ), | |
| 693 | ), | |
| 694 | ), | |
| 695 | ), | |
| 696 | ); | |
| 697 | const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction( | |
| 698 | instanceId, | |
| 699 | (action) => | |
| 700 | liveSnapshotSemaphore.withPermit( | |
| 701 | getSnapshot.pipe( | |
| 702 | Effect.flatMap((current) => | |
| 703 | publishSnapshot( | |
| 704 | applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)), | |
| 705 | ), | |
| 706 | ), | |
| 707 | ), | |
| 708 | ), | |
| 709 | ); | |
| 710 | return Effect.all( | |
| 711 | [ | |
| 712 | publishEnrichment, | |
| 713 | publishLiveCommands, | |
| 714 | publishLiveConfiguration, | |
| 715 | publishUrlAuthAction, | |
| 716 | ], | |
| 717 | { | |
| 718 | concurrency: "unbounded", | |
| 719 | discard: true, | |
| 720 | }, | |
| 721 | ); | |
| 722 | }, | |
| 723 | }).pipe( | |
| 724 | Effect.mapError( | |
| 725 | (cause) => | |
| 726 | new ProviderDriverError({ | |
| 727 | driver: DRIVER_KIND, | |
| 728 | instanceId, | |
| 729 | detail: "Failed to build the ACP Registry provider snapshot.", | |
| 730 | cause, | |
| 731 | }), | |
| 732 | ), | |
| 733 | ); | |
| 734 | ||
| 735 | // The management probes read the coordinator via serviceOption, so it is | |
| 736 | // provided only when this driver instance actually has one. | |
| 737 | const provideAcpManagementServices = <A, E>( | |
| 738 | effect: Effect.Effect< | |
| 739 | A, | |
| 740 | E, | |
| 741 | | AcpRegistrySupport.AcpRegistryCatalog | |
| 742 | | ChildProcessSpawner.ChildProcessSpawner | |
| 743 | | Crypto.Crypto | |
| 744 | >, | |
| 745 | ): Effect.Effect<A, E> => { | |
| 746 | const provided = effect.pipe( | |
| 747 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 748 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 749 | Effect.provideService(Crypto.Crypto, crypto), | |
| 750 | ); | |
| 751 | return Option.isSome(runtimeCoordinator) | |
| 752 | ? provided.pipe( | |
| 753 | Effect.provideService( | |
| 754 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 755 | runtimeCoordinator.value, | |
| 756 | ), | |
| 757 | ) | |
| 758 | : provided; | |
| 759 | }; | |
| 760 | ||
| 761 | const clearLiveState = Option.isSome(runtimeCoordinator) | |
| 762 | ? runtimeCoordinator.value | |
| 763 | .clearLiveConfiguration(instanceId) | |
| 764 | .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId))) | |
| 765 | : Effect.void; | |
| 766 | const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ | |
| 767 | instanceId, | |
| 768 | settings: effectiveConfig, | |
| 769 | cwd: host.paths.cwd, | |
| 770 | environment: processEnvironment, | |
| 771 | onChanged: (authenticated) => | |
| 772 | confirmedAuthentication | |
| 773 | .set(authenticated) | |
| 774 | .pipe( | |
| 775 | Effect.andThen(authenticated ? Effect.void : clearLiveState), | |
| 776 | Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), | |
| 777 | Effect.andThen(snapshot.refresh), | |
| 778 | Effect.asVoid, | |
| 779 | ), | |
| 780 | }).pipe( | |
| 781 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 782 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 783 | Effect.provideService(Crypto.Crypto, crypto), | |
| 784 | ); | |
| 785 | const auth = { | |
| 786 | ...controller, | |
| 787 | invalidate: (controller.invalidate ?? Effect.void).pipe( | |
| 788 | Effect.andThen(confirmedAuthentication.set(false)), | |
| 789 | Effect.andThen(clearLiveState), | |
| 790 | Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), | |
| 791 | Effect.andThen(snapshot.refresh), | |
| 792 | Effect.asVoid, | |
| 793 | ), | |
| 794 | }; | |
| 795 | ||
| 796 | return { | |
| 797 | instanceId, | |
| 798 | driverKind: DRIVER_KIND, | |
| 799 | continuationIdentity, | |
| 800 | displayName, | |
| 801 | accentColor, | |
| 802 | enabled, | |
| 803 | auth, | |
| 804 | snapshot: { | |
| 805 | ...snapshot, | |
| 806 | refresh: snapshot.refresh.pipe( | |
| 807 | Effect.tap(() => controller.refreshMethods ?? Effect.void), | |
| 808 | ), | |
| 809 | }, | |
| 810 | orchestrationAdapter, | |
| 811 | textGeneration: makeUnsupportedTextGeneration(), | |
| 812 | acpSessionManagement: { | |
| 813 | listSessions: ({ cwd, cursor }) => | |
| 814 | provideAcpManagementServices( | |
| 815 | listAcpRegistrySessions({ | |
| 816 | instanceId, | |
| 817 | settings: effectiveConfig, | |
| 818 | cwd, | |
| 819 | environment: processEnvironment, | |
| 820 | ...(cursor === undefined ? {} : { cursor }), | |
| 821 | }), | |
| 822 | ), | |
| 823 | deleteSession: ({ cwd, sessionId }) => | |
| 824 | provideAcpManagementServices( | |
| 825 | deleteAcpRegistrySession({ | |
| 826 | instanceId, | |
| 827 | settings: effectiveConfig, | |
| 828 | cwd, | |
| 829 | environment: processEnvironment, | |
| 830 | sessionId, | |
| 831 | }), | |
| 832 | ), | |
| 833 | listProviders: (cwd) => | |
| 834 | provideAcpManagementServices( | |
| 835 | listAcpRegistryProviders({ | |
| 836 | instanceId, | |
| 837 | settings: effectiveConfig, | |
| 838 | cwd, | |
| 839 | environment: processEnvironment, | |
| 840 | }), | |
| 841 | ), | |
| 842 | setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) => | |
| 843 | provideAcpManagementServices( | |
| 844 | setAcpRegistryProvider({ | |
| 845 | instanceId, | |
| 846 | settings: effectiveConfig, | |
| 847 | cwd, | |
| 848 | environment: processEnvironment, | |
| 849 | providerId, | |
| 850 | apiType, | |
| 851 | baseUrl, | |
| 852 | ...(headers === undefined ? {} : { headers }), | |
| 853 | }), | |
| 854 | ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), | |
| 855 | disableProvider: ({ cwd, providerId }) => | |
| 856 | provideAcpManagementServices( | |
| 857 | disableAcpRegistryProvider({ | |
| 858 | instanceId, | |
| 859 | settings: effectiveConfig, | |
| 860 | cwd, | |
| 861 | environment: processEnvironment, | |
| 862 | providerId, | |
| 863 | }), | |
| 864 | ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), | |
| 865 | logout: (cwd) => { | |
| 866 | const logout = logoutAcpRegistry({ | |
| 867 | instanceId, | |
| 868 | settings: effectiveConfig, | |
| 869 | cwd, | |
| 870 | environment: processEnvironment, | |
| 871 | }).pipe( | |
| 872 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 873 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 874 | Effect.provideService(Crypto.Crypto, crypto), | |
| 875 | Effect.tap(() => confirmedAuthentication.set(false)), | |
| 876 | Effect.tap(() => | |
| 877 | liveSnapshotSemaphore.withPermit( | |
| 878 | invalidateEnrichmentCache.pipe( | |
| 879 | Effect.andThen( | |
| 880 | Option.isSome(runtimeCoordinator) | |
| 881 | ? Effect.all( | |
| 882 | [ | |
| 883 | runtimeCoordinator.value.clearAvailableCommands(instanceId), | |
| 884 | runtimeCoordinator.value.clearLiveConfiguration(instanceId), | |
| 885 | ], | |
| 886 | { concurrency: "unbounded", discard: true }, | |
| 887 | ) | |
| 888 | : Effect.void, | |
| 889 | ), | |
| 890 | ), | |
| 891 | ), | |
| 892 | ), | |
| 893 | ); | |
| 894 | return Option.isSome(runtimeCoordinator) | |
| 895 | ? logout.pipe( | |
| 896 | Effect.provideService( | |
| 897 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 898 | runtimeCoordinator.value, | |
| 899 | ), | |
| 900 | ) | |
| 901 | : logout; | |
| 902 | }, | |
| 903 | }, | |
| 904 | } satisfies ProviderInstance; | |
| 905 | }), | |
| 906 | }; |
apps/server/src/provider/ProviderAuthFlow.test.ts deleted-559| ... | ... | @@ -1,559 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | ProviderInstanceId, | |
| 5 | ProviderSetupError, | |
| 6 | type ProviderAuthInteraction, | |
| 7 | type ProviderAuthResponse, | |
| 8 | type ProviderAuthState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Scope from "effect/Scope"; | |
| 13 | import * as Deferred from "effect/Deferred"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as TestClock from "effect/testing/TestClock"; | |
| 18 | ||
| 19 | import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; | |
| 20 | ||
| 21 | const instanceId = ProviderInstanceId.make("auth-flow-test"); | |
| 22 | const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; | |
| 23 | ||
| 24 | it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>(); | |
| 27 | const controller = yield* ProviderAuthFlow.make({ | |
| 28 | instanceId, | |
| 29 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 30 | methods: Deferred.await(discovered), | |
| 31 | authenticate: () => Effect.void, | |
| 32 | logout: Effect.void, | |
| 33 | }); | |
| 34 | const pending = yield* controller | |
| 35 | .subscribe("owner") | |
| 36 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 37 | assert.isUndefined(pending.methods); | |
| 38 | yield* Deferred.succeed(discovered, []); | |
| 39 | const ready = yield* controller.subscribe("owner").pipe( | |
| 40 | Stream.filter((state) => state.methods !== undefined), | |
| 41 | Stream.runHead, | |
| 42 | Effect.map(Option.getOrThrow), | |
| 43 | ); | |
| 44 | assert.deepEqual(ready.methods, []); | |
| 45 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 46 | ); | |
| 47 | ||
| 48 | const makeHarness = Effect.gen(function* () { | |
| 49 | const approved = yield* Deferred.make<void>(); | |
| 50 | const verified = yield* Deferred.make<void>(); | |
| 51 | const started = yield* Deferred.make<void>(); | |
| 52 | let attempts = 0; | |
| 53 | const controller = yield* ProviderAuthFlow.make({ | |
| 54 | instanceId, | |
| 55 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 56 | methods: Effect.succeed([method]), | |
| 57 | authenticate: (_, context) => | |
| 58 | Effect.gen(function* () { | |
| 59 | attempts++; | |
| 60 | yield* context.setInteraction( | |
| 61 | { | |
| 62 | type: "browser", | |
| 63 | id: "consent", | |
| 64 | url: "https://example.com/login", | |
| 65 | requiresConsent: true, | |
| 66 | }, | |
| 67 | (response) => | |
| 68 | response.type === "browser" && response.action === "accept" | |
| 69 | ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) | |
| 70 | : Effect.void, | |
| 71 | ); | |
| 72 | yield* Deferred.succeed(started, undefined); | |
| 73 | yield* Deferred.await(approved); | |
| 74 | yield* context.verifying; | |
| 75 | yield* Deferred.await(verified); | |
| 76 | }), | |
| 77 | logout: Effect.void, | |
| 78 | }); | |
| 79 | const phase = (phase: ProviderAuthState["phase"], owner = "owner") => | |
| 80 | controller.subscribe(owner).pipe( | |
| 81 | Stream.filter((state) => state.phase === phase), | |
| 82 | Stream.runHead, | |
| 83 | Effect.map(Option.getOrThrow), | |
| 84 | ); | |
| 85 | return { controller, phase, started, verified, attempts: () => attempts }; | |
| 86 | }); | |
| 87 | ||
| 88 | it.effect("requires owner consent and provider verification before success", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const { controller, phase, started, verified, attempts } = yield* makeHarness; | |
| 91 | const state = yield* controller.start("owner"); | |
| 92 | yield* Deferred.await(started); | |
| 93 | const response = { | |
| 94 | instanceId, | |
| 95 | flowId: state.flowId!, | |
| 96 | interactionId: "consent", | |
| 97 | response: { type: "browser" as const, action: "accept" as const }, | |
| 98 | }; | |
| 99 | const other = yield* phase("waiting", "other"); | |
| 100 | assert.isNull(other.interaction); | |
| 101 | assert.isNull(other.flowId); | |
| 102 | assert.isTrue( | |
| 103 | (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", | |
| 104 | ); | |
| 105 | assert.isTrue( | |
| 106 | (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( | |
| 107 | Effect.result, | |
| 108 | ))._tag === "Failure", | |
| 109 | ); | |
| 110 | yield* controller.start("owner"); | |
| 111 | assert.strictEqual(attempts(), 1); | |
| 112 | yield* controller.respond!("owner", response); | |
| 113 | yield* phase("verifying"); | |
| 114 | yield* Deferred.succeed(verified, undefined); | |
| 115 | assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); | |
| 116 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("cancellation rejects late consent and permits another login", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const { controller, started, phase } = yield* makeHarness; | |
| 122 | const state = yield* controller.start("owner"); | |
| 123 | yield* Deferred.await(started); | |
| 124 | yield* controller.cancel("owner", state.flowId!); | |
| 125 | assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); | |
| 126 | assert.isTrue( | |
| 127 | (yield* controller.respond!("owner", { | |
| 128 | instanceId, | |
| 129 | flowId: state.flowId!, | |
| 130 | interactionId: "consent", | |
| 131 | response: { type: "browser", action: "accept" }, | |
| 132 | }).pipe(Effect.result))._tag === "Failure", | |
| 133 | ); | |
| 134 | const next = yield* controller.start("other"); | |
| 135 | assert.notStrictEqual(next.flowId, state.flowId); | |
| 136 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("expires pending login and rejects its response", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const { controller, started, phase } = yield* makeHarness; | |
| 142 | const state = yield* controller.start("owner"); | |
| 143 | yield* Deferred.await(started); | |
| 144 | yield* TestClock.adjust(300_001); | |
| 145 | assert.include((yield* phase("failed")).message ?? "", "expired"); | |
| 146 | assert.isTrue( | |
| 147 | (yield* controller.respond!("owner", { | |
| 148 | instanceId, | |
| 149 | flowId: state.flowId!, | |
| 150 | interactionId: "consent", | |
| 151 | response: { type: "browser", action: "accept" }, | |
| 152 | }).pipe(Effect.result))._tag === "Failure", | |
| 153 | ); | |
| 154 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("closes admitted provider processes and blocks new ones while login is pending", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const { controller, started } = yield* makeHarness; | |
| 160 | let closed = false; | |
| 161 | yield* controller.withAccess!( | |
| 162 | Effect.addFinalizer(() => | |
| 163 | Effect.sync(() => { | |
| 164 | closed = true; | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 168 | yield* controller.start("owner"); | |
| 169 | yield* Deferred.await(started); | |
| 170 | assert.isTrue(closed); | |
| 171 | assert.isTrue( | |
| 172 | (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", | |
| 173 | ); | |
| 174 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const { controller } = yield* makeHarness; | |
| 180 | let closed = false; | |
| 181 | yield* controller.withAccess!( | |
| 182 | Effect.addFinalizer(() => | |
| 183 | Effect.sync(() => { | |
| 184 | closed = true; | |
| 185 | }), | |
| 186 | ), | |
| 187 | ); | |
| 188 | yield* controller.invalidate!; | |
| 189 | assert.isTrue(closed); | |
| 190 | assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); | |
| 191 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect.each([ | |
| 195 | { | |
| 196 | interaction: { type: "terminal", id: "terminal", output: "Log in" }, | |
| 197 | response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, | |
| 198 | }, | |
| 199 | { | |
| 200 | interaction: { | |
| 201 | type: "credentials", | |
| 202 | id: "credentials", | |
| 203 | fields: [{ name: "token", label: "Token", secret: true }], | |
| 204 | }, | |
| 205 | response: { type: "credentials", values: { token: "private-token" } }, | |
| 206 | }, | |
| 207 | ] satisfies ReadonlyArray<{ | |
| 208 | interaction: ProviderAuthInteraction; | |
| 209 | response: ProviderAuthResponse; | |
| 210 | }>)( | |
| 211 | "keeps $interaction.type responses private and waits for adapter verification", | |
| 212 | ({ interaction, response }) => | |
| 213 | Effect.gen(function* () { | |
| 214 | const received = yield* Deferred.make<ProviderAuthResponse>(); | |
| 215 | const controller = yield* ProviderAuthFlow.make({ | |
| 216 | instanceId, | |
| 217 | credentialBinding: { owner: "t3", key: "binding" }, | |
| 218 | methods: Effect.succeed([method]), | |
| 219 | authenticate: (_, context) => | |
| 220 | Effect.gen(function* () { | |
| 221 | yield* context.setInteraction(interaction, (response) => | |
| 222 | Deferred.succeed(received, response).pipe(Effect.asVoid), | |
| 223 | ); | |
| 224 | yield* Deferred.await(received); | |
| 225 | yield* context.verifying; | |
| 226 | return yield* Effect.never; | |
| 227 | }), | |
| 228 | logout: Effect.void, | |
| 229 | }); | |
| 230 | const state = yield* controller.start("owner"); | |
| 231 | const pending = yield* controller.subscribe("owner").pipe( | |
| 232 | Stream.filter((state) => state.phase === "waiting"), | |
| 233 | Stream.runHead, | |
| 234 | Effect.map(Option.getOrThrow), | |
| 235 | ); | |
| 236 | assert.deepStrictEqual(pending.interaction, interaction); | |
| 237 | const hidden = yield* controller | |
| 238 | .subscribe("other") | |
| 239 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 240 | assert.isNull(hidden.interaction); | |
| 241 | yield* controller.respond!("owner", { | |
| 242 | instanceId, | |
| 243 | flowId: state.flowId!, | |
| 244 | interactionId: interaction.id, | |
| 245 | response, | |
| 246 | }); | |
| 247 | assert.deepStrictEqual(yield* Deferred.await(received), response); | |
| 248 | const verifying = yield* controller.subscribe("owner").pipe( | |
| 249 | Stream.filter((state) => state.phase === "verifying"), | |
| 250 | Stream.runHead, | |
| 251 | Effect.map(Option.getOrThrow), | |
| 252 | ); | |
| 253 | assert.isNull(verifying.interaction); | |
| 254 | assert.notInclude(verifying.message ?? "", "private-token"); | |
| 255 | yield* controller.cancel("owner", state.flowId!); | |
| 256 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 257 | ); | |
| 258 | ||
| 259 | it.effect("shows a device code only to its owner without treating it as authenticated", () => | |
| 260 | Effect.gen(function* () { | |
| 261 | const controller = yield* ProviderAuthFlow.make({ | |
| 262 | instanceId, | |
| 263 | credentialBinding: { owner: "provider", key: "device" }, | |
| 264 | methods: Effect.succeed([method]), | |
| 265 | authenticate: (_, context) => | |
| 266 | context | |
| 267 | .setInteraction({ | |
| 268 | type: "deviceCode", | |
| 269 | id: "code", | |
| 270 | url: "https://example.com/device", | |
| 271 | userCode: "ABCD-EFGH", | |
| 272 | }) | |
| 273 | .pipe(Effect.andThen(Effect.never)), | |
| 274 | logout: Effect.void, | |
| 275 | }); | |
| 276 | const start = yield* controller.start("owner"); | |
| 277 | const waiting = yield* controller.subscribe("owner").pipe( | |
| 278 | Stream.filter((state) => state.phase === "waiting"), | |
| 279 | Stream.runHead, | |
| 280 | Effect.map(Option.getOrThrow), | |
| 281 | ); | |
| 282 | assert.strictEqual(waiting.interaction?.type, "deviceCode"); | |
| 283 | assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); | |
| 284 | const other = yield* controller | |
| 285 | .subscribe("other") | |
| 286 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 287 | assert.isNull(other.interaction); | |
| 288 | assert.isNull(other.authorizationUrl); | |
| 289 | yield* controller.cancel("owner", start.flowId!); | |
| 290 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect.each([ | |
| 294 | { | |
| 295 | failure: Effect.fail( | |
| 296 | new ProviderSetupError({ | |
| 297 | instanceId, | |
| 298 | operation: "authenticate", | |
| 299 | detail: "This account cannot sign in. Choose another account.", | |
| 300 | }), | |
| 301 | ), | |
| 302 | message: "This account cannot sign in. Choose another account.", | |
| 303 | }, | |
| 304 | { | |
| 305 | failure: Effect.die(new Error("private-token in native diagnostics")), | |
| 306 | message: "Sign-in failed. Start again.", | |
| 307 | }, | |
| 308 | ])("publishes only safe authentication failure text %#", ({ failure, message }) => | |
| 309 | Effect.gen(function* () { | |
| 310 | const controller = yield* ProviderAuthFlow.make({ | |
| 311 | instanceId, | |
| 312 | credentialBinding: { owner: "t3", key: "failure" }, | |
| 313 | methods: Effect.succeed([method]), | |
| 314 | authenticate: () => failure, | |
| 315 | logout: Effect.void, | |
| 316 | }); | |
| 317 | yield* controller.start("owner"); | |
| 318 | const failed = yield* controller.subscribe("owner").pipe( | |
| 319 | Stream.filter((state) => state.phase === "failed"), | |
| 320 | Stream.runHead, | |
| 321 | Effect.map(Option.getOrThrow), | |
| 322 | ); | |
| 323 | assert.strictEqual(failed.message, message); | |
| 324 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 325 | ); | |
| 326 | ||
| 327 | const makeBlockingResponseHarness = Effect.gen(function* () { | |
| 328 | const entered = yield* Deferred.make<void>(); | |
| 329 | const cleanupStarted = yield* Deferred.make<void>(); | |
| 330 | const cleanupReleased = yield* Deferred.make<void>(); | |
| 331 | const cleanupFinished = yield* Deferred.make<void>(); | |
| 332 | const authenticationFinished = yield* Deferred.make<void>(); | |
| 333 | let responses = 0; | |
| 334 | const controller = yield* ProviderAuthFlow.make({ | |
| 335 | instanceId, | |
| 336 | credentialBinding: { owner: "t3", key: "blocked-response" }, | |
| 337 | methods: Effect.succeed([method]), | |
| 338 | authenticate: (_, context) => | |
| 339 | Effect.gen(function* () { | |
| 340 | yield* context.setInteraction( | |
| 341 | { | |
| 342 | type: "browser", | |
| 343 | id: "blocked", | |
| 344 | url: "https://example.com/login", | |
| 345 | requiresConsent: true, | |
| 346 | }, | |
| 347 | () => | |
| 348 | Effect.gen(function* () { | |
| 349 | responses++; | |
| 350 | yield* Deferred.succeed(entered, undefined); | |
| 351 | return yield* Effect.never; | |
| 352 | }).pipe( | |
| 353 | Effect.ensuring( | |
| 354 | Effect.gen(function* () { | |
| 355 | yield* Deferred.succeed(cleanupStarted, undefined); | |
| 356 | yield* Deferred.await(cleanupReleased); | |
| 357 | yield* Deferred.succeed(cleanupFinished, undefined); | |
| 358 | }), | |
| 359 | ), | |
| 360 | ), | |
| 361 | ); | |
| 362 | yield* Deferred.await(authenticationFinished); | |
| 363 | }), | |
| 364 | logout: Effect.void, | |
| 365 | }); | |
| 366 | const start = yield* controller.start("owner"); | |
| 367 | yield* controller.subscribe("owner").pipe( | |
| 368 | Stream.filter((state) => state.phase === "waiting"), | |
| 369 | Stream.runHead, | |
| 370 | ); | |
| 371 | const input = { | |
| 372 | instanceId, | |
| 373 | flowId: start.flowId!, | |
| 374 | interactionId: "blocked", | |
| 375 | response: { type: "browser" as const, action: "accept" as const }, | |
| 376 | }; | |
| 377 | const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); | |
| 378 | yield* Deferred.await(entered); | |
| 379 | return { | |
| 380 | controller, | |
| 381 | input, | |
| 382 | response, | |
| 383 | cleanupStarted, | |
| 384 | cleanupReleased, | |
| 385 | cleanupFinished, | |
| 386 | authenticationFinished, | |
| 387 | responses: () => responses, | |
| 388 | }; | |
| 389 | }); | |
| 390 | ||
| 391 | it.effect.each(["cancel", "logout"] as const)( | |
| 392 | "%s interrupts and awaits a blocked adapter response before admitting another sign-in", | |
| 393 | (action) => | |
| 394 | Effect.gen(function* () { | |
| 395 | const harness = yield* makeBlockingResponseHarness; | |
| 396 | const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); | |
| 397 | assert.include(duplicate.detail, "already in progress"); | |
| 398 | assert.equal(harness.responses(), 1); | |
| 399 | const stopping = yield* ( | |
| 400 | action === "cancel" | |
| 401 | ? harness.controller.cancel("owner", harness.input.flowId) | |
| 402 | : harness.controller.logout(Effect.void) | |
| 403 | ).pipe(Effect.forkChild); | |
| 404 | yield* Deferred.await(harness.cleanupStarted); | |
| 405 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 406 | assert.isUndefined(stopping.pollUnsafe()); | |
| 407 | const premature = yield* Effect.flip(harness.controller.start("other")); | |
| 408 | assert.include(premature.detail, "in progress"); | |
| 409 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 410 | yield* Fiber.join(stopping); | |
| 411 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 412 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 413 | assert.isFalse(yield* harness.controller.isChangingCredentials!); | |
| 414 | assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); | |
| 415 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 416 | ); | |
| 417 | ||
| 418 | it.effect("timeout interrupts and drains a blocked response before publishing failure", () => | |
| 419 | Effect.gen(function* () { | |
| 420 | const harness = yield* makeBlockingResponseHarness; | |
| 421 | yield* TestClock.adjust(300_001); | |
| 422 | yield* Deferred.await(harness.cleanupStarted); | |
| 423 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 424 | const duringCleanup = yield* harness.controller | |
| 425 | .subscribe("owner") | |
| 426 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 427 | assert.notEqual(duringCleanup.phase, "failed"); | |
| 428 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 429 | const failed = yield* harness.controller.subscribe("owner").pipe( | |
| 430 | Stream.filter((state) => state.phase === "failed"), | |
| 431 | Stream.runHead, | |
| 432 | Effect.map(Option.getOrThrow), | |
| 433 | ); | |
| 434 | assert.include(failed.message ?? "", "expired"); | |
| 435 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 436 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 437 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 438 | ); | |
| 439 | ||
| 440 | it.effect( | |
| 441 | "successful authentication drains a still-running response before admitting provider access", | |
| 442 | () => | |
| 443 | Effect.gen(function* () { | |
| 444 | const harness = yield* makeBlockingResponseHarness; | |
| 445 | yield* Deferred.succeed(harness.authenticationFinished, undefined); | |
| 446 | yield* Deferred.await(harness.cleanupStarted); | |
| 447 | const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); | |
| 448 | assert.include(denied.detail, "changing"); | |
| 449 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 450 | const succeeded = yield* harness.controller.subscribe("owner").pipe( | |
| 451 | Stream.filter((state) => state.phase === "succeeded"), | |
| 452 | Stream.runHead, | |
| 453 | Effect.map(Option.getOrThrow), | |
| 454 | ); | |
| 455 | assert.equal(succeeded.phase, "succeeded"); | |
| 456 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 457 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 458 | yield* harness.controller.withAccess!(Effect.void); | |
| 459 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 460 | ); | |
| 461 | ||
| 462 | it.effect("closing the controller scope interrupts and drains its adapter response", () => | |
| 463 | Effect.gen(function* () { | |
| 464 | const scope = yield* Scope.make(); | |
| 465 | const harness = yield* makeBlockingResponseHarness.pipe( | |
| 466 | Effect.provideService(Scope.Scope, scope), | |
| 467 | ); | |
| 468 | const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); | |
| 469 | yield* Deferred.await(harness.cleanupStarted); | |
| 470 | assert.isUndefined(closing.pollUnsafe()); | |
| 471 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 472 | yield* Fiber.join(closing); | |
| 473 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 474 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 475 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 476 | ); | |
| 477 | ||
| 478 | it.effect("a failed method refresh keeps the last discovered methods", () => | |
| 479 | Effect.gen(function* () { | |
| 480 | let fail = false; | |
| 481 | const controller = yield* ProviderAuthFlow.make({ | |
| 482 | instanceId, | |
| 483 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 484 | methods: Effect.suspend(() => | |
| 485 | fail | |
| 486 | ? Effect.fail( | |
| 487 | new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), | |
| 488 | ) | |
| 489 | : Effect.succeed([method]), | |
| 490 | ), | |
| 491 | authenticate: () => Effect.void, | |
| 492 | logout: Effect.void, | |
| 493 | }); | |
| 494 | yield* controller.refreshMethods!; | |
| 495 | fail = true; | |
| 496 | yield* controller.refreshMethods!; | |
| 497 | const state = yield* controller | |
| 498 | .subscribe("owner") | |
| 499 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 500 | assert.deepEqual(state.methods, [method]); | |
| 501 | assert.strictEqual(state.message, "interrupted"); | |
| 502 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const scope = yield* Scope.make(); | |
| 508 | const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 509 | let closed = false; | |
| 510 | yield* controller.withAccess!( | |
| 511 | Effect.addFinalizer(() => | |
| 512 | Effect.sync(() => { | |
| 513 | closed = true; | |
| 514 | }), | |
| 515 | ), | |
| 516 | ); | |
| 517 | yield* Scope.close(scope, Exit.void); | |
| 518 | assert.isFalse(closed); | |
| 519 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 520 | ); | |
| 521 | ||
| 522 | it.effect("profile import stops owned sessions and gates access until credentials are saved", () => | |
| 523 | Effect.gen(function* () { | |
| 524 | const controller = yield* ProviderAuthFlow.make({ | |
| 525 | instanceId, | |
| 526 | credentialBinding: { owner: "t3", key: "handoff-binding" }, | |
| 527 | methods: Effect.succeed([method]), | |
| 528 | authenticate: () => Effect.void, | |
| 529 | logout: Effect.void, | |
| 530 | }); | |
| 531 | let closed = false; | |
| 532 | yield* controller.withAccess!( | |
| 533 | Effect.addFinalizer(() => | |
| 534 | Effect.sync(() => { | |
| 535 | closed = true; | |
| 536 | }), | |
| 537 | ), | |
| 538 | ); | |
| 539 | const writing = yield* Deferred.make<void>(); | |
| 540 | const release = yield* Deferred.make<void>(); | |
| 541 | const imported = yield* controller.adoptCredentials!( | |
| 542 | Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))), | |
| 543 | Effect.sync(() => { | |
| 544 | assert.isTrue(closed); | |
| 545 | }), | |
| 546 | ).pipe(Effect.forkChild); | |
| 547 | yield* Deferred.await(writing); | |
| 548 | const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe( | |
| 549 | Effect.result, | |
| 550 | ); | |
| 551 | assert.strictEqual(denied._tag, "Failure"); | |
| 552 | yield* Deferred.succeed(release, undefined); | |
| 553 | assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded"); | |
| 554 | assert.strictEqual( | |
| 555 | yield* controller.withAccess!(Effect.succeed("new credential process")), | |
| 556 | "new credential process", | |
| 557 | ); | |
| 558 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 559 | ); |
apps/server/src/provider/ProviderAuthFlow.ts deleted-527| ... | ... | @@ -1,527 +0,0 @@ |
| 1 | import { | |
| 2 | ProviderSetupError, | |
| 3 | type ProviderAuthInteraction, | |
| 4 | type ProviderAuthMethod, | |
| 5 | type ProviderAuthResponse, | |
| 6 | type ProviderAuthState, | |
| 7 | type ProviderInstanceId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Cause from "effect/Cause"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Clock from "effect/Clock"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Deferred from "effect/Deferred"; | |
| 17 | import * as Exit from "effect/Exit"; | |
| 18 | import * as Fiber from "effect/Fiber"; | |
| 19 | import * as Scope from "effect/Scope"; | |
| 20 | import * as Semaphore from "effect/Semaphore"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 23 | ||
| 24 | import type * as ProviderAuthService from "./ProviderAuthService.ts"; | |
| 25 | ||
| 26 | export interface ProviderAuthFlowContext { | |
| 27 | readonly flowId: string; | |
| 28 | /** The lifetime timeout interrupts authenticate before publishing its expired result. */ | |
| 29 | readonly expiresAt: number; | |
| 30 | readonly returnUrl?: string; | |
| 31 | readonly callbackMode?: "server" | "client"; | |
| 32 | readonly setInteraction: ( | |
| 33 | interaction: ProviderAuthInteraction, | |
| 34 | respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>, | |
| 35 | complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>, | |
| 36 | ) => Effect.Effect<void>; | |
| 37 | readonly verifying: Effect.Effect<void>; | |
| 38 | } | |
| 39 | ||
| 40 | const isSetupError = Schema.is(ProviderSetupError); | |
| 41 | ||
| 42 | const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => { | |
| 43 | const error = Cause.findErrorOption(cause); | |
| 44 | return Option.isSome(error) && isSetupError(error.value) | |
| 45 | ? error.value.detail | |
| 46 | : "Sign-in failed. Start again."; | |
| 47 | }; | |
| 48 | ||
| 49 | interface Flow { | |
| 50 | readonly id: string; | |
| 51 | readonly owner: string; | |
| 52 | readonly expiresAt: number; | |
| 53 | complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined; | |
| 54 | fiber?: Fiber.Fiber<void>; | |
| 55 | responseFiber?: Fiber.Fiber<void, ProviderSetupError>; | |
| 56 | respond: | |
| 57 | | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>) | |
| 58 | | undefined; | |
| 59 | } | |
| 60 | ||
| 61 | /** Adapters do login and credential handling; this owns client consent and flow lifetime. */ | |
| 62 | export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { | |
| 63 | readonly instanceId: ProviderInstanceId; | |
| 64 | readonly credentialBinding: NonNullable< | |
| 65 | ProviderAuthService.ProviderAuthController["credentialBinding"] | |
| 66 | >; | |
| 67 | readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>; | |
| 68 | readonly defaultMethodId?: string; | |
| 69 | /** Stored account profiles can change the advertised methods after auth/logout. */ | |
| 70 | readonly refreshMethodsAfterAuth?: boolean; | |
| 71 | /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ | |
| 72 | readonly authenticate: ( | |
| 73 | methodId: string, | |
| 74 | context: ProviderAuthFlowContext, | |
| 75 | ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>; | |
| 76 | readonly logout: Effect.Effect<void | string, ProviderSetupError>; | |
| 77 | readonly timeoutMs?: number; | |
| 78 | }) { | |
| 79 | const scope = yield* Scope.Scope; | |
| 80 | const crypto = yield* Crypto.Crypto; | |
| 81 | const lock = yield* Semaphore.make(1); | |
| 82 | const timeoutMs = options.timeoutMs ?? 300_000; | |
| 83 | const empty: ProviderAuthState = { | |
| 84 | instanceId: options.instanceId, | |
| 85 | phase: "idle", | |
| 86 | flowId: null, | |
| 87 | authorizationUrl: null, | |
| 88 | expiresAt: null, | |
| 89 | message: null, | |
| 90 | interaction: null, | |
| 91 | credentialOwner: options.credentialBinding.owner, | |
| 92 | }; | |
| 93 | const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); | |
| 94 | let active: Flow | undefined; | |
| 95 | let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; | |
| 96 | const sessions = new Set<Scope.Closeable>(); | |
| 97 | const stopOwnedSessions = Effect.suspend(() => | |
| 98 | Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { | |
| 99 | discard: true, | |
| 100 | concurrency: "unbounded", | |
| 101 | }), | |
| 102 | ); | |
| 103 | const publish = (flow: Flow, patch: Partial<ProviderAuthState>) => | |
| 104 | Effect.suspend(() => | |
| 105 | active === flow | |
| 106 | ? SubscriptionRef.update(snapshot, (current) => ({ | |
| 107 | owner: flow.owner, | |
| 108 | state: { ...current.state, ...patch }, | |
| 109 | })) | |
| 110 | : Effect.void, | |
| 111 | ); | |
| 112 | ||
| 113 | const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { | |
| 114 | if ( | |
| 115 | operation !== "auth" || | |
| 116 | !active || | |
| 117 | active.owner !== owner || | |
| 118 | active.id !== id || | |
| 119 | (yield* Clock.currentTimeMillis) >= active.expiresAt | |
| 120 | ) { | |
| 121 | return yield* new ProviderSetupError({ | |
| 122 | instanceId: options.instanceId, | |
| 123 | operation: "respond", | |
| 124 | detail: "This sign-in is no longer active in this client.", | |
| 125 | }); | |
| 126 | } | |
| 127 | return active; | |
| 128 | }); | |
| 129 | ||
| 130 | // Method discovery does not start a sign-in flow. | |
| 131 | const refreshMethods = options.methods.pipe( | |
| 132 | Effect.flatMap((methods) => | |
| 133 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 134 | ...current, | |
| 135 | state: { ...current.state, methods }, | |
| 136 | })), | |
| 137 | ), | |
| 138 | // Keep the last known methods when a refresh fails or is interrupted. | |
| 139 | Effect.catch((error) => | |
| 140 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 141 | ...current, | |
| 142 | state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, | |
| 143 | })), | |
| 144 | ), | |
| 145 | ); | |
| 146 | yield* refreshMethods.pipe(Effect.forkIn(scope)); | |
| 147 | ||
| 148 | const controller: ProviderAuthService.ProviderAuthController = { | |
| 149 | credentialBinding: options.credentialBinding, | |
| 150 | adoptCredentials: (update, stopSessions) => | |
| 151 | Effect.gen(function* () { | |
| 152 | yield* lock.withPermit( | |
| 153 | Effect.gen(function* () { | |
| 154 | if (operation !== "idle") | |
| 155 | return yield* new ProviderSetupError({ | |
| 156 | instanceId: options.instanceId, | |
| 157 | operation: "import", | |
| 158 | detail: "Finish or cancel the existing sign-in first.", | |
| 159 | }); | |
| 160 | operation = "stopping"; | |
| 161 | }), | |
| 162 | ); | |
| 163 | const result = yield* Effect.gen(function* () { | |
| 164 | yield* stopOwnedSessions; | |
| 165 | yield* stopSessions; | |
| 166 | yield* update; | |
| 167 | yield* refreshMethods; | |
| 168 | }).pipe(Effect.exit); | |
| 169 | const state: ProviderAuthState = { | |
| 170 | ...empty, | |
| 171 | methods: snapshot.value.state.methods ?? [], | |
| 172 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 173 | message: Exit.isSuccess(result) ? null : failureMessage(result.cause), | |
| 174 | }; | |
| 175 | yield* lock.withPermit( | |
| 176 | Effect.gen(function* () { | |
| 177 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 178 | operation = "idle"; | |
| 179 | }), | |
| 180 | ); | |
| 181 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 182 | return state; | |
| 183 | }).pipe(Effect.uninterruptible), | |
| 184 | refreshMethods, | |
| 185 | invalidate: lock.withPermit( | |
| 186 | Effect.gen(function* () { | |
| 187 | if (operation !== "idle") return; | |
| 188 | yield* stopOwnedSessions; | |
| 189 | yield* SubscriptionRef.set(snapshot, { | |
| 190 | owner: null, | |
| 191 | state: { | |
| 192 | ...empty, | |
| 193 | methods: snapshot.value.state.methods ?? [], | |
| 194 | message: "This provider's shared sign-in changed.", | |
| 195 | }, | |
| 196 | }); | |
| 197 | }), | |
| 198 | ), | |
| 199 | isChangingCredentials: Effect.sync(() => operation !== "idle"), | |
| 200 | withAccess: (task) => | |
| 201 | Effect.uninterruptibleMask((restore) => | |
| 202 | Effect.gen(function* () { | |
| 203 | const parent = yield* Scope.Scope; | |
| 204 | const child = yield* lock.withPermit( | |
| 205 | Effect.gen(function* () { | |
| 206 | if (operation !== "idle") | |
| 207 | return yield* new ProviderSetupError({ | |
| 208 | instanceId: options.instanceId, | |
| 209 | operation: "session", | |
| 210 | detail: "Provider sign-in is changing. Try again after it finishes.", | |
| 211 | }); | |
| 212 | const child = yield* Scope.make(); | |
| 213 | sessions.add(child); | |
| 214 | yield* Scope.addFinalizer( | |
| 215 | child, | |
| 216 | Effect.sync(() => { | |
| 217 | sessions.delete(child); | |
| 218 | }), | |
| 219 | ); | |
| 220 | yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); | |
| 221 | return child; | |
| 222 | }), | |
| 223 | ); | |
| 224 | const fiber = yield* restore(task).pipe( | |
| 225 | Effect.provideService(Scope.Scope, child), | |
| 226 | Effect.forkIn(child), | |
| 227 | ); | |
| 228 | return yield* restore(Fiber.await(fiber)).pipe( | |
| 229 | Effect.flatMap((result) => result), | |
| 230 | Effect.onExit((result) => | |
| 231 | Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, | |
| 232 | ), | |
| 233 | ); | |
| 234 | }), | |
| 235 | ), | |
| 236 | start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) => | |
| 237 | lock.withPermit( | |
| 238 | Effect.gen(function* () { | |
| 239 | if (operation === "auth" && active?.owner === owner) return snapshot.value.state; | |
| 240 | if (operation !== "idle") | |
| 241 | return yield* new ProviderSetupError({ | |
| 242 | instanceId: options.instanceId, | |
| 243 | operation: "start", | |
| 244 | detail: "Provider setup is already in progress.", | |
| 245 | }); | |
| 246 | const id = yield* crypto.randomUUIDv4.pipe( | |
| 247 | Effect.mapError( | |
| 248 | () => | |
| 249 | new ProviderSetupError({ | |
| 250 | instanceId: options.instanceId, | |
| 251 | operation: "start", | |
| 252 | detail: "Could not start sign-in. Try again.", | |
| 253 | }), | |
| 254 | ), | |
| 255 | ); | |
| 256 | const flow: Flow = { | |
| 257 | id, | |
| 258 | owner, | |
| 259 | expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, | |
| 260 | respond: undefined, | |
| 261 | }; | |
| 262 | active = flow; | |
| 263 | operation = "auth"; | |
| 264 | const state: ProviderAuthState = { | |
| 265 | ...empty, | |
| 266 | methods: snapshot.value.state.methods ?? [], | |
| 267 | phase: "starting", | |
| 268 | flowId: id, | |
| 269 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), | |
| 270 | message: "Starting sign-in.", | |
| 271 | }; | |
| 272 | yield* SubscriptionRef.set(snapshot, { owner, state }); | |
| 273 | flow.fiber = yield* Effect.gen(function* () { | |
| 274 | const methods = yield* options.methods; | |
| 275 | yield* publish(flow, { methods }); | |
| 276 | const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; | |
| 277 | if (!methodId || !methods.some((method) => method.id === methodId)) | |
| 278 | return yield* new ProviderSetupError({ | |
| 279 | instanceId: options.instanceId, | |
| 280 | operation: "start", | |
| 281 | detail: "The provider did not advertise this sign-in method.", | |
| 282 | }); | |
| 283 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 284 | yield* options.authenticate(methodId, { | |
| 285 | flowId: id, | |
| 286 | expiresAt: flow.expiresAt, | |
| 287 | ...(returnUrl ? { returnUrl } : {}), | |
| 288 | ...(callbackMode ? { callbackMode } : {}), | |
| 289 | setInteraction: (interaction, respond, complete) => | |
| 290 | Effect.gen(function* () { | |
| 291 | if (active !== flow) return; | |
| 292 | flow.respond = respond; | |
| 293 | flow.complete = complete; | |
| 294 | yield* publish(flow, { | |
| 295 | phase: "waiting", | |
| 296 | interaction, | |
| 297 | authorizationUrl: | |
| 298 | interaction.type === "browser" || interaction.type === "deviceCode" | |
| 299 | ? interaction.url | |
| 300 | : null, | |
| 301 | message: "Complete sign-in to continue.", | |
| 302 | }); | |
| 303 | }), | |
| 304 | verifying: Effect.gen(function* () { | |
| 305 | flow.respond = undefined; | |
| 306 | flow.complete = undefined; | |
| 307 | yield* publish(flow, { | |
| 308 | phase: "verifying", | |
| 309 | interaction: null, | |
| 310 | authorizationUrl: null, | |
| 311 | message: "Checking provider sign-in.", | |
| 312 | }); | |
| 313 | }), | |
| 314 | }); | |
| 315 | }).pipe( | |
| 316 | Effect.scoped, | |
| 317 | Effect.timeoutOrElse({ | |
| 318 | duration: timeoutMs, | |
| 319 | orElse: () => | |
| 320 | Effect.fail( | |
| 321 | new ProviderSetupError({ | |
| 322 | instanceId: options.instanceId, | |
| 323 | operation: "start", | |
| 324 | detail: "Sign-in expired. Start again.", | |
| 325 | }), | |
| 326 | ), | |
| 327 | }), | |
| 328 | Effect.exit, | |
| 329 | Effect.flatMap((result) => | |
| 330 | Effect.gen(function* () { | |
| 331 | const finishing = yield* lock.withPermit( | |
| 332 | Effect.sync(() => { | |
| 333 | if (active !== flow) return false; | |
| 334 | operation = "stopping"; | |
| 335 | return true; | |
| 336 | }), | |
| 337 | ); | |
| 338 | if (!finishing) return; | |
| 339 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 340 | yield* lock.withPermit( | |
| 341 | Effect.gen(function* () { | |
| 342 | if (active !== flow) return; | |
| 343 | if (options.refreshMethodsAfterAuth) yield* refreshMethods; | |
| 344 | yield* publish(flow, { | |
| 345 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 346 | interaction: null, | |
| 347 | authorizationUrl: null, | |
| 348 | expiresAt: null, | |
| 349 | message: Exit.isSuccess(result) | |
| 350 | ? "Sign-in complete." | |
| 351 | : failureMessage(result.cause), | |
| 352 | }); | |
| 353 | active = undefined; | |
| 354 | operation = "idle"; | |
| 355 | }), | |
| 356 | ); | |
| 357 | }), | |
| 358 | ), | |
| 359 | Effect.interruptible, | |
| 360 | Effect.forkIn(scope), | |
| 361 | ); | |
| 362 | return state; | |
| 363 | }).pipe(Effect.uninterruptible), | |
| 364 | ), | |
| 365 | respond: (owner, input) => | |
| 366 | Effect.uninterruptibleMask((restore) => | |
| 367 | Effect.gen(function* () { | |
| 368 | const { flow, fiber, ready } = yield* lock.withPermit( | |
| 369 | Effect.gen(function* () { | |
| 370 | const flow = yield* requireFlow(owner, input.flowId); | |
| 371 | const interaction = snapshot.value.state.interaction; | |
| 372 | if ( | |
| 373 | !interaction || | |
| 374 | interaction.id !== input.interactionId || | |
| 375 | interaction.type !== input.response.type || | |
| 376 | !flow.respond | |
| 377 | ) | |
| 378 | return yield* new ProviderSetupError({ | |
| 379 | instanceId: options.instanceId, | |
| 380 | operation: "respond", | |
| 381 | detail: "This sign-in interaction is no longer available.", | |
| 382 | }); | |
| 383 | if (flow.responseFiber) | |
| 384 | return yield* new ProviderSetupError({ | |
| 385 | instanceId: options.instanceId, | |
| 386 | operation: "respond", | |
| 387 | detail: "A sign-in response is already in progress.", | |
| 388 | }); | |
| 389 | const ready = yield* Deferred.make<void>(); | |
| 390 | const callback = flow.respond; | |
| 391 | const fiber = yield* Deferred.await(ready).pipe( | |
| 392 | Effect.andThen(Effect.suspend(() => callback(input.response))), | |
| 393 | Effect.interruptible, | |
| 394 | Effect.forkIn(scope), | |
| 395 | ); | |
| 396 | flow.responseFiber = fiber; | |
| 397 | return { flow, fiber, ready }; | |
| 398 | }), | |
| 399 | ); | |
| 400 | yield* Deferred.succeed(ready, undefined); | |
| 401 | yield* restore(Fiber.join(fiber)).pipe( | |
| 402 | Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), | |
| 403 | Effect.ensuring( | |
| 404 | lock.withPermit( | |
| 405 | Effect.sync(() => { | |
| 406 | if (flow.responseFiber === fiber) delete flow.responseFiber; | |
| 407 | }), | |
| 408 | ), | |
| 409 | ), | |
| 410 | ); | |
| 411 | return snapshot.value.state; | |
| 412 | }), | |
| 413 | ), | |
| 414 | complete: (owner, input) => | |
| 415 | lock.withPermit( | |
| 416 | Effect.gen(function* () { | |
| 417 | const flow = yield* requireFlow(owner, input.flowId); | |
| 418 | const interaction = snapshot.value.state.interaction; | |
| 419 | if ( | |
| 420 | snapshot.value.state.phase !== "waiting" || | |
| 421 | interaction?.type !== "browser" || | |
| 422 | !interaction.acceptsCallback || | |
| 423 | !flow.complete | |
| 424 | ) | |
| 425 | return yield* new ProviderSetupError({ | |
| 426 | instanceId: options.instanceId, | |
| 427 | operation: "complete", | |
| 428 | detail: "This sign-in does not accept a redirect URL.", | |
| 429 | }); | |
| 430 | yield* flow.complete(input.callbackUrl); | |
| 431 | return snapshot.value.state; | |
| 432 | }), | |
| 433 | ), | |
| 434 | cancel: (owner, id) => | |
| 435 | Effect.gen(function* () { | |
| 436 | const flow = yield* lock.withPermit( | |
| 437 | Effect.gen(function* () { | |
| 438 | const flow = yield* requireFlow(owner, id); | |
| 439 | yield* publish(flow, { | |
| 440 | phase: "cancelled", | |
| 441 | interaction: null, | |
| 442 | authorizationUrl: null, | |
| 443 | expiresAt: null, | |
| 444 | message: "Sign-in cancelled.", | |
| 445 | }); | |
| 446 | active = undefined; | |
| 447 | operation = "stopping"; | |
| 448 | return flow; | |
| 449 | }), | |
| 450 | ); | |
| 451 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 452 | if (flow.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 453 | operation = "idle"; | |
| 454 | return snapshot.value.state; | |
| 455 | }).pipe(Effect.uninterruptible), | |
| 456 | logout: (stopSessions) => | |
| 457 | Effect.gen(function* () { | |
| 458 | const flow = yield* lock.withPermit( | |
| 459 | Effect.gen(function* () { | |
| 460 | if (operation !== "idle" && operation !== "auth") | |
| 461 | return yield* new ProviderSetupError({ | |
| 462 | instanceId: options.instanceId, | |
| 463 | operation: "logout", | |
| 464 | detail: "Provider setup is already stopping.", | |
| 465 | }); | |
| 466 | operation = "stopping"; | |
| 467 | const flow = active; | |
| 468 | active = undefined; | |
| 469 | return flow; | |
| 470 | }), | |
| 471 | ); | |
| 472 | const result = yield* Effect.gen(function* () { | |
| 473 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 474 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 475 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 476 | const message = yield* options.logout; | |
| 477 | if (options.refreshMethodsAfterAuth) yield* refreshMethods; | |
| 478 | return message; | |
| 479 | }).pipe(Effect.exit); | |
| 480 | const state: ProviderAuthState = { | |
| 481 | ...empty, | |
| 482 | methods: snapshot.value.state.methods ?? [], | |
| 483 | phase: Exit.isSuccess(result) ? "idle" : "failed", | |
| 484 | message: Exit.isSuccess(result) | |
| 485 | ? (result.value ?? "Signed out.") | |
| 486 | : "Could not sign out. Try again.", | |
| 487 | }; | |
| 488 | yield* lock.withPermit( | |
| 489 | Effect.gen(function* () { | |
| 490 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 491 | operation = "idle"; | |
| 492 | }), | |
| 493 | ); | |
| 494 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 495 | return state; | |
| 496 | }).pipe(Effect.uninterruptible), | |
| 497 | subscribe: (owner) => | |
| 498 | SubscriptionRef.changes(snapshot).pipe( | |
| 499 | Stream.map((current) => | |
| 500 | current.owner === null || current.owner === owner | |
| 501 | ? current.state | |
| 502 | : { | |
| 503 | ...current.state, | |
| 504 | flowId: null, | |
| 505 | authorizationUrl: null, | |
| 506 | interaction: null, | |
| 507 | expiresAt: null, | |
| 508 | message: active | |
| 509 | ? "Sign-in is in progress in another client." | |
| 510 | : current.state.message, | |
| 511 | }, | |
| 512 | ), | |
| 513 | ), | |
| 514 | }; | |
| 515 | yield* Effect.addFinalizer(() => | |
| 516 | Effect.gen(function* () { | |
| 517 | operation = "closed"; | |
| 518 | const flow = active; | |
| 519 | active = undefined; | |
| 520 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 521 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 522 | // Settings edits rebuild instances; admitted sessions (including those of | |
| 523 | // peers sharing this binding) end with their own scopes, not this one. | |
| 524 | }), | |
| 525 | ); | |
| 526 | return controller; | |
| 527 | }); |
apps/server/src/provider/ProviderHostLive.ts+1| ... | ... | @@ -35,6 +35,7 @@ export const layer = Layer.effect( |
| 35 | 35 | }, |
| 36 | 36 | settings: { |
| 37 | 37 | get: serverSettings.getSettings, |
| 38 | withSnapshot: serverSettings.withSettingsSnapshot, | |
| 38 | 39 | changes: serverSettings.streamChanges, |
| 39 | 40 | subscribe: serverSettings.subscribeChanges, |
| 40 | 41 | }, |
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+2-3| ... | ... | @@ -38,8 +38,7 @@ import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; |
| 38 | 38 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; |
| 39 | 39 | import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; |
| 40 | 40 | import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; |
| 41 | import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; | |
| 42 | import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts"; | |
| 41 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; | |
| 43 | 42 | import * as ProviderHostLive from "./ProviderHostLive.ts"; |
| 44 | 43 | import type { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; |
| 45 | 44 | import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| ... | ... | @@ -144,7 +143,7 @@ export const layer: Layer.Layer< |
| 144 | 143 | configMap: initialConfigMap, |
| 145 | 144 | }).pipe( |
| 146 | 145 | Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), |
| 147 | Layer.provide(AcpRegistryCatalog.layer), | |
| 146 | Layer.provide(AcpRegistrySupport.layerFromHost), | |
| 148 | 147 | Layer.provide(ProviderHostLive.layer), |
| 149 | 148 | ); |
| 150 | 149 |
apps/server/src/provider/acp/AcpRegistryAuth.test.ts deleted-367| ... | ... | @@ -1,367 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | AcpRegistrySettings, | |
| 5 | ProviderInstanceId, | |
| 6 | type ProviderAuthState, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import type * as AcpSchema from "effect-acp/compat"; | |
| 14 | import { AcpRequestError } from "effect-acp/errors"; | |
| 15 | ||
| 16 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; | |
| 17 | import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; | |
| 18 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 19 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 20 | ||
| 21 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 22 | const instanceId = ProviderInstanceId.make("acp-auth-test"); | |
| 23 | const browserMethod = { id: "browser", name: "Browser", type: "agent" as const }; | |
| 24 | const terminalMethod = { | |
| 25 | id: "terminal", | |
| 26 | name: "Terminal", | |
| 27 | type: "terminal" as const, | |
| 28 | args: ["login"], | |
| 29 | env: { LOGIN: "yes", OVERRIDE: "method" }, | |
| 30 | }; | |
| 31 | const resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent = { | |
| 32 | agent: { | |
| 33 | id: "test-agent", | |
| 34 | name: "Test", | |
| 35 | version: "1.0.0", | |
| 36 | description: "Test", | |
| 37 | distribution: {}, | |
| 38 | }, | |
| 39 | distribution: "binary", | |
| 40 | spawn: { | |
| 41 | command: "/managed/agent", | |
| 42 | args: ["--acp"], | |
| 43 | env: { OVERRIDE: "spawn", AGENT_HOME: "/custom/home" }, | |
| 44 | }, | |
| 45 | }; | |
| 46 | const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = { | |
| 47 | inspect: () => | |
| 48 | Effect.succeed({ | |
| 49 | status: "ready", | |
| 50 | agentId: "test-agent", | |
| 51 | version: "1.0.0", | |
| 52 | distribution: "binary", | |
| 53 | }), | |
| 54 | resolve: () => Effect.succeed(resolved), | |
| 55 | search: () => Effect.die("unused"), | |
| 56 | prepare: () => Effect.die("unused"), | |
| 57 | uninstallManagedBinary: () => Effect.die("unused"), | |
| 58 | }; | |
| 59 | ||
| 60 | const makeHarness = ( | |
| 61 | method: AcpSchema.AuthMethod, | |
| 62 | failVerification = false, | |
| 63 | settings = decodeSettings({ agentId: "test-agent" }), | |
| 64 | providerInstanceId = instanceId, | |
| 65 | ) => | |
| 66 | Effect.gen(function* () { | |
| 67 | const verify = yield* Deferred.make<void>(); | |
| 68 | const changed: boolean[] = []; | |
| 69 | const closed: number[] = []; | |
| 70 | const authenticated: string[] = []; | |
| 71 | const started: number[] = []; | |
| 72 | let runtimes = 0; | |
| 73 | let version = "1.0.0"; | |
| 74 | let terminalSpawn: PtyAdapter.PtySpawnInput | undefined; | |
| 75 | let data: ((data: string) => void) | undefined; | |
| 76 | let exit: ((event: PtyAdapter.PtyExitEvent) => void) | undefined; | |
| 77 | let killed = false; | |
| 78 | let closedBeforeTerminal = false; | |
| 79 | const written: string[] = []; | |
| 80 | const sizes: number[][] = []; | |
| 81 | const controller = yield* makeAcpRegistryAuth({ | |
| 82 | instanceId: providerInstanceId, | |
| 83 | settings, | |
| 84 | cwd: "/workspace", | |
| 85 | environment: { PATH: "/tools", OVERRIDE: "base" }, | |
| 86 | onChanged: (value) => | |
| 87 | Effect.sync(() => { | |
| 88 | changed.push(value); | |
| 89 | }), | |
| 90 | makeRuntime: () => | |
| 91 | Effect.gen(function* () { | |
| 92 | const id = ++runtimes; | |
| 93 | yield* Effect.addFinalizer(() => | |
| 94 | Effect.sync(() => { | |
| 95 | closed.push(id); | |
| 96 | }), | |
| 97 | ); | |
| 98 | const initialized: AcpSchema.InitializeResponse = { | |
| 99 | protocolVersion: 2, | |
| 100 | agentCapabilities: {}, | |
| 101 | authMethods: [{ ...method, name: `${method.name} ${version}` }], | |
| 102 | }; | |
| 103 | let elicitation: | |
| 104 | | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0] | |
| 105 | | undefined; | |
| 106 | return { | |
| 107 | initialize: () => Effect.succeed(initialized), | |
| 108 | handleElicitation: (handler) => | |
| 109 | Effect.sync(() => { | |
| 110 | elicitation = handler; | |
| 111 | }), | |
| 112 | authenticate: (methodId) => | |
| 113 | Effect.gen(function* () { | |
| 114 | authenticated.push(methodId); | |
| 115 | if (elicitation) | |
| 116 | yield* elicitation( | |
| 117 | { | |
| 118 | mode: "url", | |
| 119 | url: "https://example.com/login", | |
| 120 | elicitationId: "consent", | |
| 121 | requestId: "login", | |
| 122 | message: "Sign in", | |
| 123 | }, | |
| 124 | { requestId: "login", method: "elicitation/create" }, | |
| 125 | ); | |
| 126 | }), | |
| 127 | start: () => | |
| 128 | Effect.gen(function* () { | |
| 129 | started.push(id); | |
| 130 | yield* Deferred.await(verify); | |
| 131 | if (failVerification) | |
| 132 | return yield* new AcpRequestError({ | |
| 133 | method: "session/new", | |
| 134 | code: -32000, | |
| 135 | errorMessage: "Authentication required", | |
| 136 | }); | |
| 137 | return { | |
| 138 | sessionId: "verified", | |
| 139 | initializeResult: initialized, | |
| 140 | sessionSetupResult: { sessionId: "verified" }, | |
| 141 | modelConfigId: undefined, | |
| 142 | }; | |
| 143 | }), | |
| 144 | logout: Effect.succeed({}), | |
| 145 | }; | |
| 146 | }), | |
| 147 | }).pipe( | |
| 148 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, { | |
| 149 | ...catalog, | |
| 150 | inspect: () => | |
| 151 | Effect.sync(() => ({ | |
| 152 | status: "ready" as const, | |
| 153 | agentId: "test-agent", | |
| 154 | version, | |
| 155 | distribution: "binary" as const, | |
| 156 | })), | |
| 157 | }), | |
| 158 | Effect.provideService(PtyAdapter.PtyAdapter, { | |
| 159 | spawn: (input) => | |
| 160 | Effect.sync(() => { | |
| 161 | terminalSpawn = input; | |
| 162 | closedBeforeTerminal = closed.includes(runtimes); | |
| 163 | return { | |
| 164 | pid: 123, | |
| 165 | write: (input) => { | |
| 166 | written.push(input); | |
| 167 | }, | |
| 168 | resize: (cols, rows) => { | |
| 169 | sizes.push([cols, rows]); | |
| 170 | }, | |
| 171 | kill: () => { | |
| 172 | killed = true; | |
| 173 | }, | |
| 174 | onData: (callback) => { | |
| 175 | data = callback; | |
| 176 | return () => { | |
| 177 | data = undefined; | |
| 178 | }; | |
| 179 | }, | |
| 180 | onExit: (callback) => { | |
| 181 | exit = callback; | |
| 182 | return () => { | |
| 183 | exit = undefined; | |
| 184 | }; | |
| 185 | }, | |
| 186 | }; | |
| 187 | }), | |
| 188 | }), | |
| 189 | ); | |
| 190 | const state = (predicate: (state: ProviderAuthState) => boolean) => | |
| 191 | controller | |
| 192 | .subscribe("owner") | |
| 193 | .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 194 | const phase = (phase: ProviderAuthState["phase"]) => state((value) => value.phase === phase); | |
| 195 | yield* state((value) => (value.methods?.length ?? 0) > 0); | |
| 196 | return { | |
| 197 | controller, | |
| 198 | verify, | |
| 199 | changed, | |
| 200 | closed, | |
| 201 | authenticated, | |
| 202 | started, | |
| 203 | phase, | |
| 204 | state, | |
| 205 | terminalSpawn: () => terminalSpawn, | |
| 206 | closedBeforeTerminal: () => closedBeforeTerminal, | |
| 207 | data: (value: string) => data?.(value), | |
| 208 | exit: (exitCode: number) => exit?.({ exitCode, signal: null }), | |
| 209 | killed: () => killed, | |
| 210 | written, | |
| 211 | sizes, | |
| 212 | runtimes: () => runtimes, | |
| 213 | setVersion: (value: string) => { | |
| 214 | version = value; | |
| 215 | }, | |
| 216 | }; | |
| 217 | }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); | |
| 218 | ||
| 219 | it.effect("keeps local credentials separate from a matching registry agent ID", () => | |
| 220 | Effect.gen(function* () { | |
| 221 | const registry = yield* makeHarness(browserMethod); | |
| 222 | const local = yield* makeHarness( | |
| 223 | browserMethod, | |
| 224 | false, | |
| 225 | decodeSettings({ source: "local", commandPath: "/different/agent" }), | |
| 226 | ProviderInstanceId.make("test-agent"), | |
| 227 | ); | |
| 228 | assert.deepEqual(registry.controller.credentialBinding, { | |
| 229 | owner: "provider", | |
| 230 | key: "acp:test-agent", | |
| 231 | }); | |
| 232 | assert.deepEqual(local.controller.credentialBinding, { | |
| 233 | owner: "provider", | |
| 234 | key: "acp:local:test-agent", | |
| 235 | }); | |
| 236 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 237 | ); | |
| 238 | ||
| 239 | it.effect( | |
| 240 | "discovers methods without signing in, then waits for browser consent and session verification", | |
| 241 | () => | |
| 242 | Effect.gen(function* () { | |
| 243 | const h = yield* makeHarness(browserMethod); | |
| 244 | assert.deepEqual(h.authenticated, []); | |
| 245 | assert.deepEqual(h.started, []); | |
| 246 | assert.deepEqual(h.closed, [1]); | |
| 247 | const flow = yield* h.controller.start("owner", Effect.void, "browser"); | |
| 248 | const waiting = yield* h.phase("waiting"); | |
| 249 | assert.deepEqual(waiting.interaction, { | |
| 250 | type: "browser", | |
| 251 | id: "consent", | |
| 252 | url: "https://example.com/login", | |
| 253 | requiresConsent: true, | |
| 254 | }); | |
| 255 | yield* h.controller.respond!("owner", { | |
| 256 | instanceId, | |
| 257 | flowId: flow.flowId!, | |
| 258 | interactionId: "consent", | |
| 259 | response: { type: "browser", action: "accept" }, | |
| 260 | }); | |
| 261 | yield* h.phase("verifying"); | |
| 262 | assert.deepEqual(h.changed, []); | |
| 263 | yield* Deferred.succeed(h.verify, undefined); | |
| 264 | yield* h.phase("succeeded"); | |
| 265 | assert.deepEqual(h.authenticated, ["browser"]); | |
| 266 | assert.deepEqual(h.changed, [true]); | |
| 267 | assert.deepEqual(h.closed, [1, 2]); | |
| 268 | yield* h.controller.logout(Effect.void); | |
| 269 | assert.deepEqual(h.changed, [true, false]); | |
| 270 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 271 | ); | |
| 272 | ||
| 273 | it.effect( | |
| 274 | "runs terminal auth directly with the resolved environment and reconnects to verify", | |
| 275 | () => | |
| 276 | Effect.gen(function* () { | |
| 277 | const h = yield* makeHarness(terminalMethod); | |
| 278 | const flow = yield* h.controller.start("owner", Effect.void, "terminal"); | |
| 279 | yield* h.phase("waiting"); | |
| 280 | assert.isTrue(h.closedBeforeTerminal()); | |
| 281 | assert.deepEqual(h.terminalSpawn(), { | |
| 282 | shell: "/managed/agent", | |
| 283 | args: ["--acp", "login"], | |
| 284 | cwd: "/workspace", | |
| 285 | cols: 80, | |
| 286 | rows: 24, | |
| 287 | env: { PATH: "/tools", OVERRIDE: "method", AGENT_HOME: "/custom/home", LOGIN: "yes" }, | |
| 288 | }); | |
| 289 | h.data("Enter code: "); | |
| 290 | const output = yield* h.state( | |
| 291 | (value) => | |
| 292 | value.interaction?.type === "terminal" && value.interaction.output === "Enter code: ", | |
| 293 | ); | |
| 294 | assert.strictEqual( | |
| 295 | output.interaction?.type === "terminal" && output.interaction.outputOffset, | |
| 296 | 12, | |
| 297 | ); | |
| 298 | yield* h.controller.respond!("owner", { | |
| 299 | instanceId, | |
| 300 | flowId: flow.flowId!, | |
| 301 | interactionId: "terminal", | |
| 302 | response: { type: "terminal", data: "123\r", size: { cols: 100, rows: 30 } }, | |
| 303 | }); | |
| 304 | assert.deepEqual(h.written, ["123\r"]); | |
| 305 | assert.deepEqual(h.sizes, [[100, 30]]); | |
| 306 | h.exit(0); | |
| 307 | yield* h.phase("verifying"); | |
| 308 | assert.isTrue(h.killed()); | |
| 309 | assert.deepEqual(h.authenticated, []); | |
| 310 | yield* Deferred.succeed(h.verify, undefined); | |
| 311 | yield* h.phase("succeeded"); | |
| 312 | assert.deepEqual(h.started, [3]); | |
| 313 | assert.deepEqual(h.changed, [true]); | |
| 314 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 315 | ); | |
| 316 | ||
| 317 | it.effect("cancels the login PTY and rejects further terminal input", () => | |
| 318 | Effect.gen(function* () { | |
| 319 | const h = yield* makeHarness(terminalMethod); | |
| 320 | const flow = yield* h.controller.start("owner"); | |
| 321 | yield* h.phase("waiting"); | |
| 322 | yield* h.controller.cancel("owner", flow.flowId!); | |
| 323 | assert.isTrue(h.killed()); | |
| 324 | assert.deepEqual(h.started, []); | |
| 325 | assert.deepEqual(h.changed, []); | |
| 326 | assert.strictEqual( | |
| 327 | (yield* h.controller.respond!("owner", { | |
| 328 | instanceId, | |
| 329 | flowId: flow.flowId!, | |
| 330 | interactionId: "terminal", | |
| 331 | response: { type: "terminal", data: "late\r" }, | |
| 332 | }).pipe(Effect.result))._tag, | |
| 333 | "Failure", | |
| 334 | ); | |
| 335 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 336 | ); | |
| 337 | ||
| 338 | it.effect("does not report authentication when post-login verification fails", () => | |
| 339 | Effect.gen(function* () { | |
| 340 | const h = yield* makeHarness(terminalMethod, true); | |
| 341 | yield* h.controller.start("owner"); | |
| 342 | yield* h.phase("waiting"); | |
| 343 | h.exit(0); | |
| 344 | yield* h.phase("verifying"); | |
| 345 | yield* Deferred.succeed(h.verify, undefined); | |
| 346 | yield* h.phase("failed"); | |
| 347 | assert.deepEqual(h.changed, []); | |
| 348 | assert.isTrue(h.killed()); | |
| 349 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 350 | ); | |
| 351 | ||
| 352 | it.effect("refreshes advertised methods after a registry version change without logging in", () => | |
| 353 | Effect.gen(function* () { | |
| 354 | const h = yield* makeHarness(browserMethod); | |
| 355 | const count = h.runtimes(); | |
| 356 | yield* h.controller.refreshMethods!; | |
| 357 | assert.strictEqual(h.runtimes(), count); | |
| 358 | h.setVersion("2.0.0"); | |
| 359 | yield* h.controller.refreshMethods!; | |
| 360 | const state = yield* h.state((value) => value.methods?.[0]?.name === "Browser 2.0.0"); | |
| 361 | assert.strictEqual(state.phase, "idle"); | |
| 362 | assert.strictEqual(h.runtimes(), count + 1); | |
| 363 | assert.deepEqual(h.authenticated, []); | |
| 364 | assert.deepEqual(h.started, []); | |
| 365 | assert.deepEqual(h.closed, [1, 2]); | |
| 366 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 367 | ); |
apps/server/src/provider/acp/AcpRegistryAuth.ts deleted-370| ... | ... | @@ -1,370 +0,0 @@ |
| 1 | import { | |
| 2 | ProviderSetupError, | |
| 3 | type AcpRegistrySettings, | |
| 4 | type ProviderAuthMethod, | |
| 5 | type ProviderInstanceId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Crypto from "effect/Crypto"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | import type * as AcpSchema from "effect-acp/compat"; | |
| 17 | ||
| 18 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; | |
| 19 | import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; | |
| 20 | import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; | |
| 21 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 22 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 23 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 24 | ||
| 25 | type Runtime = Pick< | |
| 26 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 27 | "initialize" | "authenticate" | "start" | "handleElicitation" | "logout" | |
| 28 | >; | |
| 29 | ||
| 30 | /** ACP credentials stay in the agent's own store, using the same launch environment as chat. */ | |
| 31 | export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (options: { | |
| 32 | readonly instanceId: ProviderInstanceId; | |
| 33 | readonly settings: AcpRegistrySettings; | |
| 34 | readonly cwd: string; | |
| 35 | readonly environment: NodeJS.ProcessEnv; | |
| 36 | readonly onChanged: (authenticated: boolean) => Effect.Effect<void>; | |
| 37 | readonly makeRuntime?: ( | |
| 38 | spawn: AcpSessionRuntime.AcpSpawnInput, | |
| 39 | ) => Effect.Effect<Runtime, ProviderSetupError, Scope.Scope>; | |
| 40 | }) { | |
| 41 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 42 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 43 | const crypto = yield* Crypto.Crypto; | |
| 44 | const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); | |
| 45 | const coordinator = yield* Effect.serviceOption( | |
| 46 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 47 | ); | |
| 48 | const agentKey = | |
| 49 | options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; | |
| 50 | const failure = (operation: string, detail: string, cause?: unknown) => | |
| 51 | new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); | |
| 52 | const resolve = catalog | |
| 53 | .resolve(options.settings, options.cwd, options.environment) | |
| 54 | .pipe( | |
| 55 | Effect.mapError((cause) => | |
| 56 | failure("start", "Could not prepare the selected ACP agent.", cause), | |
| 57 | ), | |
| 58 | ); | |
| 59 | const makeRuntime = | |
| 60 | options.makeRuntime ?? | |
| 61 | ((spawn) => | |
| 62 | Effect.gen(function* () { | |
| 63 | const context = yield* Layer.build( | |
| 64 | AcpSessionRuntime.layer({ | |
| 65 | spawn, | |
| 66 | cwd: options.cwd, | |
| 67 | authenticateOnAuthRequired: false, | |
| 68 | clientCapabilities: { | |
| 69 | auth: { terminal: Option.isSome(pty) }, | |
| 70 | elicitation: { url: {} }, | |
| 71 | fs: { readTextFile: false, writeTextFile: false }, | |
| 72 | terminal: false, | |
| 73 | }, | |
| 74 | clientInfo: { name: "t3-code-provider-auth", version: "0.0.0" }, | |
| 75 | }).pipe( | |
| 76 | Layer.provide( | |
| 77 | Layer.mergeAll( | |
| 78 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 79 | Layer.succeed(Crypto.Crypto, crypto), | |
| 80 | ), | |
| 81 | ), | |
| 82 | ), | |
| 83 | ); | |
| 84 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 85 | Effect.provide(context), | |
| 86 | ); | |
| 87 | }).pipe( | |
| 88 | Effect.mapError((cause) => | |
| 89 | failure("start", "Could not start the selected ACP agent.", cause), | |
| 90 | ), | |
| 91 | )); | |
| 92 | ||
| 93 | let knownMethods: | |
| 94 | | { readonly version: string | null; readonly methods: ReadonlyArray<ProviderAuthMethod> } | |
| 95 | | undefined; | |
| 96 | const discoverMethods = Effect.scoped( | |
| 97 | Effect.gen(function* () { | |
| 98 | const inspected = yield* catalog | |
| 99 | .inspect(options.settings, options.environment) | |
| 100 | .pipe( | |
| 101 | Effect.mapError((cause) => | |
| 102 | failure("methods", "Could not inspect the selected ACP agent.", cause), | |
| 103 | ), | |
| 104 | ); | |
| 105 | if (inspected.status !== "ready") | |
| 106 | return yield* failure("methods", "Prepare this ACP agent before signing in."); | |
| 107 | // An unversioned command (a path override) can change in place, so only | |
| 108 | // registry versions reuse earlier discovery. | |
| 109 | if (inspected.version !== null && knownMethods?.version === inspected.version) | |
| 110 | return knownMethods.methods; | |
| 111 | const resolved = yield* resolve; | |
| 112 | const runtime = yield* makeRuntime(resolved.spawn); | |
| 113 | const initialized = yield* runtime | |
| 114 | .initialize() | |
| 115 | .pipe( | |
| 116 | Effect.mapError((cause) => | |
| 117 | failure("methods", "Could not discover this agent's sign-in methods.", cause), | |
| 118 | ), | |
| 119 | ); | |
| 120 | const advertised = normalizeAcpRegistryAuthMethods(initialized.authMethods) | |
| 121 | .filter( | |
| 122 | (method) => method.id.length <= 128 && (method.type !== "terminal" || Option.isSome(pty)), | |
| 123 | ) | |
| 124 | .slice(0, 32) | |
| 125 | .map((method) => ({ | |
| 126 | id: method.id, | |
| 127 | name: method.name, | |
| 128 | description: method.description, | |
| 129 | type: method.type === "env_var" ? ("credentials" as const) : method.type, | |
| 130 | })); | |
| 131 | knownMethods = { version: inspected.version, methods: advertised }; | |
| 132 | return advertised; | |
| 133 | }), | |
| 134 | ).pipe( | |
| 135 | Effect.timeoutOrElse({ | |
| 136 | duration: "30 seconds", | |
| 137 | orElse: () => | |
| 138 | Effect.fail(failure("methods", "The ACP agent did not advertise sign-in methods in time.")), | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | const methods = Option.isSome(coordinator) | |
| 143 | ? coordinator.value | |
| 144 | .runBackgroundProbe(agentKey, discoverMethods) | |
| 145 | .pipe( | |
| 146 | Effect.flatMap((result) => | |
| 147 | Option.isSome(result) | |
| 148 | ? Effect.succeed(result.value) | |
| 149 | : Effect.fail( | |
| 150 | failure( | |
| 151 | "methods", | |
| 152 | "Sign-in discovery was interrupted by an active provider session. Try again.", | |
| 153 | ), | |
| 154 | ), | |
| 155 | ), | |
| 156 | ) | |
| 157 | : discoverMethods; | |
| 158 | ||
| 159 | const runTerminal = Effect.fnUntraced(function* ( | |
| 160 | resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent, | |
| 161 | method: Extract<AcpSchema.AuthMethod, { readonly type: "terminal" }>, | |
| 162 | context: ProviderAuthFlow.ProviderAuthFlowContext, | |
| 163 | ) { | |
| 164 | if (Option.isNone(pty)) | |
| 165 | return yield* failure( | |
| 166 | "start", | |
| 167 | "Interactive provider login is unavailable on this environment.", | |
| 168 | ); | |
| 169 | const exited = yield* Deferred.make<number>(); | |
| 170 | const output = yield* Queue.sliding<string>(64); | |
| 171 | const process = yield* pty.value | |
| 172 | .spawn({ | |
| 173 | shell: resolved.spawn.command, | |
| 174 | args: [...resolved.spawn.args, ...(method.args ?? [])], | |
| 175 | cwd: options.cwd, | |
| 176 | cols: 80, | |
| 177 | rows: 24, | |
| 178 | env: { ...options.environment, ...resolved.spawn.env, ...method.env }, | |
| 179 | }) | |
| 180 | .pipe( | |
| 181 | Effect.mapError((cause) => | |
| 182 | failure("start", "Could not open the provider sign-in terminal.", cause), | |
| 183 | ), | |
| 184 | ); | |
| 185 | const detachData = process.onData((data) => { | |
| 186 | Queue.offerUnsafe(output, data.slice(-16_384)); | |
| 187 | }); | |
| 188 | const detachExit = process.onExit(({ exitCode }) => { | |
| 189 | Deferred.doneUnsafe(exited, Effect.succeed(exitCode)); | |
| 190 | }); | |
| 191 | yield* Effect.addFinalizer(() => | |
| 192 | Effect.sync(() => { | |
| 193 | detachData(); | |
| 194 | detachExit(); | |
| 195 | try { | |
| 196 | process.kill(); | |
| 197 | } catch { | |
| 198 | /* The login process may already have exited. */ | |
| 199 | } | |
| 200 | }), | |
| 201 | ); | |
| 202 | let transcript = ""; | |
| 203 | let outputOffset = 0; | |
| 204 | const update = () => | |
| 205 | context.setInteraction( | |
| 206 | { type: "terminal", id: "terminal", output: transcript, outputOffset }, | |
| 207 | (response) => | |
| 208 | response.type === "terminal" | |
| 209 | ? Effect.try({ | |
| 210 | try: () => { | |
| 211 | if (response.size) process.resize(response.size.cols, response.size.rows); | |
| 212 | if (response.data) process.write(response.data); | |
| 213 | }, | |
| 214 | catch: () => | |
| 215 | failure("respond", "The provider sign-in terminal is no longer available."), | |
| 216 | }) | |
| 217 | : Effect.void, | |
| 218 | ); | |
| 219 | yield* update(); | |
| 220 | yield* Queue.take(output).pipe( | |
| 221 | Effect.flatMap((data) => { | |
| 222 | outputOffset += data.length; | |
| 223 | transcript = (transcript + data).slice(-16_384); | |
| 224 | return update(); | |
| 225 | }), | |
| 226 | Effect.forever, | |
| 227 | Effect.forkScoped, | |
| 228 | ); | |
| 229 | if ((yield* Deferred.await(exited)) !== 0) | |
| 230 | return yield* failure("start", "The provider login command did not finish successfully."); | |
| 231 | }); | |
| 232 | ||
| 233 | const authenticate = (methodId: string, context: ProviderAuthFlow.ProviderAuthFlowContext) => | |
| 234 | Effect.gen(function* () { | |
| 235 | if (!options.settings.enabled) | |
| 236 | return yield* failure("start", "Enable this provider before signing in."); | |
| 237 | const login = Effect.scoped( | |
| 238 | Effect.gen(function* () { | |
| 239 | const resolved = yield* resolve; | |
| 240 | const runtimeScope = yield* Scope.make(); | |
| 241 | yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); | |
| 242 | const runtime = yield* makeRuntime(resolved.spawn).pipe( | |
| 243 | Effect.provideService(Scope.Scope, runtimeScope), | |
| 244 | ); | |
| 245 | yield* runtime.handleElicitation((request) => | |
| 246 | Effect.gen(function* () { | |
| 247 | if ( | |
| 248 | request.mode !== "url" || | |
| 249 | !("url" in request) || | |
| 250 | !("elicitationId" in request) || | |
| 251 | typeof request.url !== "string" || | |
| 252 | typeof request.elicitationId !== "string" | |
| 253 | ) | |
| 254 | return { action: "decline" } as const; | |
| 255 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 256 | const id = request.elicitationId.trim(); | |
| 257 | if (!url || !id || id.length > 128) return { action: "decline" } as const; | |
| 258 | const consent = yield* Deferred.make<boolean>(); | |
| 259 | yield* context.setInteraction( | |
| 260 | { type: "browser", id, url, requiresConsent: true }, | |
| 261 | (response) => | |
| 262 | response.type === "browser" | |
| 263 | ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid) | |
| 264 | : Effect.void, | |
| 265 | ); | |
| 266 | return (yield* Deferred.await(consent)) | |
| 267 | ? ({ action: "accept" } as const) | |
| 268 | : ({ action: "decline" } as const); | |
| 269 | }), | |
| 270 | ); | |
| 271 | const initialized = yield* runtime | |
| 272 | .initialize() | |
| 273 | .pipe( | |
| 274 | Effect.mapError((cause) => | |
| 275 | failure("start", "Could not initialize the selected ACP agent.", cause), | |
| 276 | ), | |
| 277 | ); | |
| 278 | const method = initialized.authMethods?.find((method) => method.id === methodId); | |
| 279 | if (!method) | |
| 280 | return yield* failure("start", "The agent no longer advertises this sign-in method."); | |
| 281 | if (method.type === "terminal") { | |
| 282 | yield* Scope.close(runtimeScope, Exit.void); | |
| 283 | yield* runTerminal(resolved, method, context).pipe(Effect.scoped); | |
| 284 | // Terminal login is a separate invocation. Reconnect after it exits so | |
| 285 | // the ACP process reads the credentials its login command persisted. | |
| 286 | const verifiedRuntime = yield* makeRuntime(resolved.spawn); | |
| 287 | yield* context.verifying; | |
| 288 | yield* verifiedRuntime | |
| 289 | .start() | |
| 290 | .pipe( | |
| 291 | Effect.mapError((cause) => | |
| 292 | failure( | |
| 293 | "verify", | |
| 294 | "The provider could not create a session after terminal sign-in.", | |
| 295 | cause, | |
| 296 | ), | |
| 297 | ), | |
| 298 | ); | |
| 299 | } else { | |
| 300 | if (method.type === undefined || method.type === "agent") { | |
| 301 | if (!runtime.authenticate) | |
| 302 | return yield* failure( | |
| 303 | "start", | |
| 304 | "This runtime does not support explicit ACP sign-in.", | |
| 305 | ); | |
| 306 | yield* runtime | |
| 307 | .authenticate(methodId) | |
| 308 | .pipe( | |
| 309 | Effect.mapError((cause) => | |
| 310 | failure("start", "The ACP agent could not complete sign-in.", cause), | |
| 311 | ), | |
| 312 | ); | |
| 313 | } | |
| 314 | // Legacy env_var methods use the secret-backed environment fields in | |
| 315 | // provider settings. Never send their ID to authenticate. | |
| 316 | yield* context.verifying; | |
| 317 | yield* runtime | |
| 318 | .start() | |
| 319 | .pipe( | |
| 320 | Effect.mapError((cause) => | |
| 321 | failure( | |
| 322 | "verify", | |
| 323 | "The provider could not create a session after sign-in.", | |
| 324 | cause, | |
| 325 | ), | |
| 326 | ), | |
| 327 | ); | |
| 328 | } | |
| 329 | }), | |
| 330 | ); | |
| 331 | yield* Option.isSome(coordinator) | |
| 332 | ? coordinator.value.withForegroundStartup(agentKey, login) | |
| 333 | : login; | |
| 334 | yield* options.onChanged(true); | |
| 335 | }); | |
| 336 | const logout = Effect.scoped( | |
| 337 | Effect.gen(function* () { | |
| 338 | const resolved = yield* resolve; | |
| 339 | const runtime = yield* makeRuntime(resolved.spawn); | |
| 340 | yield* runtime.logout.pipe( | |
| 341 | Effect.mapError((cause) => | |
| 342 | failure( | |
| 343 | "logout", | |
| 344 | "This ACP agent could not sign out. It may not advertise logout support.", | |
| 345 | cause, | |
| 346 | ), | |
| 347 | ), | |
| 348 | ); | |
| 349 | }), | |
| 350 | ).pipe( | |
| 351 | Effect.timeoutOrElse({ | |
| 352 | duration: "60 seconds", | |
| 353 | orElse: () => | |
| 354 | Effect.fail(failure("logout", "The ACP agent did not finish signing out in time.")), | |
| 355 | }), | |
| 356 | ); | |
| 357 | const signOut = ( | |
| 358 | Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout | |
| 359 | ).pipe(Effect.andThen(options.onChanged(false))); | |
| 360 | return yield* ProviderAuthFlow.make({ | |
| 361 | instanceId: options.instanceId, | |
| 362 | // ACP doesn't advertise its credential scope. Conservatively treat all | |
| 363 | // instances of the same agent on this environment as sharing credentials. | |
| 364 | credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, | |
| 365 | methods, | |
| 366 | ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), | |
| 367 | authenticate, | |
| 368 | logout: signOut, | |
| 369 | }); | |
| 370 | }); |
apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts deleted-99| ... | ... | @@ -1,99 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | ||
| 8 | import { makeAcpRegistryAuthenticationState } from "./AcpRegistryAuthenticationState.ts"; | |
| 9 | ||
| 10 | const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" }); | |
| 11 | ||
| 12 | it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => { | |
| 13 | it.effect("restores explicit sign-in across driver recreation and server restart", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const fs = yield* FileSystem.FileSystem; | |
| 16 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 17 | const input = { | |
| 18 | cacheDir, | |
| 19 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 20 | settings, | |
| 21 | environment: [], | |
| 22 | processEnvironment: { HOME: "/home/test" }, | |
| 23 | }; | |
| 24 | const first = yield* makeAcpRegistryAuthenticationState(input); | |
| 25 | assert.isFalse(yield* first.get); | |
| 26 | yield* first.set(true); | |
| 27 | const recreated = yield* makeAcpRegistryAuthenticationState({ | |
| 28 | ...input, | |
| 29 | settings: { ...settings, customModels: ["new-model"] }, | |
| 30 | }); | |
| 31 | assert.isTrue(yield* recreated.get); | |
| 32 | // Both logout and an explicit authentication failure revoke confirmation. | |
| 33 | yield* recreated.set(false); | |
| 34 | const restarted = yield* makeAcpRegistryAuthenticationState(input); | |
| 35 | assert.isFalse(yield* restarted.get); | |
| 36 | }), | |
| 37 | ); | |
| 38 | ||
| 39 | it.effect( | |
| 40 | "does not carry confirmation to a different agent, method, executable, or credential environment", | |
| 41 | () => | |
| 42 | Effect.gen(function* () { | |
| 43 | const fs = yield* FileSystem.FileSystem; | |
| 44 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 45 | const input = { | |
| 46 | cacheDir, | |
| 47 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 48 | settings, | |
| 49 | environment: [{ name: "DEVIN_TOKEN", value: "test-only-token", sensitive: true }], | |
| 50 | processEnvironment: { HOME: "/home/test" }, | |
| 51 | }; | |
| 52 | for (const change of [ | |
| 53 | { settings: { ...settings, agentId: "other-agent" } }, | |
| 54 | { settings: { ...settings, authMethodId: "enterprise" } }, | |
| 55 | { settings: { ...settings, commandPath: "/other/devin" } }, | |
| 56 | { environment: [{ name: "DEVIN_TOKEN", value: "different-token", sensitive: true }] }, | |
| 57 | { processEnvironment: { HOME: "/home/other" } }, | |
| 58 | ]) { | |
| 59 | const signedIn = yield* makeAcpRegistryAuthenticationState(input); | |
| 60 | yield* signedIn.set(true); | |
| 61 | const changed = yield* makeAcpRegistryAuthenticationState({ ...input, ...change }); | |
| 62 | assert.isFalse(yield* changed.get); | |
| 63 | const restoredConfig = yield* makeAcpRegistryAuthenticationState(input); | |
| 64 | assert.isFalse(yield* restoredConfig.get); | |
| 65 | } | |
| 66 | for (const name of yield* fs.readDirectory(cacheDir)) { | |
| 67 | const contents = yield* fs.readFileString(`${cacheDir}/${name}`); | |
| 68 | assert.isFalse(contents.includes("test-only-token")); | |
| 69 | assert.isFalse(contents.includes("different-token")); | |
| 70 | } | |
| 71 | }), | |
| 72 | ); | |
| 73 | ||
| 74 | it.effect("ignores damaged confirmation and keeps another instance independent", () => | |
| 75 | Effect.gen(function* () { | |
| 76 | const fs = yield* FileSystem.FileSystem; | |
| 77 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 78 | const input = { | |
| 79 | cacheDir, | |
| 80 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 81 | settings, | |
| 82 | environment: [], | |
| 83 | processEnvironment: {}, | |
| 84 | }; | |
| 85 | const first = yield* makeAcpRegistryAuthenticationState(input); | |
| 86 | yield* first.set(true); | |
| 87 | const other = yield* makeAcpRegistryAuthenticationState({ | |
| 88 | ...input, | |
| 89 | instanceId: ProviderInstanceId.make("acp_other"), | |
| 90 | }); | |
| 91 | assert.isFalse(yield* other.get); | |
| 92 | for (const name of yield* fs.readDirectory(cacheDir)) { | |
| 93 | yield* fs.writeFileString(`${cacheDir}/${name}`, "invalid"); | |
| 94 | } | |
| 95 | const restored = yield* makeAcpRegistryAuthenticationState(input); | |
| 96 | assert.isFalse(yield* restored.get); | |
| 97 | }), | |
| 98 | ); | |
| 99 | }); |
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts deleted-85| ... | ... | @@ -1,85 +0,0 @@ |
| 1 | import type { | |
| 2 | AcpRegistrySettings, | |
| 3 | ProviderInstanceEnvironment, | |
| 4 | ProviderInstanceId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Crypto from "effect/Crypto"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Hex from "effect/encoding/Hex"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Ref from "effect/Ref"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import * as Semaphore from "effect/Semaphore"; | |
| 14 | ||
| 15 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 16 | ||
| 17 | const decodeState = Schema.decodeUnknownEffect( | |
| 18 | Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), | |
| 19 | ); | |
| 20 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 21 | const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) { | |
| 22 | const crypto = yield* Crypto.Crypto; | |
| 23 | const json = yield* encodeJson(value).pipe(Effect.orDie); | |
| 24 | const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie); | |
| 25 | return Hex.encode(digest); | |
| 26 | }); | |
| 27 | ||
| 28 | /** Remember explicit sign-in success, never discovery success or the agent's credentials. */ | |
| 29 | export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")( | |
| 30 | function* (input: { | |
| 31 | readonly cacheDir: string; | |
| 32 | readonly instanceId: ProviderInstanceId; | |
| 33 | readonly settings: AcpRegistrySettings; | |
| 34 | readonly environment: ProviderInstanceEnvironment; | |
| 35 | readonly processEnvironment: NodeJS.ProcessEnv; | |
| 36 | }) { | |
| 37 | const fs = yield* FileSystem.FileSystem; | |
| 38 | const path = yield* Path.Path; | |
| 39 | const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`); | |
| 40 | // Cosmetic settings and model discovery can rebuild the driver without | |
| 41 | // changing the account. Credential overrides and profile paths cannot. | |
| 42 | const binding = yield* hash({ | |
| 43 | ...(input.settings.source === "local" | |
| 44 | ? { source: "local", commandArgs: input.settings.commandArgs } | |
| 45 | : {}), | |
| 46 | agentId: input.settings.agentId, | |
| 47 | commandPath: input.settings.commandPath, | |
| 48 | distribution: input.settings.distribution, | |
| 49 | authMethodId: input.settings.authMethodId, | |
| 50 | environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)), | |
| 51 | profiles: [ | |
| 52 | "HOME", | |
| 53 | "XDG_CONFIG_HOME", | |
| 54 | "XDG_DATA_HOME", | |
| 55 | "XDG_STATE_HOME", | |
| 56 | "APPDATA", | |
| 57 | "LOCALAPPDATA", | |
| 58 | ].map((name) => input.processEnvironment[name] ?? null), | |
| 59 | }); | |
| 60 | const saved = yield* fs.readFileString(filePath).pipe( | |
| 61 | Effect.flatMap(decodeState), | |
| 62 | Effect.orElseSucceed(() => undefined), | |
| 63 | ); | |
| 64 | const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated); | |
| 65 | const lock = yield* Semaphore.make(1); | |
| 66 | const persist = (authenticated: boolean) => | |
| 67 | writeFileStringAtomically({ | |
| 68 | filePath, | |
| 69 | contents: JSON.stringify({ binding, authenticated }), | |
| 70 | }).pipe( | |
| 71 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 72 | Effect.provideService(Path.Path, path), | |
| 73 | Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")), | |
| 74 | Effect.ignore, | |
| 75 | ); | |
| 76 | if (saved && saved.binding !== binding) yield* persist(false); | |
| 77 | return { | |
| 78 | get: Ref.get(confirmed), | |
| 79 | set: (authenticated: boolean) => | |
| 80 | lock.withPermit( | |
| 81 | Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))), | |
| 82 | ), | |
| 83 | }; | |
| 84 | }, | |
| 85 | ); |
apps/server/src/provider/acp/AcpRegistryProbe.test.ts deleted-605| ... | ... | @@ -1,605 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodePath from "node:path"; | |
| 3 | import * as NodeURL from "node:url"; | |
| 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; | |
| 7 | import { describe, expect, it } from "@effect/vitest"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | import * as EffectAcpErrors from "effect-acp/errors"; | |
| 14 | ||
| 15 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 16 | import { | |
| 17 | acpRegistryProbeFailure, | |
| 18 | acpRegistryProbeResult, | |
| 19 | listAcpRegistrySessions, | |
| 20 | logoutAcpRegistry, | |
| 21 | normalizeAcpRegistryAuthMethods, | |
| 22 | normalizeAcpRegistryCommands, | |
| 23 | probeAcpRegistryConfiguration, | |
| 24 | } from "./AcpRegistryProbe.ts"; | |
| 25 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 26 | ||
| 27 | const instanceId = ProviderInstanceId.make("acpRegistry_codex"); | |
| 28 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | |
| 29 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | |
| 30 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 31 | ||
| 32 | describe("ACP Registry probe", () => { | |
| 33 | it("returns advertised auth methods and de-duplicated models", () => { | |
| 34 | const result = acpRegistryProbeResult(instanceId, { | |
| 35 | sessionId: "probe-session", | |
| 36 | initializeResult: { | |
| 37 | protocolVersion: 1, | |
| 38 | agentCapabilities: { | |
| 39 | loadSession: true, | |
| 40 | auth: { logout: {} }, | |
| 41 | sessionCapabilities: { list: {}, resume: {} }, | |
| 42 | }, | |
| 43 | authMethods: [ | |
| 44 | { id: "chatgpt", name: "ChatGPT" }, | |
| 45 | { | |
| 46 | id: "api-key", | |
| 47 | name: "API key", | |
| 48 | type: "env_var", | |
| 49 | vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }], | |
| 50 | }, | |
| 51 | { id: "login", name: "Terminal login", type: "terminal" }, | |
| 52 | ], | |
| 53 | }, | |
| 54 | sessionSetupResult: { | |
| 55 | sessionId: "probe-session", | |
| 56 | configOptions: [ | |
| 57 | { | |
| 58 | id: "model", | |
| 59 | name: "Model", | |
| 60 | category: "model", | |
| 61 | type: "select", | |
| 62 | currentValue: "gpt-5.4", | |
| 63 | options: [ | |
| 64 | { value: "gpt-5.4", name: "GPT-5.4" }, | |
| 65 | { value: "gpt-5.4", name: "Duplicate" }, | |
| 66 | ], | |
| 67 | }, | |
| 68 | ], | |
| 69 | }, | |
| 70 | modelConfigId: "model", | |
| 71 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 72 | ||
| 73 | expect(result).toEqual({ | |
| 74 | instanceId, | |
| 75 | ready: true, | |
| 76 | icon: null, | |
| 77 | authMethods: [ | |
| 78 | { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" }, | |
| 79 | { | |
| 80 | id: "api-key", | |
| 81 | name: "API key", | |
| 82 | description: null, | |
| 83 | type: "env_var", | |
| 84 | envVarNames: ["OPENAI_API_KEY"], | |
| 85 | }, | |
| 86 | { id: "login", name: "Terminal login", description: null, type: "terminal" }, | |
| 87 | ], | |
| 88 | models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }], | |
| 89 | currentModelId: "gpt-5.4", | |
| 90 | configOptions: [], | |
| 91 | sessionManagement: { | |
| 92 | canList: true, | |
| 93 | canLoad: true, | |
| 94 | canResume: true, | |
| 95 | canLogout: true, | |
| 96 | canDelete: false, | |
| 97 | canConfigureProviders: false, | |
| 98 | }, | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | it("composes runnable terminal auth commands from the resolved spawn recipe", () => { | |
| 103 | const methods = normalizeAcpRegistryAuthMethods( | |
| 104 | [ | |
| 105 | { | |
| 106 | id: "login", | |
| 107 | name: "Terminal login", | |
| 108 | type: "terminal", | |
| 109 | args: ["auth", "log in"], | |
| 110 | env: { FORCE_TTY: "1" }, | |
| 111 | }, | |
| 112 | ], | |
| 113 | { command: "/opt/agents/devin", args: ["--acp"] }, | |
| 114 | ); | |
| 115 | ||
| 116 | expect(methods).toEqual([ | |
| 117 | { | |
| 118 | id: "login", | |
| 119 | name: "Terminal login", | |
| 120 | description: null, | |
| 121 | type: "terminal", | |
| 122 | command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'", | |
| 123 | }, | |
| 124 | ]); | |
| 125 | }); | |
| 126 | ||
| 127 | it("omits unsafe truncated auth commands instead of publishing broken shell", () => { | |
| 128 | const methods = normalizeAcpRegistryAuthMethods( | |
| 129 | [ | |
| 130 | { | |
| 131 | id: "login", | |
| 132 | name: "Terminal login", | |
| 133 | type: "terminal", | |
| 134 | args: ["x".repeat(2_048)], | |
| 135 | }, | |
| 136 | ], | |
| 137 | { command: "/opt/agents/devin", args: ["--acp"] }, | |
| 138 | ); | |
| 139 | ||
| 140 | expect(methods[0]).not.toHaveProperty("command"); | |
| 141 | }); | |
| 142 | ||
| 143 | it("omits overlong opaque auth method and environment variable ids", () => { | |
| 144 | const methods = normalizeAcpRegistryAuthMethods([ | |
| 145 | { id: "x".repeat(129), name: "Invalid" }, | |
| 146 | { | |
| 147 | id: "api-key", | |
| 148 | name: "API key", | |
| 149 | type: "env_var", | |
| 150 | vars: [ | |
| 151 | { name: "y".repeat(129), label: "Invalid" }, | |
| 152 | { name: "OPENAI_API_KEY", label: "OpenAI API key" }, | |
| 153 | ], | |
| 154 | }, | |
| 155 | ]); | |
| 156 | ||
| 157 | expect(methods).toEqual([ | |
| 158 | { | |
| 159 | id: "api-key", | |
| 160 | name: "API key", | |
| 161 | description: null, | |
| 162 | type: "env_var", | |
| 163 | envVarNames: ["OPENAI_API_KEY"], | |
| 164 | }, | |
| 165 | ]); | |
| 166 | }); | |
| 167 | ||
| 168 | it("falls back to model-category configuration options", () => { | |
| 169 | const result = acpRegistryProbeResult(instanceId, { | |
| 170 | sessionId: "probe-session", | |
| 171 | initializeResult: { protocolVersion: 1 }, | |
| 172 | sessionSetupResult: { | |
| 173 | sessionId: "probe-session", | |
| 174 | configOptions: [ | |
| 175 | { | |
| 176 | id: "model", | |
| 177 | name: "Model", | |
| 178 | category: "model", | |
| 179 | type: "select", | |
| 180 | currentValue: "sonnet", | |
| 181 | options: [ | |
| 182 | { value: "sonnet", name: "Sonnet", description: "Balanced" }, | |
| 183 | { value: "haiku", name: "Haiku" }, | |
| 184 | ], | |
| 185 | }, | |
| 186 | ], | |
| 187 | }, | |
| 188 | modelConfigId: "model", | |
| 189 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 190 | ||
| 191 | expect(result.models).toEqual([ | |
| 192 | { id: "sonnet", name: "Sonnet", description: "Balanced" }, | |
| 193 | { id: "haiku", name: "Haiku", description: null }, | |
| 194 | ]); | |
| 195 | expect(result.currentModelId).toBe("sonnet"); | |
| 196 | }); | |
| 197 | ||
| 198 | it("omits overlong opaque model ids instead of publishing mutated ids", () => { | |
| 199 | const result = acpRegistryProbeResult(instanceId, { | |
| 200 | sessionId: "probe-session", | |
| 201 | initializeResult: { protocolVersion: 1 }, | |
| 202 | sessionSetupResult: { | |
| 203 | sessionId: "probe-session", | |
| 204 | configOptions: [ | |
| 205 | { | |
| 206 | id: "model", | |
| 207 | name: "Model", | |
| 208 | category: "model", | |
| 209 | type: "select", | |
| 210 | currentValue: "x".repeat(129), | |
| 211 | options: [ | |
| 212 | { value: "x".repeat(129), name: "Too long" }, | |
| 213 | { value: "valid", name: "Valid" }, | |
| 214 | ], | |
| 215 | }, | |
| 216 | ], | |
| 217 | }, | |
| 218 | modelConfigId: "model", | |
| 219 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 220 | ||
| 221 | expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); | |
| 222 | expect(result.currentModelId).toBeNull(); | |
| 223 | }); | |
| 224 | ||
| 225 | it("preserves grouped model values and advertises reasoning choices", () => { | |
| 226 | const codexModel = '["codex","gpt-6.1-sol"]'; | |
| 227 | const goModel = '["opencode-go","deepseek-v4.1-flash"]'; | |
| 228 | const result = acpRegistryProbeResult(instanceId, { | |
| 229 | sessionId: "probe-session", | |
| 230 | initializeResult: { protocolVersion: 1 }, | |
| 231 | sessionSetupResult: { | |
| 232 | sessionId: "probe-session", | |
| 233 | configOptions: [ | |
| 234 | { | |
| 235 | id: "model", | |
| 236 | name: "Model", | |
| 237 | category: "model", | |
| 238 | type: "select", | |
| 239 | currentValue: goModel, | |
| 240 | options: [ | |
| 241 | { | |
| 242 | groupId: "codex", | |
| 243 | name: "Codex", | |
| 244 | options: [{ value: codexModel, name: "GPT-6.1 Sol" }], | |
| 245 | }, | |
| 246 | { | |
| 247 | groupId: "opencode-go", | |
| 248 | name: "OpenCode Go", | |
| 249 | options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }], | |
| 250 | }, | |
| 251 | ], | |
| 252 | }, | |
| 253 | { | |
| 254 | id: "reasoning", | |
| 255 | name: "Reasoning", | |
| 256 | category: "thought_level", | |
| 257 | type: "select", | |
| 258 | currentValue: "high", | |
| 259 | options: [ | |
| 260 | { | |
| 261 | groupId: "effort", | |
| 262 | name: "Effort", | |
| 263 | options: [ | |
| 264 | { value: "medium", name: "Medium" }, | |
| 265 | { value: "high", name: "High" }, | |
| 266 | ], | |
| 267 | }, | |
| 268 | ], | |
| 269 | }, | |
| 270 | ], | |
| 271 | }, | |
| 272 | modelConfigId: "model", | |
| 273 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 274 | ||
| 275 | expect(result.models).toEqual([ | |
| 276 | { id: codexModel, name: "GPT-6.1 Sol", description: null }, | |
| 277 | { id: goModel, name: "DeepSeek V4.1 Flash", description: null }, | |
| 278 | ]); | |
| 279 | expect(result.currentModelId).toBe(goModel); | |
| 280 | expect(result.configOptions).toEqual([ | |
| 281 | { | |
| 282 | id: "reasoning", | |
| 283 | label: "Reasoning", | |
| 284 | type: "select", | |
| 285 | currentValue: "high", | |
| 286 | options: [ | |
| 287 | { id: "medium", label: "Medium" }, | |
| 288 | { id: "high", label: "High" }, | |
| 289 | ], | |
| 290 | }, | |
| 291 | ]); | |
| 292 | }); | |
| 293 | ||
| 294 | it("omits a current model that falls outside the bounded model catalog", () => { | |
| 295 | const result = acpRegistryProbeResult(instanceId, { | |
| 296 | sessionId: "probe-session", | |
| 297 | initializeResult: { protocolVersion: 1 }, | |
| 298 | sessionSetupResult: { | |
| 299 | sessionId: "probe-session", | |
| 300 | configOptions: [ | |
| 301 | { | |
| 302 | id: "model", | |
| 303 | name: "Model", | |
| 304 | category: "model", | |
| 305 | type: "select", | |
| 306 | currentValue: "model-256", | |
| 307 | options: Array.from({ length: 257 }, (_, index) => ({ | |
| 308 | value: `model-${index}`, | |
| 309 | name: `Model ${index}`, | |
| 310 | })), | |
| 311 | }, | |
| 312 | ], | |
| 313 | }, | |
| 314 | modelConfigId: "model", | |
| 315 | } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); | |
| 316 | ||
| 317 | expect(result.models).toHaveLength(256); | |
| 318 | expect(result.currentModelId).toBeNull(); | |
| 319 | }); | |
| 320 | ||
| 321 | it("bounds and de-duplicates advertised commands", () => { | |
| 322 | const commands = normalizeAcpRegistryCommands([ | |
| 323 | { | |
| 324 | name: "create_plan", | |
| 325 | description: " Create a plan ", | |
| 326 | input: { type: "text", hint: " topic " }, | |
| 327 | }, | |
| 328 | { name: "CREATE_PLAN", description: "duplicate" }, | |
| 329 | ...Array.from({ length: 140 }, (_, index) => ({ | |
| 330 | name: `command_${index}`, | |
| 331 | description: "", | |
| 332 | })), | |
| 333 | ]); | |
| 334 | ||
| 335 | expect(commands.slashCommands).toHaveLength(128); | |
| 336 | expect(commands.slashCommands[0]).toEqual({ | |
| 337 | name: "create_plan", | |
| 338 | description: "Create a plan", | |
| 339 | input: { hint: "topic" }, | |
| 340 | }); | |
| 341 | expect( | |
| 342 | commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"), | |
| 343 | ).toHaveLength(1); | |
| 344 | expect(commands.skills).toEqual([]); | |
| 345 | }); | |
| 346 | ||
| 347 | it("routes dollar-prefixed ACP commands to the provider skill menu", () => { | |
| 348 | expect( | |
| 349 | normalizeAcpRegistryCommands([ | |
| 350 | { name: "$workspace-skill", description: "Run the workspace skill" }, | |
| 351 | { name: "$WORKSPACE-SKILL", description: "duplicate" }, | |
| 352 | { name: "$", description: "empty" }, | |
| 353 | { name: "review", description: "Review changes" }, | |
| 354 | ]), | |
| 355 | ).toEqual({ | |
| 356 | slashCommands: [{ name: "review", description: "Review changes" }], | |
| 357 | skills: [ | |
| 358 | { | |
| 359 | name: "workspace-skill", | |
| 360 | description: "Run the workspace skill", | |
| 361 | path: "acp://skill/workspace-skill", | |
| 362 | scope: "agent", | |
| 363 | enabled: true, | |
| 364 | }, | |
| 365 | ], | |
| 366 | }); | |
| 367 | }); | |
| 368 | ||
| 369 | it.effect("captures commands advertised just after session creation", () => | |
| 370 | Effect.gen(function* () { | |
| 371 | const result = yield* probeAcpRegistryConfiguration({ | |
| 372 | instanceId, | |
| 373 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 374 | cwd: process.cwd(), | |
| 375 | environment: process.env, | |
| 376 | }); | |
| 377 | ||
| 378 | expect(result.slashCommands).toEqual([ | |
| 379 | { | |
| 380 | name: "review", | |
| 381 | description: "Review the current changes", | |
| 382 | input: { hint: "focus" }, | |
| 383 | }, | |
| 384 | ]); | |
| 385 | expect(result.skills).toEqual([ | |
| 386 | { | |
| 387 | name: "workspace-skill", | |
| 388 | description: "Run the workspace skill", | |
| 389 | path: "acp://skill/workspace-skill", | |
| 390 | scope: "agent", | |
| 391 | enabled: true, | |
| 392 | }, | |
| 393 | ]); | |
| 394 | }).pipe( | |
| 395 | Effect.provideService( | |
| 396 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 397 | AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 398 | search: () => Effect.die("unused search"), | |
| 399 | prepare: () => Effect.die("unused prepare"), | |
| 400 | inspect: () => Effect.die("unused inspect"), | |
| 401 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 402 | resolve: () => | |
| 403 | Effect.succeed({ | |
| 404 | agent: { | |
| 405 | id: "mock-agent", | |
| 406 | name: "Mock Agent", | |
| 407 | version: "1.0.0", | |
| 408 | description: "ACP probe test agent", | |
| 409 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 410 | }, | |
| 411 | distribution: "npx", | |
| 412 | spawn: { | |
| 413 | command: "node", | |
| 414 | args: [mockAgentPath], | |
| 415 | env: { | |
| 416 | ...process.env, | |
| 417 | T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25", | |
| 418 | }, | |
| 419 | }, | |
| 420 | }), | |
| 421 | }), | |
| 422 | ), | |
| 423 | Effect.provide(NodeServices.layer), | |
| 424 | Effect.scoped, | |
| 425 | ), | |
| 426 | ); | |
| 427 | ||
| 428 | it.effect("includes package resolution in the probe timeout", () => | |
| 429 | Effect.gen(function* () { | |
| 430 | const resolveStarted = yield* Deferred.make<void>(); | |
| 431 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 432 | search: () => Effect.die("unused search"), | |
| 433 | prepare: () => Effect.die("unused prepare"), | |
| 434 | inspect: () => Effect.die("unused inspect"), | |
| 435 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 436 | resolve: () => | |
| 437 | Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)), | |
| 438 | }); | |
| 439 | const probe = yield* probeAcpRegistryConfiguration({ | |
| 440 | instanceId, | |
| 441 | settings: decodeSettings({ agentId: "slow-agent" }), | |
| 442 | cwd: process.cwd(), | |
| 443 | environment: process.env, | |
| 444 | }).pipe( | |
| 445 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 446 | Effect.flip, | |
| 447 | Effect.forkChild({ startImmediately: true }), | |
| 448 | ); | |
| 449 | ||
| 450 | yield* Deferred.await(resolveStarted); | |
| 451 | yield* TestClock.adjust("60 seconds"); | |
| 452 | ||
| 453 | expect(yield* Fiber.join(probe)).toMatchObject({ | |
| 454 | reason: "probe_failed", | |
| 455 | message: expect.stringContaining("within 60 seconds"), | |
| 456 | }); | |
| 457 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 458 | ); | |
| 459 | ||
| 460 | it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => { | |
| 461 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 462 | search: () => Effect.die("unused search"), | |
| 463 | prepare: () => Effect.die("unused prepare"), | |
| 464 | inspect: () => Effect.die("unused inspect"), | |
| 465 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 466 | resolve: () => | |
| 467 | Effect.succeed({ | |
| 468 | agent: { | |
| 469 | id: "mock-agent", | |
| 470 | name: "Mock Agent", | |
| 471 | version: "1.0.0", | |
| 472 | description: "ACP session management test agent", | |
| 473 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 474 | }, | |
| 475 | distribution: "npx" as const, | |
| 476 | spawn: { | |
| 477 | command: "node", | |
| 478 | args: [mockAgentPath], | |
| 479 | env: { | |
| 480 | ...process.env, | |
| 481 | T3_ACP_SESSION_LIFECYCLE: "1", | |
| 482 | T3_ACP_AUTH_METHOD_ID: "mock-login", | |
| 483 | }, | |
| 484 | }, | |
| 485 | }), | |
| 486 | }); | |
| 487 | const input = { | |
| 488 | instanceId, | |
| 489 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 490 | cwd: process.cwd(), | |
| 491 | environment: process.env, | |
| 492 | }; | |
| 493 | ||
| 494 | return Effect.gen(function* () { | |
| 495 | const listed = yield* listAcpRegistrySessions(input); | |
| 496 | expect(listed).toMatchObject({ | |
| 497 | canLoad: true, | |
| 498 | canResume: true, | |
| 499 | nextCursor: null, | |
| 500 | sessions: [ | |
| 501 | { | |
| 502 | sessionId: "mock-session-1", | |
| 503 | cwd: process.cwd(), | |
| 504 | importedThreadId: null, | |
| 505 | }, | |
| 506 | ], | |
| 507 | }); | |
| 508 | yield* logoutAcpRegistry(input); | |
| 509 | }).pipe( | |
| 510 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 511 | Effect.provide(NodeServices.layer), | |
| 512 | Effect.scoped, | |
| 513 | ); | |
| 514 | }); | |
| 515 | ||
| 516 | it.effect("preserves the ACP method error for unsupported session management", () => { | |
| 517 | const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ | |
| 518 | search: () => Effect.die("unused search"), | |
| 519 | prepare: () => Effect.die("unused prepare"), | |
| 520 | inspect: () => Effect.die("unused inspect"), | |
| 521 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 522 | resolve: () => | |
| 523 | Effect.succeed({ | |
| 524 | agent: { | |
| 525 | id: "mock-agent", | |
| 526 | name: "Mock Agent", | |
| 527 | version: "1.0.0", | |
| 528 | description: "ACP unsupported management test agent", | |
| 529 | distribution: { npx: { package: "mock-agent@1.0.0" } }, | |
| 530 | }, | |
| 531 | distribution: "npx" as const, | |
| 532 | spawn: { | |
| 533 | command: "node", | |
| 534 | args: [mockAgentPath], | |
| 535 | env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" }, | |
| 536 | }, | |
| 537 | }), | |
| 538 | }); | |
| 539 | ||
| 540 | return Effect.gen(function* () { | |
| 541 | const failure = yield* listAcpRegistrySessions({ | |
| 542 | instanceId, | |
| 543 | settings: decodeSettings({ agentId: "mock-agent" }), | |
| 544 | cwd: process.cwd(), | |
| 545 | environment: process.env, | |
| 546 | }).pipe(Effect.flip); | |
| 547 | ||
| 548 | expect(failure).toMatchObject({ | |
| 549 | reason: "session_list_unsupported", | |
| 550 | message: "The ACP agent does not advertise session listing.", | |
| 551 | cause: { _tag: "AcpRequestError", code: -32601 }, | |
| 552 | }); | |
| 553 | }).pipe( | |
| 554 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 555 | Effect.provide(NodeServices.layer), | |
| 556 | Effect.scoped, | |
| 557 | ); | |
| 558 | }); | |
| 559 | ||
| 560 | it("distinguishes authentication failures from generic probe failures", () => { | |
| 561 | const advertised = normalizeAcpRegistryAuthMethods([ | |
| 562 | { id: "grok-login", name: "Log in with Grok" }, | |
| 563 | ]); | |
| 564 | const cause = new EffectAcpErrors.AcpRequestError({ | |
| 565 | code: -32000, | |
| 566 | errorMessage: "login required", | |
| 567 | }); | |
| 568 | const authFailure = acpRegistryProbeFailure(cause, advertised); | |
| 569 | expect(authFailure.reason).toBe("authentication_failed"); | |
| 570 | expect(authFailure.message).toBe("The ACP agent could not complete authentication."); | |
| 571 | expect(authFailure.cause).toBe(cause); | |
| 572 | expect(authFailure.authMethods).toEqual([ | |
| 573 | { | |
| 574 | id: "grok-login", | |
| 575 | name: "Log in with Grok", | |
| 576 | description: null, | |
| 577 | type: "agent", | |
| 578 | }, | |
| 579 | ]); | |
| 580 | expect( | |
| 581 | acpRegistryProbeFailure( | |
| 582 | new EffectAcpErrors.AcpTransportError({ | |
| 583 | detail: "Authentication required", | |
| 584 | cause: "credentials missing", | |
| 585 | }), | |
| 586 | ).reason, | |
| 587 | ).toBe("authentication_failed"); | |
| 588 | const genericCause = new EffectAcpErrors.AcpTransportError({ | |
| 589 | detail: "connection closed", | |
| 590 | cause: "closed", | |
| 591 | }); | |
| 592 | const genericFailure = acpRegistryProbeFailure(genericCause); | |
| 593 | expect(genericFailure.reason).toBe("probe_failed"); | |
| 594 | expect(genericFailure.message).toBe("The ACP agent could not create a test session."); | |
| 595 | expect(genericFailure.cause).toBe(genericCause); | |
| 596 | expect( | |
| 597 | acpRegistryProbeFailure( | |
| 598 | new EffectAcpErrors.AcpTransportError({ | |
| 599 | detail: "Failed while logging request", | |
| 600 | cause: "logging failed", | |
| 601 | }), | |
| 602 | ).reason, | |
| 603 | ).toBe("probe_failed"); | |
| 604 | }); | |
| 605 | }); |
apps/server/src/provider/acp/AcpRegistryProbe.ts deleted-782| ... | ... | @@ -1,782 +0,0 @@ |
| 1 | import { | |
| 2 | AcpRegistryListProvidersResult, | |
| 3 | AcpRegistryOperationError, | |
| 4 | AcpRegistryListSessionsResult, | |
| 5 | AcpRegistryProbeResult, | |
| 6 | AcpRegistrySettings, | |
| 7 | type AcpRegistryProbeAuthMethod, | |
| 8 | type AcpRegistryProbeModel, | |
| 9 | type AcpRegistryUrlAuthAction, | |
| 10 | type ProviderInstanceId, | |
| 11 | type ServerProviderSkill, | |
| 12 | type ServerProviderSlashCommand, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Crypto from "effect/Crypto"; | |
| 15 | import * as Deferred from "effect/Deferred"; | |
| 16 | import * as Duration from "effect/Duration"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as Layer from "effect/Layer"; | |
| 19 | import * as Option from "effect/Option"; | |
| 20 | import * as Ref from "effect/Ref"; | |
| 21 | import * as Result from "effect/Result"; | |
| 22 | import { ChildProcessSpawner } from "effect/process"; | |
| 23 | import type * as EffectAcpErrors from "effect-acp/errors"; | |
| 24 | import type * as EffectAcpSchema from "effect-acp/compat"; | |
| 25 | ||
| 26 | import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; | |
| 27 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 28 | import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; | |
| 29 | import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; | |
| 30 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 31 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 32 | ||
| 33 | const MAX_AUTH_METHODS = 32; | |
| 34 | const MAX_MODELS = 256; | |
| 35 | const MAX_ID_LENGTH = 128; | |
| 36 | const MAX_NAME_LENGTH = 160; | |
| 37 | const MAX_DESCRIPTION_LENGTH = 1_024; | |
| 38 | const MAX_COMMANDS = 128; | |
| 39 | const MAX_COMMAND_LINE_LENGTH = 2_048; | |
| 40 | // Covers a cold package command launch and ACP initialization after registry | |
| 41 | // preparation globally installs the exact package. | |
| 42 | const PROBE_TIMEOUT_SECONDS = 60; | |
| 43 | const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; | |
| 44 | const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; | |
| 45 | ||
| 46 | const boundedText = (value: string, maximumLength: number): string => | |
| 47 | value.trim().slice(0, maximumLength); | |
| 48 | ||
| 49 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => | |
| 50 | value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; | |
| 51 | ||
| 52 | export function normalizeAcpRegistryWebUrl(value: string): string | undefined { | |
| 53 | if (value.length > 2_048 || !URL.canParse(value)) return undefined; | |
| 54 | const url = new URL(value); | |
| 55 | return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; | |
| 56 | } | |
| 57 | ||
| 58 | function modelConfigOptions( | |
| 59 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | |
| 60 | ): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> { | |
| 61 | return (configOptions ?? []).flatMap((option) => { | |
| 62 | if (option.category !== "model" || option.type !== "select") return []; | |
| 63 | return option.options.flatMap((candidate) => | |
| 64 | "value" in candidate ? [candidate] : candidate.options, | |
| 65 | ); | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | function normalizeModels( | |
| 70 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | |
| 71 | ): ReadonlyArray<AcpRegistryProbeModel> { | |
| 72 | // Model discovery is the model config option's base models; ACP has no | |
| 73 | // other portable model inventory. | |
| 74 | const candidates = modelConfigOptions(configOptions).map((model) => ({ | |
| 75 | id: model.value, | |
| 76 | name: model.name, | |
| 77 | description: model.description ?? null, | |
| 78 | })); | |
| 79 | const seen = new Set<string>(); | |
| 80 | const models: Array<AcpRegistryProbeModel> = []; | |
| 81 | for (const candidate of candidates) { | |
| 82 | const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH); | |
| 83 | if (id === undefined || seen.has(id)) continue; | |
| 84 | seen.add(id); | |
| 85 | models.push({ | |
| 86 | id, | |
| 87 | name: boundedText(candidate.name, MAX_NAME_LENGTH) || id, | |
| 88 | description: | |
| 89 | candidate.description === null | |
| 90 | ? null | |
| 91 | : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null, | |
| 92 | }); | |
| 93 | if (models.length === MAX_MODELS) break; | |
| 94 | } | |
| 95 | return models; | |
| 96 | } | |
| 97 | ||
| 98 | /** Agent spawn recipe used to compose runnable terminal-auth command lines. */ | |
| 99 | export interface AcpRegistryAuthSpawnContext { | |
| 100 | readonly command: string; | |
| 101 | readonly args: ReadonlyArray<string>; | |
| 102 | } | |
| 103 | ||
| 104 | const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u; | |
| 105 | ||
| 106 | function shellDisplayToken(token: string): string { | |
| 107 | return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; | |
| 108 | } | |
| 109 | ||
| 110 | function terminalAuthCommand( | |
| 111 | method: Extract<EffectAcpSchema.AuthMethod, { readonly type: "terminal" }>, | |
| 112 | spawn: AcpRegistryAuthSpawnContext, | |
| 113 | ): string | undefined { | |
| 114 | const environmentPrefix = Object.entries(method.env ?? {}).map( | |
| 115 | ([name, value]) => `${name}=${shellDisplayToken(value)}`, | |
| 116 | ); | |
| 117 | const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken); | |
| 118 | const displayCommand = [...environmentPrefix, ...command].join(" "); | |
| 119 | return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined; | |
| 120 | } | |
| 121 | ||
| 122 | export function normalizeAcpRegistryAuthMethods( | |
| 123 | methods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | |
| 124 | spawn?: AcpRegistryAuthSpawnContext, | |
| 125 | ): ReadonlyArray<AcpRegistryProbeAuthMethod> { | |
| 126 | const normalized: Array<AcpRegistryProbeAuthMethod> = []; | |
| 127 | for (const method of methods ?? []) { | |
| 128 | const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH); | |
| 129 | if (id === undefined) continue; | |
| 130 | const type = method.type ?? "agent"; | |
| 131 | const envVarNames = | |
| 132 | type === "env_var" && "vars" in method | |
| 133 | ? method.vars | |
| 134 | .flatMap((variable) => { | |
| 135 | const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH); | |
| 136 | return name === undefined ? [] : [name]; | |
| 137 | }) | |
| 138 | .slice(0, 16) | |
| 139 | : []; | |
| 140 | const command = | |
| 141 | spawn !== undefined && "type" in method && method.type === "terminal" | |
| 142 | ? terminalAuthCommand(method, spawn) | |
| 143 | : undefined; | |
| 144 | const link = | |
| 145 | type === "env_var" && "link" in method && method.link | |
| 146 | ? normalizeAcpRegistryWebUrl(method.link) | |
| 147 | : undefined; | |
| 148 | normalized.push({ | |
| 149 | id, | |
| 150 | name: boundedText(method.name, MAX_NAME_LENGTH) || id, | |
| 151 | description: | |
| 152 | method.description == null | |
| 153 | ? null | |
| 154 | : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null, | |
| 155 | type, | |
| 156 | ...(command === undefined ? {} : { command }), | |
| 157 | ...(envVarNames.length > 0 ? { envVarNames } : {}), | |
| 158 | ...(link === undefined ? {} : { link }), | |
| 159 | }); | |
| 160 | if (normalized.length === MAX_AUTH_METHODS) break; | |
| 161 | } | |
| 162 | return normalized; | |
| 163 | } | |
| 164 | ||
| 165 | export interface AcpRegistryAvailableCommands { | |
| 166 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 167 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 168 | } | |
| 169 | ||
| 170 | export interface AcpRegistryLiveConfiguration { | |
| 171 | readonly models: ReadonlyArray<AcpRegistryProbeModel>; | |
| 172 | readonly currentModelId: string | null; | |
| 173 | readonly configOptions: AcpRegistryProbeResult["configOptions"]; | |
| 174 | } | |
| 175 | ||
| 176 | /** Normalizes volatile session configuration using the same bounds as discovery probes. */ | |
| 177 | export function normalizeAcpRegistryLiveConfiguration( | |
| 178 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | |
| 179 | modeState?: Parameters<typeof acpProviderOptionDescriptors>[0]["modeState"], | |
| 180 | ): AcpRegistryLiveConfiguration { | |
| 181 | const modelOption = configOptions.find( | |
| 182 | (option) => option.category === "model" && option.type === "select", | |
| 183 | ); | |
| 184 | const boundedCurrentModelId = | |
| 185 | modelOption?.type === "select" | |
| 186 | ? (boundedOpaqueValue(modelOption.currentValue, MAX_ID_LENGTH) ?? null) | |
| 187 | : null; | |
| 188 | const models = normalizeModels(configOptions); | |
| 189 | return { | |
| 190 | models, | |
| 191 | currentModelId: models.some((model) => model.id === boundedCurrentModelId) | |
| 192 | ? boundedCurrentModelId | |
| 193 | : null, | |
| 194 | configOptions: acpProviderOptionDescriptors({ configOptions, modeState }), | |
| 195 | }; | |
| 196 | } | |
| 197 | ||
| 198 | const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({ | |
| 199 | slashCommands: [], | |
| 200 | skills: [], | |
| 201 | }); | |
| 202 | ||
| 203 | /** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */ | |
| 204 | export function normalizeAcpRegistryCommands( | |
| 205 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | |
| 206 | ): AcpRegistryAvailableCommands { | |
| 207 | const seen = new Set<string>(); | |
| 208 | const slashCommands: Array<ServerProviderSlashCommand> = []; | |
| 209 | const skills: Array<ServerProviderSkill> = []; | |
| 210 | let accepted = 0; | |
| 211 | for (const command of commands) { | |
| 212 | const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH); | |
| 213 | if (name === undefined) continue; | |
| 214 | const key = name.toLowerCase(); | |
| 215 | if (seen.has(key)) continue; | |
| 216 | seen.add(key); | |
| 217 | const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH); | |
| 218 | const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : ""; | |
| 219 | if (name.startsWith("$")) { | |
| 220 | const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH); | |
| 221 | if (skillName === undefined) continue; | |
| 222 | skills.push({ | |
| 223 | name: skillName, | |
| 224 | ...(description ? { description } : {}), | |
| 225 | path: `acp://skill/${encodeURIComponent(skillName)}`, | |
| 226 | scope: "agent", | |
| 227 | enabled: true, | |
| 228 | }); | |
| 229 | } else { | |
| 230 | slashCommands.push({ | |
| 231 | name, | |
| 232 | ...(description ? { description } : {}), | |
| 233 | ...(hint ? { input: { hint } } : {}), | |
| 234 | }); | |
| 235 | } | |
| 236 | accepted += 1; | |
| 237 | if (accepted === MAX_COMMANDS) break; | |
| 238 | } | |
| 239 | return { slashCommands, skills }; | |
| 240 | } | |
| 241 | ||
| 242 | /** Builds the bounded wire result from a successfully created disposable ACP session. */ | |
| 243 | export function acpRegistryProbeResult( | |
| 244 | instanceId: ProviderInstanceId, | |
| 245 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, | |
| 246 | icon: string | null = null, | |
| 247 | spawn?: AcpRegistryAuthSpawnContext, | |
| 248 | ): AcpRegistryProbeResult { | |
| 249 | const liveConfiguration = normalizeAcpRegistryLiveConfiguration( | |
| 250 | started.sessionSetupResult.configOptions ?? [], | |
| 251 | parseSessionModeState(started.sessionSetupResult), | |
| 252 | ); | |
| 253 | return AcpRegistryProbeResult.make({ | |
| 254 | instanceId, | |
| 255 | ready: true, | |
| 256 | icon, | |
| 257 | authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn), | |
| 258 | sessionManagement: { | |
| 259 | canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null, | |
| 260 | canLoad: started.initializeResult.agentCapabilities?.loadSession === true, | |
| 261 | canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null, | |
| 262 | canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null, | |
| 263 | canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null, | |
| 264 | canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null, | |
| 265 | }, | |
| 266 | ...liveConfiguration, | |
| 267 | }); | |
| 268 | } | |
| 269 | ||
| 270 | export function acpRegistryProbeFailure( | |
| 271 | error: EffectAcpErrors.AcpError, | |
| 272 | authMethods: ReadonlyArray<AcpRegistryProbeAuthMethod> = [], | |
| 273 | authAction?: AcpRegistryUrlAuthAction, | |
| 274 | ): AcpRegistryOperationError { | |
| 275 | const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message; | |
| 276 | const authenticationFailed = | |
| 277 | (error._tag === "AcpRequestError" && error.code === -32000) || | |
| 278 | /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail); | |
| 279 | return new AcpRegistryOperationError({ | |
| 280 | reason: authenticationFailed ? "authentication_failed" : "probe_failed", | |
| 281 | message: authenticationFailed | |
| 282 | ? "The ACP agent could not complete authentication." | |
| 283 | : "The ACP agent could not create a test session.", | |
| 284 | cause: error, | |
| 285 | ...(authMethods.length > 0 ? { authMethods } : {}), | |
| 286 | ...(authAction === undefined ? {} : { authAction }), | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | export interface AcpRegistryConfigurationProbeInput { | |
| 291 | readonly instanceId: ProviderInstanceId; | |
| 292 | readonly settings: AcpRegistrySettings; | |
| 293 | readonly cwd: string; | |
| 294 | readonly environment: NodeJS.ProcessEnv; | |
| 295 | } | |
| 296 | ||
| 297 | type AcpRegistryManagementInput = AcpRegistryConfigurationProbeInput; | |
| 298 | ||
| 299 | export interface AcpRegistryConfigurationProbeResult { | |
| 300 | readonly probe: AcpRegistryProbeResult; | |
| 301 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 302 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 303 | } | |
| 304 | ||
| 305 | export type AcpRegistryConfigurationProbe<Requirements = never> = ( | |
| 306 | input: AcpRegistryConfigurationProbeInput, | |
| 307 | ) => Effect.Effect<AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, Requirements>; | |
| 308 | ||
| 309 | /** Starts and immediately disposes an ACP session for one already-decoded configuration. */ | |
| 310 | export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")( | |
| 311 | function* ( | |
| 312 | input: AcpRegistryConfigurationProbeInput, | |
| 313 | ): Effect.fn.Return< | |
| 314 | AcpRegistryConfigurationProbeResult, | |
| 315 | AcpRegistryOperationError, | |
| 316 | AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | |
| 317 | > { | |
| 318 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 319 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 320 | const crypto = yield* Crypto.Crypto; | |
| 321 | const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); | |
| 322 | ||
| 323 | const result = yield* Effect.gen(function* () { | |
| 324 | // Resolution may install a missing registry package. Keep it inside the | |
| 325 | // probe deadline so a provider refresh cannot inherit the installer's | |
| 326 | // much longer timeout. | |
| 327 | const resolved = yield* catalog | |
| 328 | .resolve(input.settings, input.cwd, input.environment) | |
| 329 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)); | |
| 330 | const authMethodsRef = yield* Ref.make<ReadonlyArray<AcpRegistryProbeAuthMethod>>([]); | |
| 331 | const authActionRef = yield* Ref.make<AcpRegistryUrlAuthAction | undefined>(undefined); | |
| 332 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 333 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 334 | ); | |
| 335 | const runtimeContext = yield* Layer.build( | |
| 336 | AcpSessionRuntime.layer({ | |
| 337 | spawn: resolved.spawn, | |
| 338 | cwd: input.cwd, | |
| 339 | clientCapabilities: { | |
| 340 | auth: { terminal: Option.isSome(pty) }, | |
| 341 | elicitation: { url: {} }, | |
| 342 | fs: { readTextFile: false, writeTextFile: false }, | |
| 343 | terminal: false, | |
| 344 | }, | |
| 345 | clientInfo: { name: "t3-code-provider-test", version: "0.0.0" }, | |
| 346 | authenticateOnAuthRequired: false, | |
| 347 | onInitialized: (initializeResult) => | |
| 348 | Ref.set( | |
| 349 | authMethodsRef, | |
| 350 | normalizeAcpRegistryAuthMethods(initializeResult.authMethods, { | |
| 351 | command: resolved.spawn.command, | |
| 352 | args: resolved.spawn.args, | |
| 353 | }), | |
| 354 | ), | |
| 355 | ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}), | |
| 356 | }).pipe( | |
| 357 | Layer.provide( | |
| 358 | Layer.mergeAll( | |
| 359 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 360 | Layer.succeed(Crypto.Crypto, crypto), | |
| 361 | ), | |
| 362 | ), | |
| 363 | ), | |
| 364 | ); | |
| 365 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 366 | Effect.provide(runtimeContext), | |
| 367 | ); | |
| 368 | const commandsRef = yield* Ref.make<AcpRegistryAvailableCommands>( | |
| 369 | emptyAcpRegistryAvailableCommands(), | |
| 370 | ); | |
| 371 | const commandsAdvertised = yield* Deferred.make<void>(); | |
| 372 | yield* runtime.handleElicitation((request) => | |
| 373 | Effect.gen(function* () { | |
| 374 | if ( | |
| 375 | request.mode !== "url" || | |
| 376 | !("url" in request) || | |
| 377 | !("elicitationId" in request) || | |
| 378 | typeof request.url !== "string" || | |
| 379 | typeof request.elicitationId !== "string" | |
| 380 | ) { | |
| 381 | return { action: "decline" } as const; | |
| 382 | } | |
| 383 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 384 | const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH); | |
| 385 | if (url === undefined || elicitationId === undefined) { | |
| 386 | return { action: "decline" } as const; | |
| 387 | } | |
| 388 | const action: AcpRegistryUrlAuthAction = { | |
| 389 | elicitationId, | |
| 390 | url, | |
| 391 | message: boundedText(request.message, MAX_DESCRIPTION_LENGTH), | |
| 392 | }; | |
| 393 | yield* Ref.set(authActionRef, action); | |
| 394 | const accepted = yield* Option.match(runtimeCoordinator, { | |
| 395 | onNone: () => Effect.succeed(false), | |
| 396 | onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action), | |
| 397 | }); | |
| 398 | return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const); | |
| 399 | }), | |
| 400 | ); | |
| 401 | yield* runtime.handleSessionUpdate((notification) => { | |
| 402 | const update = notification.update; | |
| 403 | return update.sessionUpdate === "available_commands_update" | |
| 404 | ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe( | |
| 405 | Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)), | |
| 406 | Effect.asVoid, | |
| 407 | ) | |
| 408 | : Effect.void; | |
| 409 | }); | |
| 410 | const startResult = yield* Effect.result(runtime.start()); | |
| 411 | if (Result.isFailure(startResult)) { | |
| 412 | return yield* acpRegistryProbeFailure( | |
| 413 | startResult.failure, | |
| 414 | yield* Ref.get(authMethodsRef), | |
| 415 | yield* Ref.get(authActionRef), | |
| 416 | ); | |
| 417 | } | |
| 418 | const started = startResult.success; | |
| 419 | yield* Deferred.await(commandsAdvertised).pipe( | |
| 420 | Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE), | |
| 421 | ); | |
| 422 | return { resolved, started, commands: yield* Ref.get(commandsRef) }; | |
| 423 | }).pipe( | |
| 424 | Effect.scoped, | |
| 425 | Effect.timeoutOrElse({ | |
| 426 | duration: PROBE_TIMEOUT, | |
| 427 | orElse: () => | |
| 428 | Effect.fail( | |
| 429 | new AcpRegistryOperationError({ | |
| 430 | reason: "probe_failed", | |
| 431 | message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`, | |
| 432 | }), | |
| 433 | ), | |
| 434 | }), | |
| 435 | Effect.mapError((error) => | |
| 436 | error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error), | |
| 437 | ), | |
| 438 | ); | |
| 439 | return { | |
| 440 | probe: acpRegistryProbeResult( | |
| 441 | input.instanceId, | |
| 442 | result.started, | |
| 443 | result.resolved.agent?.icon ?? null, | |
| 444 | { | |
| 445 | command: result.resolved.spawn.command, | |
| 446 | args: result.resolved.spawn.args, | |
| 447 | }, | |
| 448 | ), | |
| 449 | slashCommands: result.commands.slashCommands, | |
| 450 | skills: result.commands.skills, | |
| 451 | }; | |
| 452 | }, | |
| 453 | ); | |
| 454 | ||
| 455 | const MANAGEMENT_TIMEOUT = Duration.seconds(60); | |
| 456 | ||
| 457 | const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")( | |
| 458 | function* (input: AcpRegistryManagementInput) { | |
| 459 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 460 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 461 | const crypto = yield* Crypto.Crypto; | |
| 462 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 463 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 464 | ); | |
| 465 | const resolved = yield* catalog | |
| 466 | .resolve(input.settings, input.cwd, input.environment) | |
| 467 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)); | |
| 468 | const runtimeContext = yield* Layer.build( | |
| 469 | AcpSessionRuntime.layer({ | |
| 470 | spawn: resolved.spawn, | |
| 471 | cwd: input.cwd, | |
| 472 | clientCapabilities: { | |
| 473 | auth: { terminal: false }, | |
| 474 | elicitation: { url: {} }, | |
| 475 | fs: { readTextFile: false, writeTextFile: false }, | |
| 476 | terminal: false, | |
| 477 | }, | |
| 478 | clientInfo: { name: "t3-code-session-manager", version: "0.0.0" }, | |
| 479 | authenticateOnAuthRequired: false, | |
| 480 | ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}), | |
| 481 | }).pipe( | |
| 482 | Layer.provide( | |
| 483 | Layer.mergeAll( | |
| 484 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 485 | Layer.succeed(Crypto.Crypto, crypto), | |
| 486 | ), | |
| 487 | ), | |
| 488 | ), | |
| 489 | ); | |
| 490 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 491 | Effect.provide(runtimeContext), | |
| 492 | ); | |
| 493 | yield* runtime.handleElicitation((request) => | |
| 494 | Effect.gen(function* () { | |
| 495 | if ( | |
| 496 | request.mode !== "url" || | |
| 497 | !("url" in request) || | |
| 498 | !("elicitationId" in request) || | |
| 499 | typeof request.url !== "string" || | |
| 500 | typeof request.elicitationId !== "string" | |
| 501 | ) { | |
| 502 | return { action: "decline" } as const; | |
| 503 | } | |
| 504 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 505 | const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH); | |
| 506 | if (url === undefined || elicitationId === undefined) { | |
| 507 | return { action: "decline" } as const; | |
| 508 | } | |
| 509 | const action: AcpRegistryUrlAuthAction = { | |
| 510 | elicitationId, | |
| 511 | url, | |
| 512 | message: boundedText(request.message, MAX_DESCRIPTION_LENGTH), | |
| 513 | }; | |
| 514 | const accepted = yield* Option.match(runtimeCoordinator, { | |
| 515 | onNone: () => Effect.succeed(false), | |
| 516 | onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action), | |
| 517 | }); | |
| 518 | return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const); | |
| 519 | }), | |
| 520 | ); | |
| 521 | return runtime; | |
| 522 | }, | |
| 523 | ); | |
| 524 | ||
| 525 | function managementFailure( | |
| 526 | operation: | |
| 527 | | "list" | |
| 528 | | "delete" | |
| 529 | | "providers_list" | |
| 530 | | "providers_set" | |
| 531 | | "providers_disable" | |
| 532 | | "logout", | |
| 533 | error: EffectAcpErrors.AcpError | AcpRegistryOperationError, | |
| 534 | ): AcpRegistryOperationError { | |
| 535 | if (error._tag === "AcpRegistryOperationError") return error; | |
| 536 | if (error._tag === "AcpRequestError" && error.code === -32601) { | |
| 537 | if (operation === "list") { | |
| 538 | return new AcpRegistryOperationError({ | |
| 539 | reason: "session_list_unsupported", | |
| 540 | message: "The ACP agent does not advertise session listing.", | |
| 541 | cause: error, | |
| 542 | }); | |
| 543 | } | |
| 544 | if (operation === "delete") { | |
| 545 | return new AcpRegistryOperationError({ | |
| 546 | reason: "session_delete_unsupported", | |
| 547 | message: "The ACP agent does not advertise session deletion.", | |
| 548 | cause: error, | |
| 549 | }); | |
| 550 | } | |
| 551 | if (operation.startsWith("providers_")) { | |
| 552 | return new AcpRegistryOperationError({ | |
| 553 | reason: "providers_unsupported", | |
| 554 | message: "The ACP agent does not advertise provider configuration.", | |
| 555 | cause: error, | |
| 556 | }); | |
| 557 | } | |
| 558 | return new AcpRegistryOperationError({ | |
| 559 | reason: "logout_unsupported", | |
| 560 | message: "The ACP agent does not advertise logout.", | |
| 561 | cause: error, | |
| 562 | }); | |
| 563 | } | |
| 564 | const classified = acpRegistryProbeFailure(error); | |
| 565 | if (classified.reason === "authentication_failed") return classified; | |
| 566 | const failure = | |
| 567 | operation === "delete" | |
| 568 | ? { reason: "session_delete_failed" as const, message: "Could not delete the ACP session." } | |
| 569 | : operation === "providers_list" | |
| 570 | ? { reason: "providers_list_failed" as const, message: "Could not list ACP providers." } | |
| 571 | : operation === "providers_set" || operation === "providers_disable" | |
| 572 | ? { | |
| 573 | reason: "provider_configuration_failed" as const, | |
| 574 | message: "Could not update the ACP provider configuration.", | |
| 575 | } | |
| 576 | : undefined; | |
| 577 | return failure === undefined | |
| 578 | ? classified | |
| 579 | : new AcpRegistryOperationError({ ...failure, cause: error }); | |
| 580 | } | |
| 581 | ||
| 582 | export const listAcpRegistrySessions = Effect.fn("AcpRegistryProbe.listSessions")( | |
| 583 | function* (input: AcpRegistryManagementInput & { readonly cursor?: string }) { | |
| 584 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 585 | const initialized = yield* runtime.initialize(); | |
| 586 | const listed = yield* runtime.listSessions(input.cursor); | |
| 587 | const sessions = listed.sessions.slice(0, 256).flatMap((session) => { | |
| 588 | const sessionId = boundedOpaqueValue(session.sessionId, 1_024); | |
| 589 | const cwd = boundedOpaqueValue(session.cwd, 4_096); | |
| 590 | if (sessionId === undefined || cwd === undefined) return []; | |
| 591 | return [ | |
| 592 | { | |
| 593 | sessionId, | |
| 594 | cwd, | |
| 595 | additionalDirectories: (session.additionalDirectories ?? []) | |
| 596 | .flatMap((directory) => { | |
| 597 | const normalized = boundedOpaqueValue(directory, 4_096); | |
| 598 | return normalized === undefined ? [] : [normalized]; | |
| 599 | }) | |
| 600 | .slice(0, 32), | |
| 601 | title: | |
| 602 | session.title === undefined || session.title === null | |
| 603 | ? null | |
| 604 | : boundedText(session.title, 1_024) || null, | |
| 605 | updatedAt: | |
| 606 | session.updatedAt === undefined || session.updatedAt === null | |
| 607 | ? null | |
| 608 | : boundedText(session.updatedAt, 128) || null, | |
| 609 | importedThreadId: null, | |
| 610 | }, | |
| 611 | ]; | |
| 612 | }); | |
| 613 | return AcpRegistryListSessionsResult.make({ | |
| 614 | sessions, | |
| 615 | nextCursor: | |
| 616 | listed.nextCursor === undefined || listed.nextCursor === null | |
| 617 | ? null | |
| 618 | : boundedText(listed.nextCursor, 2_048) || null, | |
| 619 | canLoad: initialized.agentCapabilities?.loadSession === true, | |
| 620 | canResume: initialized.agentCapabilities?.sessionCapabilities?.resume != null, | |
| 621 | canDelete: initialized.agentCapabilities?.sessionCapabilities?.delete != null, | |
| 622 | }); | |
| 623 | }, | |
| 624 | (effect) => | |
| 625 | effect.pipe( | |
| 626 | Effect.scoped, | |
| 627 | Effect.timeoutOrElse({ | |
| 628 | duration: MANAGEMENT_TIMEOUT, | |
| 629 | orElse: () => | |
| 630 | Effect.fail( | |
| 631 | new AcpRegistryOperationError({ | |
| 632 | reason: "probe_failed", | |
| 633 | message: "The ACP session list request timed out.", | |
| 634 | }), | |
| 635 | ), | |
| 636 | }), | |
| 637 | Effect.mapError((error) => managementFailure("list", error)), | |
| 638 | ), | |
| 639 | ); | |
| 640 | ||
| 641 | export const deleteAcpRegistrySession = Effect.fn("AcpRegistryProbe.deleteSession")( | |
| 642 | function* (input: AcpRegistryManagementInput & { readonly sessionId: string }) { | |
| 643 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 644 | yield* runtime.deleteSession(input.sessionId); | |
| 645 | }, | |
| 646 | (effect) => | |
| 647 | effect.pipe( | |
| 648 | Effect.scoped, | |
| 649 | Effect.timeoutOrElse({ | |
| 650 | duration: MANAGEMENT_TIMEOUT, | |
| 651 | orElse: () => | |
| 652 | Effect.fail( | |
| 653 | new AcpRegistryOperationError({ | |
| 654 | reason: "session_delete_failed", | |
| 655 | message: "The ACP session delete request timed out.", | |
| 656 | }), | |
| 657 | ), | |
| 658 | }), | |
| 659 | Effect.mapError((error) => managementFailure("delete", error)), | |
| 660 | ), | |
| 661 | ); | |
| 662 | ||
| 663 | export const listAcpRegistryProviders = Effect.fn("AcpRegistryProbe.listProviders")( | |
| 664 | function* (input: AcpRegistryManagementInput) { | |
| 665 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 666 | const listed = yield* runtime.listProviders; | |
| 667 | return AcpRegistryListProvidersResult.make({ | |
| 668 | providers: listed.providers.slice(0, 64).flatMap((provider) => { | |
| 669 | const providerId = boundedOpaqueValue(provider.providerId, 256); | |
| 670 | if (providerId === undefined) return []; | |
| 671 | const supported = provider.supported | |
| 672 | .flatMap((protocol) => { | |
| 673 | const value = boundedOpaqueValue(protocol, 64); | |
| 674 | return value === undefined ? [] : [value]; | |
| 675 | }) | |
| 676 | .slice(0, 16); | |
| 677 | const current = provider.current; | |
| 678 | const currentApiType = | |
| 679 | current == null ? undefined : boundedOpaqueValue(current.apiType, 64); | |
| 680 | const currentBaseUrl = | |
| 681 | current == null ? undefined : normalizeAcpRegistryWebUrl(current.baseUrl); | |
| 682 | return [ | |
| 683 | { | |
| 684 | providerId, | |
| 685 | supported, | |
| 686 | required: provider.required, | |
| 687 | current: | |
| 688 | currentApiType === undefined || currentBaseUrl === undefined | |
| 689 | ? null | |
| 690 | : { apiType: currentApiType, baseUrl: currentBaseUrl }, | |
| 691 | }, | |
| 692 | ]; | |
| 693 | }), | |
| 694 | }); | |
| 695 | }, | |
| 696 | (effect) => | |
| 697 | effect.pipe( | |
| 698 | Effect.scoped, | |
| 699 | Effect.timeoutOrElse({ | |
| 700 | duration: MANAGEMENT_TIMEOUT, | |
| 701 | orElse: () => | |
| 702 | Effect.fail( | |
| 703 | new AcpRegistryOperationError({ | |
| 704 | reason: "providers_list_failed", | |
| 705 | message: "The ACP provider list request timed out.", | |
| 706 | }), | |
| 707 | ), | |
| 708 | }), | |
| 709 | Effect.mapError((error) => managementFailure("providers_list", error)), | |
| 710 | ), | |
| 711 | ); | |
| 712 | ||
| 713 | export const setAcpRegistryProvider = Effect.fn("AcpRegistryProbe.setProvider")( | |
| 714 | function* (input: AcpRegistryManagementInput & EffectAcpSchema.SetProviderRequest) { | |
| 715 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 716 | yield* runtime.setProvider({ | |
| 717 | providerId: input.providerId, | |
| 718 | apiType: input.apiType, | |
| 719 | baseUrl: input.baseUrl, | |
| 720 | ...(input.headers === undefined ? {} : { headers: input.headers }), | |
| 721 | }); | |
| 722 | }, | |
| 723 | (effect) => | |
| 724 | effect.pipe( | |
| 725 | Effect.scoped, | |
| 726 | Effect.timeoutOrElse({ | |
| 727 | duration: MANAGEMENT_TIMEOUT, | |
| 728 | orElse: () => | |
| 729 | Effect.fail( | |
| 730 | new AcpRegistryOperationError({ | |
| 731 | reason: "provider_configuration_failed", | |
| 732 | message: "The ACP provider configuration request timed out.", | |
| 733 | }), | |
| 734 | ), | |
| 735 | }), | |
| 736 | Effect.mapError((error) => managementFailure("providers_set", error)), | |
| 737 | ), | |
| 738 | ); | |
| 739 | ||
| 740 | export const disableAcpRegistryProvider = Effect.fn("AcpRegistryProbe.disableProvider")( | |
| 741 | function* (input: AcpRegistryManagementInput & { readonly providerId: string }) { | |
| 742 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 743 | yield* runtime.disableProvider(input.providerId); | |
| 744 | }, | |
| 745 | (effect) => | |
| 746 | effect.pipe( | |
| 747 | Effect.scoped, | |
| 748 | Effect.timeoutOrElse({ | |
| 749 | duration: MANAGEMENT_TIMEOUT, | |
| 750 | orElse: () => | |
| 751 | Effect.fail( | |
| 752 | new AcpRegistryOperationError({ | |
| 753 | reason: "provider_configuration_failed", | |
| 754 | message: "The ACP provider disable request timed out.", | |
| 755 | }), | |
| 756 | ), | |
| 757 | }), | |
| 758 | Effect.mapError((error) => managementFailure("providers_disable", error)), | |
| 759 | ), | |
| 760 | ); | |
| 761 | ||
| 762 | export const logoutAcpRegistry = Effect.fn("AcpRegistryProbe.logout")( | |
| 763 | function* (input: AcpRegistryManagementInput) { | |
| 764 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 765 | yield* runtime.logout; | |
| 766 | }, | |
| 767 | (effect) => | |
| 768 | effect.pipe( | |
| 769 | Effect.scoped, | |
| 770 | Effect.timeoutOrElse({ | |
| 771 | duration: MANAGEMENT_TIMEOUT, | |
| 772 | orElse: () => | |
| 773 | Effect.fail( | |
| 774 | new AcpRegistryOperationError({ | |
| 775 | reason: "probe_failed", | |
| 776 | message: "The ACP logout request timed out.", | |
| 777 | }), | |
| 778 | ), | |
| 779 | }), | |
| 780 | Effect.mapError((error) => managementFailure("logout", error)), | |
| 781 | ), | |
| 782 | ); |
apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts deleted-366| ... | ... | @@ -1,366 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | ||
| 9 | import type { | |
| 10 | AcpRegistryAvailableCommands, | |
| 11 | AcpRegistryLiveConfiguration, | |
| 12 | } from "./AcpRegistryProbe.ts"; | |
| 13 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 14 | ||
| 15 | describe("AcpRegistryRuntimeCoordinator", () => { | |
| 16 | it.effect("suppresses a background probe while foreground startup is active", () => | |
| 17 | Effect.gen(function* () { | |
| 18 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 19 | const foregroundStarted = yield* Deferred.make<void>(); | |
| 20 | const releaseForeground = yield* Deferred.make<void>(); | |
| 21 | const foreground = yield* coordinator | |
| 22 | .withForegroundStartup( | |
| 23 | "kilo", | |
| 24 | Deferred.succeed(foregroundStarted, undefined).pipe( | |
| 25 | Effect.andThen(Deferred.await(releaseForeground)), | |
| 26 | ), | |
| 27 | ) | |
| 28 | .pipe(Effect.forkChild); | |
| 29 | yield* Deferred.await(foregroundStarted); | |
| 30 | ||
| 31 | const probed = yield* coordinator.runBackgroundProbe("kilo", Effect.succeed("unexpected")); | |
| 32 | expect(Option.isNone(probed)).toBe(true); | |
| 33 | ||
| 34 | yield* Deferred.succeed(releaseForeground, undefined); | |
| 35 | yield* Fiber.join(foreground); | |
| 36 | }).pipe( | |
| 37 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 38 | Effect.scoped, | |
| 39 | ), | |
| 40 | ); | |
| 41 | ||
| 42 | it.effect("interrupts an active background probe when foreground startup begins", () => | |
| 43 | Effect.gen(function* () { | |
| 44 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 45 | const probeStarted = yield* Deferred.make<void>(); | |
| 46 | const probeFinalized = yield* Deferred.make<void>(); | |
| 47 | const releaseForeground = yield* Deferred.make<void>(); | |
| 48 | const probe = yield* coordinator | |
| 49 | .runBackgroundProbe( | |
| 50 | "kilo", | |
| 51 | Deferred.succeed(probeStarted, undefined).pipe( | |
| 52 | Effect.andThen(Effect.never), | |
| 53 | Effect.ensuring(Deferred.succeed(probeFinalized, undefined).pipe(Effect.ignore)), | |
| 54 | ), | |
| 55 | ) | |
| 56 | .pipe(Effect.forkChild); | |
| 57 | yield* Deferred.await(probeStarted); | |
| 58 | ||
| 59 | const foreground = yield* coordinator | |
| 60 | .withForegroundStartup("kilo", Deferred.await(releaseForeground)) | |
| 61 | .pipe(Effect.forkChild); | |
| 62 | expect(Option.isNone(yield* Fiber.join(probe))).toBe(true); | |
| 63 | yield* Deferred.await(probeFinalized); | |
| 64 | ||
| 65 | yield* Deferred.succeed(releaseForeground, undefined); | |
| 66 | yield* Fiber.join(foreground); | |
| 67 | }).pipe( | |
| 68 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 69 | Effect.scoped, | |
| 70 | ), | |
| 71 | ); | |
| 72 | ||
| 73 | it.effect("serializes native session mutations across callers", () => | |
| 74 | Effect.gen(function* () { | |
| 75 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 76 | const firstEntered = yield* Deferred.make<void>(); | |
| 77 | const releaseFirst = yield* Deferred.make<void>(); | |
| 78 | const secondEntered = yield* Deferred.make<void>(); | |
| 79 | const first = yield* coordinator | |
| 80 | .withSessionMutation( | |
| 81 | Deferred.succeed(firstEntered, undefined).pipe( | |
| 82 | Effect.andThen(Deferred.await(releaseFirst)), | |
| 83 | ), | |
| 84 | ) | |
| 85 | .pipe(Effect.forkChild); | |
| 86 | yield* Deferred.await(firstEntered); | |
| 87 | ||
| 88 | const second = yield* coordinator | |
| 89 | .withSessionMutation(Deferred.succeed(secondEntered, undefined)) | |
| 90 | .pipe(Effect.forkChild); | |
| 91 | expect(yield* Deferred.poll(secondEntered)).toEqual(Option.none()); | |
| 92 | ||
| 93 | yield* Deferred.succeed(releaseFirst, undefined); | |
| 94 | yield* Fiber.join(first); | |
| 95 | yield* Fiber.join(second); | |
| 96 | expect(Option.isSome(yield* Deferred.poll(secondEntered))).toBe(true); | |
| 97 | }).pipe( | |
| 98 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 99 | Effect.scoped, | |
| 100 | ), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("replays and replaces late command advertisements per provider instance", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 106 | const codex = ProviderInstanceId.make("acpRegistry_codex"); | |
| 107 | const kilo = ProviderInstanceId.make("acpRegistry_kilo"); | |
| 108 | const firstSeen = yield* Deferred.make<void>(); | |
| 109 | const replacementSeen = yield* Deferred.make<void>(); | |
| 110 | const received: Array<AcpRegistryAvailableCommands> = []; | |
| 111 | ||
| 112 | yield* coordinator.publishAvailableCommands(codex, { | |
| 113 | slashCommands: [{ name: "status" }], | |
| 114 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 115 | }); | |
| 116 | const consumer = yield* coordinator | |
| 117 | .watchAvailableCommands(codex, (commands) => | |
| 118 | Effect.gen(function* () { | |
| 119 | received.push(commands); | |
| 120 | yield* received.length === 1 | |
| 121 | ? Deferred.succeed(firstSeen, undefined) | |
| 122 | : Deferred.succeed(replacementSeen, undefined); | |
| 123 | }), | |
| 124 | ) | |
| 125 | .pipe(Effect.forkChild); | |
| 126 | yield* Deferred.await(firstSeen); | |
| 127 | ||
| 128 | yield* coordinator.publishAvailableCommands(kilo, { | |
| 129 | slashCommands: [{ name: "review" }], | |
| 130 | skills: [], | |
| 131 | }); | |
| 132 | yield* coordinator.publishAvailableCommands(codex, { slashCommands: [], skills: [] }); | |
| 133 | yield* Deferred.await(replacementSeen); | |
| 134 | yield* Fiber.interrupt(consumer); | |
| 135 | ||
| 136 | expect(received).toEqual([ | |
| 137 | { | |
| 138 | slashCommands: [{ name: "status" }], | |
| 139 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 140 | }, | |
| 141 | { slashCommands: [], skills: [] }, | |
| 142 | ]); | |
| 143 | expect(yield* coordinator.getAvailableCommands(kilo)).toEqual( | |
| 144 | Option.some({ slashCommands: [{ name: "review" }], skills: [] }), | |
| 145 | ); | |
| 146 | yield* coordinator.clearAvailableCommands(codex); | |
| 147 | expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true); | |
| 148 | }).pipe( | |
| 149 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 150 | Effect.scoped, | |
| 151 | ), | |
| 152 | ); | |
| 153 | ||
| 154 | it.effect("replays and replaces live configuration per provider instance", () => | |
| 155 | Effect.gen(function* () { | |
| 156 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 157 | const instanceId = ProviderInstanceId.make("acpRegistry_kilo"); | |
| 158 | const firstSeen = yield* Deferred.make<void>(); | |
| 159 | const replacementSeen = yield* Deferred.make<void>(); | |
| 160 | const received: Array<AcpRegistryLiveConfiguration> = []; | |
| 161 | const first = { | |
| 162 | models: [{ id: "sonnet", name: "Sonnet", description: null }], | |
| 163 | currentModelId: "sonnet", | |
| 164 | configOptions: [], | |
| 165 | } satisfies AcpRegistryLiveConfiguration; | |
| 166 | const replacement = { | |
| 167 | models: [{ id: "opus", name: "Opus", description: null }], | |
| 168 | currentModelId: "opus", | |
| 169 | configOptions: [], | |
| 170 | } satisfies AcpRegistryLiveConfiguration; | |
| 171 | ||
| 172 | yield* coordinator.publishLiveConfiguration(instanceId, first); | |
| 173 | const consumer = yield* coordinator | |
| 174 | .watchLiveConfiguration(instanceId, (configuration) => | |
| 175 | Effect.gen(function* () { | |
| 176 | received.push(configuration); | |
| 177 | yield* received.length === 1 | |
| 178 | ? Deferred.succeed(firstSeen, undefined) | |
| 179 | : Deferred.succeed(replacementSeen, undefined); | |
| 180 | }), | |
| 181 | ) | |
| 182 | .pipe(Effect.forkChild); | |
| 183 | yield* Deferred.await(firstSeen); | |
| 184 | yield* coordinator.publishLiveConfiguration(instanceId, replacement); | |
| 185 | yield* Deferred.await(replacementSeen); | |
| 186 | yield* Fiber.interrupt(consumer); | |
| 187 | ||
| 188 | expect(received).toEqual([first, replacement]); | |
| 189 | expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement)); | |
| 190 | yield* coordinator.clearLiveConfiguration(instanceId); | |
| 191 | expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true); | |
| 192 | }).pipe( | |
| 193 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 194 | Effect.scoped, | |
| 195 | ), | |
| 196 | ); | |
| 197 | ||
| 198 | it.effect("requires matching user consent before accepting URL authentication", () => | |
| 199 | Effect.gen(function* () { | |
| 200 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 201 | const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); | |
| 202 | const action = { | |
| 203 | elicitationId: "login-1", | |
| 204 | url: "https://accounts.example.com/login", | |
| 205 | message: "Continue in your browser", | |
| 206 | }; | |
| 207 | const actionSeen = yield* Deferred.make<void>(); | |
| 208 | const consumer = yield* coordinator | |
| 209 | .watchUrlAuthAction(instanceId, (current) => | |
| 210 | current === null | |
| 211 | ? Effect.void | |
| 212 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 213 | ) | |
| 214 | .pipe(Effect.forkChild); | |
| 215 | const request = yield* coordinator | |
| 216 | .requestUrlAuthentication(instanceId, action) | |
| 217 | .pipe(Effect.forkChild); | |
| 218 | yield* Deferred.await(actionSeen); | |
| 219 | ||
| 220 | expect(yield* coordinator.getUrlAuthAction(instanceId)).toEqual( | |
| 221 | Option.some({ | |
| 222 | ...action, | |
| 223 | createdAt: "1970-01-01T00:00:00.000Z", | |
| 224 | expiresAt: "1970-01-01T00:10:00.000Z", | |
| 225 | }), | |
| 226 | ); | |
| 227 | ||
| 228 | expect( | |
| 229 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "stale" }), | |
| 230 | ).toBe(false); | |
| 231 | expect( | |
| 232 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-1" }), | |
| 233 | ).toBe(true); | |
| 234 | expect(yield* Fiber.join(request)).toBe(true); | |
| 235 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 236 | yield* Fiber.interrupt(consumer); | |
| 237 | }).pipe( | |
| 238 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 239 | Effect.scoped, | |
| 240 | ), | |
| 241 | ); | |
| 242 | ||
| 243 | it.effect("expires stale URL authentication actions without accepting them", () => | |
| 244 | Effect.gen(function* () { | |
| 245 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 246 | const instanceId = ProviderInstanceId.make("acpRegistry_expired"); | |
| 247 | const actionSeen = yield* Deferred.make<void>(); | |
| 248 | const consumer = yield* coordinator | |
| 249 | .watchUrlAuthAction(instanceId, (current) => | |
| 250 | current === null | |
| 251 | ? Effect.void | |
| 252 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 253 | ) | |
| 254 | .pipe(Effect.forkChild); | |
| 255 | const request = yield* coordinator | |
| 256 | .requestUrlAuthentication(instanceId, { | |
| 257 | elicitationId: "expired-login", | |
| 258 | url: "https://accounts.example.com/login", | |
| 259 | message: "Continue in your browser", | |
| 260 | }) | |
| 261 | .pipe(Effect.forkChild); | |
| 262 | yield* Deferred.await(actionSeen); | |
| 263 | yield* TestClock.adjust("10 minutes"); | |
| 264 | ||
| 265 | expect(yield* Fiber.join(request)).toBe(false); | |
| 266 | expect( | |
| 267 | yield* coordinator.acceptUrlAuthentication({ | |
| 268 | instanceId, | |
| 269 | elicitationId: "expired-login", | |
| 270 | }), | |
| 271 | ).toBe(false); | |
| 272 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 273 | yield* Fiber.interrupt(consumer); | |
| 274 | }).pipe( | |
| 275 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 276 | Effect.scoped, | |
| 277 | ), | |
| 278 | ); | |
| 279 | ||
| 280 | it.effect("publishes a replacement URL action before retiring the previous request", () => | |
| 281 | Effect.gen(function* () { | |
| 282 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 283 | const instanceId = ProviderInstanceId.make("acpRegistry_replacement"); | |
| 284 | const received: Array<string | null> = []; | |
| 285 | const firstSeen = yield* Deferred.make<void>(); | |
| 286 | const replacementSeen = yield* Deferred.make<void>(); | |
| 287 | const consumer = yield* coordinator | |
| 288 | .watchUrlAuthAction(instanceId, (current) => | |
| 289 | Effect.sync(() => { | |
| 290 | received.push(current?.elicitationId ?? null); | |
| 291 | }).pipe( | |
| 292 | Effect.andThen( | |
| 293 | current?.elicitationId === "login-1" | |
| 294 | ? Deferred.succeed(firstSeen, undefined).pipe(Effect.asVoid) | |
| 295 | : current?.elicitationId === "login-2" | |
| 296 | ? Deferred.succeed(replacementSeen, undefined).pipe(Effect.asVoid) | |
| 297 | : Effect.void, | |
| 298 | ), | |
| 299 | ), | |
| 300 | ) | |
| 301 | .pipe(Effect.forkChild); | |
| 302 | const first = yield* coordinator | |
| 303 | .requestUrlAuthentication(instanceId, { | |
| 304 | elicitationId: "login-1", | |
| 305 | url: "https://accounts.example.com/first", | |
| 306 | message: "Continue with the first login", | |
| 307 | }) | |
| 308 | .pipe(Effect.forkChild); | |
| 309 | yield* Deferred.await(firstSeen); | |
| 310 | const replacement = yield* coordinator | |
| 311 | .requestUrlAuthentication(instanceId, { | |
| 312 | elicitationId: "login-2", | |
| 313 | url: "https://accounts.example.com/replacement", | |
| 314 | message: "Continue with the replacement login", | |
| 315 | }) | |
| 316 | .pipe(Effect.forkChild); | |
| 317 | yield* Deferred.await(replacementSeen); | |
| 318 | ||
| 319 | expect(yield* Fiber.join(first)).toBe(false); | |
| 320 | expect( | |
| 321 | (yield* coordinator.getUrlAuthAction(instanceId)).pipe(Option.getOrThrow), | |
| 322 | ).toMatchObject({ elicitationId: "login-2" }); | |
| 323 | expect(received.slice(0, 2)).toEqual(["login-1", "login-2"]); | |
| 324 | expect( | |
| 325 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-2" }), | |
| 326 | ).toBe(true); | |
| 327 | expect(yield* Fiber.join(replacement)).toBe(true); | |
| 328 | yield* Fiber.interrupt(consumer); | |
| 329 | }).pipe( | |
| 330 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 331 | Effect.scoped, | |
| 332 | ), | |
| 333 | ); | |
| 334 | ||
| 335 | it.effect("clears a published URL action when its request is interrupted", () => | |
| 336 | Effect.gen(function* () { | |
| 337 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 338 | const instanceId = ProviderInstanceId.make("acpRegistry_interrupted"); | |
| 339 | const actionSeen = yield* Deferred.make<void>(); | |
| 340 | const cleared = yield* Deferred.make<void>(); | |
| 341 | const consumer = yield* coordinator | |
| 342 | .watchUrlAuthAction(instanceId, (current) => | |
| 343 | current === null | |
| 344 | ? Deferred.succeed(cleared, undefined).pipe(Effect.asVoid) | |
| 345 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 346 | ) | |
| 347 | .pipe(Effect.forkChild); | |
| 348 | const request = yield* coordinator | |
| 349 | .requestUrlAuthentication(instanceId, { | |
| 350 | elicitationId: "interrupted-login", | |
| 351 | url: "https://accounts.example.com/interrupted", | |
| 352 | message: "Continue with login", | |
| 353 | }) | |
| 354 | .pipe(Effect.forkChild); | |
| 355 | yield* Deferred.await(actionSeen); | |
| 356 | ||
| 357 | yield* Fiber.interrupt(request); | |
| 358 | yield* Deferred.await(cleared); | |
| 359 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 360 | yield* Fiber.interrupt(consumer); | |
| 361 | }).pipe( | |
| 362 | Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), | |
| 363 | Effect.scoped, | |
| 364 | ), | |
| 365 | ); | |
| 366 | }); |
apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts deleted-369| ... | ... | @@ -1,369 +0,0 @@ |
| 1 | import { | |
| 2 | type AcpRegistryAcceptUrlAuthInput, | |
| 3 | type AcpRegistryUrlAuthAction, | |
| 4 | type ProviderInstanceId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as PubSub from "effect/PubSub"; | |
| 14 | import * as Ref from "effect/Ref"; | |
| 15 | import * as Semaphore from "effect/Semaphore"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | ||
| 18 | import type { | |
| 19 | AcpRegistryAvailableCommands, | |
| 20 | AcpRegistryLiveConfiguration, | |
| 21 | } from "./AcpRegistryProbe.ts"; | |
| 22 | ||
| 23 | interface AvailableCommandsUpdate { | |
| 24 | readonly instanceId: ProviderInstanceId; | |
| 25 | readonly commands: AcpRegistryAvailableCommands; | |
| 26 | } | |
| 27 | ||
| 28 | interface LiveConfigurationUpdate { | |
| 29 | readonly instanceId: ProviderInstanceId; | |
| 30 | readonly configuration: AcpRegistryLiveConfiguration; | |
| 31 | } | |
| 32 | ||
| 33 | interface UrlAuthActionUpdate { | |
| 34 | readonly instanceId: ProviderInstanceId; | |
| 35 | readonly action: AcpRegistryUrlAuthAction | null; | |
| 36 | } | |
| 37 | ||
| 38 | /** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */ | |
| 39 | const URL_AUTH_ACTION_TTL = Duration.minutes(10); | |
| 40 | ||
| 41 | interface PendingUrlAuthAction { | |
| 42 | readonly action: AcpRegistryUrlAuthAction; | |
| 43 | readonly consent: Deferred.Deferred<boolean>; | |
| 44 | readonly expiresAt: DateTime.Utc; | |
| 45 | } | |
| 46 | ||
| 47 | /** Gives a user-started ACP process priority over disposable discovery for the same agent. */ | |
| 48 | export class AcpRegistryRuntimeCoordinator extends Context.Service< | |
| 49 | AcpRegistryRuntimeCoordinator, | |
| 50 | { | |
| 51 | readonly withForegroundStartup: <A, E, R>( | |
| 52 | agentId: string, | |
| 53 | effect: Effect.Effect<A, E, R>, | |
| 54 | ) => Effect.Effect<A, E, R>; | |
| 55 | readonly runBackgroundProbe: <A, E, R>( | |
| 56 | agentId: string, | |
| 57 | effect: Effect.Effect<A, E, R>, | |
| 58 | ) => Effect.Effect<Option.Option<A>, E, R>; | |
| 59 | /** Serializes native-session imports and deletes across all client connections. */ | |
| 60 | readonly withSessionMutation: <A, E, R>( | |
| 61 | effect: Effect.Effect<A, E, R>, | |
| 62 | ) => Effect.Effect<A, E, R>; | |
| 63 | readonly clearAvailableCommands: (instanceId: ProviderInstanceId) => Effect.Effect<void>; | |
| 64 | readonly publishAvailableCommands: ( | |
| 65 | instanceId: ProviderInstanceId, | |
| 66 | commands: AcpRegistryAvailableCommands, | |
| 67 | ) => Effect.Effect<void>; | |
| 68 | readonly getAvailableCommands: ( | |
| 69 | instanceId: ProviderInstanceId, | |
| 70 | ) => Effect.Effect<Option.Option<AcpRegistryAvailableCommands>>; | |
| 71 | readonly watchAvailableCommands: ( | |
| 72 | instanceId: ProviderInstanceId, | |
| 73 | onUpdate: (commands: AcpRegistryAvailableCommands) => Effect.Effect<void>, | |
| 74 | ) => Effect.Effect<void>; | |
| 75 | readonly clearLiveConfiguration: (instanceId: ProviderInstanceId) => Effect.Effect<void>; | |
| 76 | readonly publishLiveConfiguration: ( | |
| 77 | instanceId: ProviderInstanceId, | |
| 78 | configuration: AcpRegistryLiveConfiguration, | |
| 79 | ) => Effect.Effect<void>; | |
| 80 | readonly getLiveConfiguration: ( | |
| 81 | instanceId: ProviderInstanceId, | |
| 82 | ) => Effect.Effect<Option.Option<AcpRegistryLiveConfiguration>>; | |
| 83 | readonly watchLiveConfiguration: ( | |
| 84 | instanceId: ProviderInstanceId, | |
| 85 | onUpdate: (configuration: AcpRegistryLiveConfiguration) => Effect.Effect<void>, | |
| 86 | ) => Effect.Effect<void>; | |
| 87 | readonly requestUrlAuthentication: ( | |
| 88 | instanceId: ProviderInstanceId, | |
| 89 | action: AcpRegistryUrlAuthAction, | |
| 90 | ) => Effect.Effect<boolean>; | |
| 91 | readonly acceptUrlAuthentication: ( | |
| 92 | input: AcpRegistryAcceptUrlAuthInput, | |
| 93 | ) => Effect.Effect<boolean>; | |
| 94 | readonly getUrlAuthAction: ( | |
| 95 | instanceId: ProviderInstanceId, | |
| 96 | ) => Effect.Effect<Option.Option<AcpRegistryUrlAuthAction>>; | |
| 97 | readonly watchUrlAuthAction: ( | |
| 98 | instanceId: ProviderInstanceId, | |
| 99 | onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect<void>, | |
| 100 | ) => Effect.Effect<void>; | |
| 101 | } | |
| 102 | >()("t3/provider/acp/AcpRegistryRuntimeCoordinator") { | |
| 103 | static get layer() { | |
| 104 | return layer; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | const make = Effect.gen(function* () { | |
| 109 | const foregroundStarts = yield* PubSub.unbounded<string>(); | |
| 110 | const activeForegroundCounts = yield* Ref.make(new Map<string, number>()); | |
| 111 | const availableCommandsUpdates = yield* PubSub.unbounded<AvailableCommandsUpdate>(); | |
| 112 | const availableCommandsByInstance = yield* Ref.make( | |
| 113 | new Map<ProviderInstanceId, AcpRegistryAvailableCommands>(), | |
| 114 | ); | |
| 115 | const liveConfigurationUpdates = yield* PubSub.unbounded<LiveConfigurationUpdate>(); | |
| 116 | const liveConfigurationByInstance = yield* Ref.make( | |
| 117 | new Map<ProviderInstanceId, AcpRegistryLiveConfiguration>(), | |
| 118 | ); | |
| 119 | const urlAuthActionUpdates = yield* PubSub.unbounded<UrlAuthActionUpdate>(); | |
| 120 | const pendingUrlAuthActions = yield* Ref.make( | |
| 121 | new Map<ProviderInstanceId, PendingUrlAuthAction>(), | |
| 122 | ); | |
| 123 | const urlAuthActionPermit = yield* Semaphore.make(1); | |
| 124 | const sessionMutationPermit = yield* Semaphore.make(1); | |
| 125 | ||
| 126 | const withForegroundStartup: AcpRegistryRuntimeCoordinator["Service"]["withForegroundStartup"] = ( | |
| 127 | agentId, | |
| 128 | effect, | |
| 129 | ) => | |
| 130 | Effect.acquireUseRelease( | |
| 131 | Ref.update(activeForegroundCounts, (current) => { | |
| 132 | const next = new Map(current); | |
| 133 | next.set(agentId, (next.get(agentId) ?? 0) + 1); | |
| 134 | return next; | |
| 135 | }).pipe(Effect.andThen(PubSub.publish(foregroundStarts, agentId))), | |
| 136 | () => effect, | |
| 137 | () => | |
| 138 | Ref.update(activeForegroundCounts, (current) => { | |
| 139 | const next = new Map(current); | |
| 140 | const remaining = (next.get(agentId) ?? 1) - 1; | |
| 141 | if (remaining <= 0) next.delete(agentId); | |
| 142 | else next.set(agentId, remaining); | |
| 143 | return next; | |
| 144 | }), | |
| 145 | ); | |
| 146 | ||
| 147 | const runBackgroundProbe: AcpRegistryRuntimeCoordinator["Service"]["runBackgroundProbe"] = ( | |
| 148 | agentId, | |
| 149 | effect, | |
| 150 | ) => | |
| 151 | Effect.scoped( | |
| 152 | Effect.gen(function* () { | |
| 153 | // Subscribe before checking state so a foreground start cannot land | |
| 154 | // in the gap between the check and the interruptible probe. | |
| 155 | const subscription = yield* PubSub.subscribe(foregroundStarts); | |
| 156 | if ((yield* Ref.get(activeForegroundCounts)).has(agentId)) { | |
| 157 | return Option.none(); | |
| 158 | } | |
| 159 | const foregroundStarted = Stream.fromSubscription(subscription).pipe( | |
| 160 | Stream.filter((candidate) => candidate === agentId), | |
| 161 | Stream.runHead, | |
| 162 | Effect.as(Option.none()), | |
| 163 | ); | |
| 164 | return yield* Effect.raceFirst(effect.pipe(Effect.map(Option.some)), foregroundStarted); | |
| 165 | }), | |
| 166 | ); | |
| 167 | ||
| 168 | const withSessionMutation: AcpRegistryRuntimeCoordinator["Service"]["withSessionMutation"] = ( | |
| 169 | effect, | |
| 170 | ) => sessionMutationPermit.withPermit(effect); | |
| 171 | ||
| 172 | const clearAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["clearAvailableCommands"] = | |
| 173 | (instanceId) => | |
| 174 | Ref.update(availableCommandsByInstance, (current) => { | |
| 175 | const next = new Map(current); | |
| 176 | next.delete(instanceId); | |
| 177 | return next; | |
| 178 | }); | |
| 179 | ||
| 180 | const publishAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["publishAvailableCommands"] = | |
| 181 | (instanceId, commands) => | |
| 182 | Ref.update(availableCommandsByInstance, (current) => { | |
| 183 | const next = new Map(current); | |
| 184 | next.set(instanceId, commands); | |
| 185 | return next; | |
| 186 | }).pipe( | |
| 187 | Effect.andThen( | |
| 188 | PubSub.publish(availableCommandsUpdates, { | |
| 189 | instanceId, | |
| 190 | commands, | |
| 191 | }), | |
| 192 | ), | |
| 193 | Effect.asVoid, | |
| 194 | ); | |
| 195 | ||
| 196 | const getAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["getAvailableCommands"] = | |
| 197 | Effect.fn("AcpRegistryRuntimeCoordinator.getAvailableCommands")(function* (instanceId) { | |
| 198 | return Option.fromNullishOr((yield* Ref.get(availableCommandsByInstance)).get(instanceId)); | |
| 199 | }); | |
| 200 | ||
| 201 | const watchAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["watchAvailableCommands"] = | |
| 202 | (instanceId, onUpdate) => | |
| 203 | Effect.scoped( | |
| 204 | Effect.gen(function* () { | |
| 205 | // Subscribe before reading the current value so a publication in | |
| 206 | // between is either observed from the snapshot, the queue, or both. | |
| 207 | const subscription = yield* PubSub.subscribe(availableCommandsUpdates); | |
| 208 | const current = yield* getAvailableCommands(instanceId); | |
| 209 | if (Option.isSome(current)) { | |
| 210 | yield* onUpdate(current.value); | |
| 211 | } | |
| 212 | yield* Stream.fromSubscription(subscription).pipe( | |
| 213 | Stream.filter((update) => update.instanceId === instanceId), | |
| 214 | Stream.runForEach((update) => onUpdate(update.commands)), | |
| 215 | ); | |
| 216 | }), | |
| 217 | ); | |
| 218 | ||
| 219 | const clearLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["clearLiveConfiguration"] = | |
| 220 | (instanceId) => | |
| 221 | Ref.update(liveConfigurationByInstance, (current) => { | |
| 222 | const next = new Map(current); | |
| 223 | next.delete(instanceId); | |
| 224 | return next; | |
| 225 | }); | |
| 226 | ||
| 227 | const publishLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["publishLiveConfiguration"] = | |
| 228 | (instanceId, configuration) => | |
| 229 | Ref.update(liveConfigurationByInstance, (current) => | |
| 230 | new Map(current).set(instanceId, configuration), | |
| 231 | ).pipe( | |
| 232 | Effect.andThen( | |
| 233 | PubSub.publish(liveConfigurationUpdates, { | |
| 234 | instanceId, | |
| 235 | configuration, | |
| 236 | }), | |
| 237 | ), | |
| 238 | Effect.asVoid, | |
| 239 | ); | |
| 240 | ||
| 241 | const getLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["getLiveConfiguration"] = | |
| 242 | Effect.fn("AcpRegistryRuntimeCoordinator.getLiveConfiguration")(function* (instanceId) { | |
| 243 | return Option.fromNullishOr((yield* Ref.get(liveConfigurationByInstance)).get(instanceId)); | |
| 244 | }); | |
| 245 | ||
| 246 | const watchLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["watchLiveConfiguration"] = | |
| 247 | (instanceId, onUpdate) => | |
| 248 | Effect.scoped( | |
| 249 | Effect.gen(function* () { | |
| 250 | const subscription = yield* PubSub.subscribe(liveConfigurationUpdates); | |
| 251 | const current = yield* getLiveConfiguration(instanceId); | |
| 252 | if (Option.isSome(current)) { | |
| 253 | yield* onUpdate(current.value); | |
| 254 | } | |
| 255 | yield* Stream.fromSubscription(subscription).pipe( | |
| 256 | Stream.filter((update) => update.instanceId === instanceId), | |
| 257 | Stream.runForEach((update) => onUpdate(update.configuration)), | |
| 258 | ); | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | const publishUrlAuthAction = ( | |
| 263 | instanceId: ProviderInstanceId, | |
| 264 | action: AcpRegistryUrlAuthAction | null, | |
| 265 | ) => PubSub.publish(urlAuthActionUpdates, { instanceId, action }).pipe(Effect.asVoid); | |
| 266 | ||
| 267 | const requestUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["requestUrlAuthentication"] = | |
| 268 | Effect.fn("AcpRegistryRuntimeCoordinator.requestUrlAuthentication")( | |
| 269 | function* (instanceId, action) { | |
| 270 | const consent = yield* Deferred.make<boolean>(); | |
| 271 | const createdAt = yield* DateTime.now; | |
| 272 | const expiresAt = DateTime.addDuration(createdAt, URL_AUTH_ACTION_TTL); | |
| 273 | const publishedAction = { | |
| 274 | ...action, | |
| 275 | createdAt: DateTime.formatIso(createdAt), | |
| 276 | expiresAt: DateTime.formatIso(expiresAt), | |
| 277 | } satisfies AcpRegistryUrlAuthAction; | |
| 278 | const cleanup = Ref.modify(pendingUrlAuthActions, (current) => { | |
| 279 | if (current.get(instanceId)?.consent !== consent) { | |
| 280 | return [false, current] as const; | |
| 281 | } | |
| 282 | const next = new Map(current); | |
| 283 | next.delete(instanceId); | |
| 284 | return [true, next] as const; | |
| 285 | }).pipe( | |
| 286 | Effect.flatMap((removed) => | |
| 287 | removed ? publishUrlAuthAction(instanceId, null) : Effect.void, | |
| 288 | ), | |
| 289 | ); | |
| 290 | return yield* Effect.gen(function* () { | |
| 291 | yield* urlAuthActionPermit.withPermits(1)( | |
| 292 | Effect.gen(function* () { | |
| 293 | const previous = yield* Ref.modify(pendingUrlAuthActions, (current) => { | |
| 294 | const next = new Map(current); | |
| 295 | const existing = next.get(instanceId); | |
| 296 | next.set(instanceId, { action: publishedAction, consent, expiresAt }); | |
| 297 | return [existing, next] as const; | |
| 298 | }); | |
| 299 | if (previous !== undefined) { | |
| 300 | yield* Deferred.succeed(previous.consent, false).pipe(Effect.ignore); | |
| 301 | } | |
| 302 | yield* publishUrlAuthAction(instanceId, publishedAction); | |
| 303 | }), | |
| 304 | ); | |
| 305 | return yield* Deferred.await(consent).pipe( | |
| 306 | Effect.timeoutOrElse({ | |
| 307 | duration: URL_AUTH_ACTION_TTL, | |
| 308 | orElse: () => Effect.succeed(false), | |
| 309 | }), | |
| 310 | ); | |
| 311 | }).pipe(Effect.ensuring(cleanup)); | |
| 312 | }, | |
| 313 | ); | |
| 314 | ||
| 315 | const acceptUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["acceptUrlAuthentication"] = | |
| 316 | Effect.fn("AcpRegistryRuntimeCoordinator.acceptUrlAuthentication")(function* (input) { | |
| 317 | const pending = (yield* Ref.get(pendingUrlAuthActions)).get(input.instanceId); | |
| 318 | if (pending?.action.elicitationId !== input.elicitationId) return false; | |
| 319 | if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, pending.expiresAt)) { | |
| 320 | yield* Deferred.succeed(pending.consent, false).pipe(Effect.ignore); | |
| 321 | return false; | |
| 322 | } | |
| 323 | return yield* Deferred.succeed(pending.consent, true); | |
| 324 | }); | |
| 325 | ||
| 326 | const getUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["getUrlAuthAction"] = Effect.fn( | |
| 327 | "AcpRegistryRuntimeCoordinator.getUrlAuthAction", | |
| 328 | )(function* (instanceId) { | |
| 329 | return Option.fromNullishOr((yield* Ref.get(pendingUrlAuthActions)).get(instanceId)?.action); | |
| 330 | }); | |
| 331 | ||
| 332 | const watchUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["watchUrlAuthAction"] = ( | |
| 333 | instanceId, | |
| 334 | onUpdate, | |
| 335 | ) => | |
| 336 | Effect.scoped( | |
| 337 | Effect.gen(function* () { | |
| 338 | const subscription = yield* PubSub.subscribe(urlAuthActionUpdates); | |
| 339 | const current = yield* getUrlAuthAction(instanceId); | |
| 340 | if (Option.isSome(current)) { | |
| 341 | yield* onUpdate(current.value); | |
| 342 | } | |
| 343 | yield* Stream.fromSubscription(subscription).pipe( | |
| 344 | Stream.filter((update) => update.instanceId === instanceId), | |
| 345 | Stream.runForEach((update) => onUpdate(update.action)), | |
| 346 | ); | |
| 347 | }), | |
| 348 | ); | |
| 349 | ||
| 350 | return AcpRegistryRuntimeCoordinator.of({ | |
| 351 | withForegroundStartup, | |
| 352 | runBackgroundProbe, | |
| 353 | withSessionMutation, | |
| 354 | clearAvailableCommands, | |
| 355 | publishAvailableCommands, | |
| 356 | getAvailableCommands, | |
| 357 | watchAvailableCommands, | |
| 358 | clearLiveConfiguration, | |
| 359 | publishLiveConfiguration, | |
| 360 | getLiveConfiguration, | |
| 361 | watchLiveConfiguration, | |
| 362 | requestUrlAuthentication, | |
| 363 | acceptUrlAuthentication, | |
| 364 | getUrlAuthAction, | |
| 365 | watchUrlAuthAction, | |
| 366 | }); | |
| 367 | }); | |
| 368 | ||
| 369 | const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make); |
apps/server/src/provider/acp/AcpRegistrySupport.test.ts deleted-1728| ... | ... | @@ -1,1728 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | HostProcessArchitecture, | |
| 6 | HostProcessEnvironment, | |
| 7 | HostProcessPlatform, | |
| 8 | } from "@t3tools/shared/hostProcess"; | |
| 9 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 10 | import * as Deferred from "effect/Deferred"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Fiber from "effect/Fiber"; | |
| 15 | import * as Layer from "effect/Layer"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 18 | import * as TestClock from "effect/testing/TestClock"; | |
| 19 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 20 | ||
| 21 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 22 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 23 | ||
| 24 | const registryUrl = "https://registry.test/registry.json"; | |
| 25 | const archiveUrl = "https://registry.test/example-agent.bin"; | |
| 26 | const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); | |
| 27 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 28 | ||
| 29 | function makeAgent( | |
| 30 | distribution: AcpRegistrySupport.AcpRegistryAgent["distribution"], | |
| 31 | ): AcpRegistrySupport.AcpRegistryAgent { | |
| 32 | return { | |
| 33 | id: "example-agent", | |
| 34 | name: "Example Agent", | |
| 35 | version: "1.2.3", | |
| 36 | description: "ACP Registry test agent", | |
| 37 | distribution, | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function makeRegistry(agent: AcpRegistrySupport.AcpRegistryAgent): string { | |
| 42 | return JSON.stringify({ version: "1.0.0", agents: [agent] }); | |
| 43 | } | |
| 44 | ||
| 45 | function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings { | |
| 46 | return decodeAcpRegistrySettings({ | |
| 47 | agentId: "example-agent", | |
| 48 | ...input, | |
| 49 | }); | |
| 50 | } | |
| 51 | ||
| 52 | function layerResolver( | |
| 53 | execute: Parameters<typeof HttpClient.make>[0], | |
| 54 | environment: NodeJS.ProcessEnv = process.env, | |
| 55 | ) { | |
| 56 | return Layer.mergeAll( | |
| 57 | NodeServices.layer, | |
| 58 | ServerSettings.layerTest(), | |
| 59 | Layer.succeed(HostProcessPlatform, "linux"), | |
| 60 | Layer.succeed(HostProcessArchitecture, "x64"), | |
| 61 | Layer.succeed(HostProcessEnvironment, environment), | |
| 62 | Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)), | |
| 63 | ); | |
| 64 | } | |
| 65 | ||
| 66 | const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* ( | |
| 67 | rootDirectory: string, | |
| 68 | ) { | |
| 69 | const fileSystem = yield* FileSystem.FileSystem; | |
| 70 | const path = yield* Path.Path; | |
| 71 | const toolchainBin = path.join(rootDirectory, "fake-node", "bin"); | |
| 72 | const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm"); | |
| 73 | const globalBin = path.join(globalPrefix, "bin"); | |
| 74 | const executablePath = path.join(globalBin, "example-agent"); | |
| 75 | const npmPath = path.join(toolchainBin, "npm"); | |
| 76 | const logPath = path.join(rootDirectory, "npm.log"); | |
| 77 | yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); | |
| 78 | yield* fileSystem.makeDirectory(globalPrefix, { recursive: true }); | |
| 79 | yield* fileSystem.writeFileString(logPath, ""); | |
| 80 | yield* fileSystem.copyFile( | |
| 81 | yield* path.fromFileUrl( | |
| 82 | new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url), | |
| 83 | ), | |
| 84 | npmPath, | |
| 85 | ); | |
| 86 | yield* fileSystem.chmod(npmPath, 0o755); | |
| 87 | return { | |
| 88 | environment: { | |
| 89 | ...process.env, | |
| 90 | PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, | |
| 91 | FAKE_NPM_LOG: logPath, | |
| 92 | FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"), | |
| 93 | FAKE_NPM_MANIFEST: encodeUnknownJson({ | |
| 94 | name: "@example/acp", | |
| 95 | version: "1.2.3", | |
| 96 | bin: { "example-agent": "dist/cli.js" }, | |
| 97 | }), | |
| 98 | } satisfies NodeJS.ProcessEnv, | |
| 99 | executablePath, | |
| 100 | globalBin, | |
| 101 | logPath, | |
| 102 | npmPath, | |
| 103 | }; | |
| 104 | }); | |
| 105 | ||
| 106 | const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* ( | |
| 107 | rootDirectory: string, | |
| 108 | ) { | |
| 109 | const fileSystem = yield* FileSystem.FileSystem; | |
| 110 | const path = yield* Path.Path; | |
| 111 | const toolchainBin = path.join(rootDirectory, "fake-uv", "bin"); | |
| 112 | const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin"); | |
| 113 | const executablePath = path.join(globalBin, "fast-agent"); | |
| 114 | const uvPath = path.join(toolchainBin, "uv"); | |
| 115 | const logPath = path.join(rootDirectory, "uv.log"); | |
| 116 | yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); | |
| 117 | yield* fileSystem.writeFileString(logPath, ""); | |
| 118 | yield* fileSystem.copyFile( | |
| 119 | yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)), | |
| 120 | uvPath, | |
| 121 | ); | |
| 122 | yield* fileSystem.chmod(uvPath, 0o755); | |
| 123 | return { | |
| 124 | environment: { | |
| 125 | ...process.env, | |
| 126 | PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, | |
| 127 | FAKE_UV_LOG: logPath, | |
| 128 | FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"), | |
| 129 | FAKE_UV_EXECUTABLE: executablePath, | |
| 130 | } satisfies NodeJS.ProcessEnv, | |
| 131 | executablePath, | |
| 132 | globalBin, | |
| 133 | logPath, | |
| 134 | uvPath, | |
| 135 | }; | |
| 136 | }); | |
| 137 | ||
| 138 | describe("AcpRegistrySupport", () => { | |
| 139 | it("preserves the registry failure when translating it for clients", () => { | |
| 140 | const cause = new Error("registry unavailable"); | |
| 141 | const failure = new AcpRegistrySupport.AcpRegistryError({ | |
| 142 | reason: "registry_unavailable", | |
| 143 | detail: "Could not load the ACP Registry.", | |
| 144 | cause, | |
| 145 | }); | |
| 146 | ||
| 147 | expect(AcpRegistrySupport.toAcpRegistryOperationError(failure)).toMatchObject({ | |
| 148 | reason: "registry_unavailable", | |
| 149 | message: "Could not load the ACP Registry.", | |
| 150 | cause: failure, | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | it("maps supported Node platforms to ACP Registry target keys", () => { | |
| 155 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe( | |
| 156 | "darwin-aarch64", | |
| 157 | ); | |
| 158 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "x64")).toBe( | |
| 159 | "linux-x86_64", | |
| 160 | ); | |
| 161 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe( | |
| 162 | "windows-aarch64", | |
| 163 | ); | |
| 164 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined(); | |
| 165 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined(); | |
| 166 | }); | |
| 167 | ||
| 168 | it("selects the preferred compatible distribution", () => { | |
| 169 | const agent = makeAgent({ | |
| 170 | binary: { | |
| 171 | "linux-x86_64": { | |
| 172 | archive: archiveUrl, | |
| 173 | cmd: "./bin/example-agent", | |
| 174 | args: ["acp"], | |
| 175 | }, | |
| 176 | }, | |
| 177 | npx: { | |
| 178 | package: "@example/acp@1.2.3", | |
| 179 | args: ["--stdio"], | |
| 180 | }, | |
| 181 | }); | |
| 182 | ||
| 183 | expect( | |
| 184 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 185 | agent, | |
| 186 | preference: "auto", | |
| 187 | platformTarget: "linux-x86_64", | |
| 188 | }), | |
| 189 | ).toMatchObject({ kind: "binary", args: ["acp"] }); | |
| 190 | expect( | |
| 191 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 192 | agent, | |
| 193 | preference: "npx", | |
| 194 | platformTarget: "linux-x86_64", | |
| 195 | }), | |
| 196 | ).toEqual({ | |
| 197 | kind: "npx", | |
| 198 | packageName: "@example/acp@1.2.3", | |
| 199 | args: ["--stdio"], | |
| 200 | env: {}, | |
| 201 | }); | |
| 202 | expect( | |
| 203 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 204 | agent, | |
| 205 | preference: "binary", | |
| 206 | platformTarget: "darwin-aarch64", | |
| 207 | }), | |
| 208 | ).toBeUndefined(); | |
| 209 | }); | |
| 210 | ||
| 211 | it.effect("resolves a local command on the selected environment without the registry", () => { | |
| 212 | const requests: Array<string> = []; | |
| 213 | return Effect.gen(function* () { | |
| 214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 215 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); | |
| 216 | const commandPath = `${cacheDir}/dsh`; | |
| 217 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 218 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 219 | const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; | |
| 220 | const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; | |
| 221 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 222 | cacheDir, | |
| 223 | toolsDir: `${cacheDir}/tools`, | |
| 224 | registryUrl, | |
| 225 | }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); | |
| 226 | const localSettings = decodeAcpRegistrySettings({ | |
| 227 | source: "local", | |
| 228 | commandPath: "dsh", | |
| 229 | commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 230 | }); | |
| 231 | ||
| 232 | expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); | |
| 233 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 234 | status: "ready", | |
| 235 | agentId: "dsh", | |
| 236 | version: null, | |
| 237 | distribution: "local", | |
| 238 | }); | |
| 239 | const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); | |
| 240 | expect(resolved).toEqual({ | |
| 241 | distribution: "local", | |
| 242 | spawn: { | |
| 243 | command: commandPath, | |
| 244 | args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 245 | cwd: "/workspace", | |
| 246 | env: environment, | |
| 247 | shell: false, | |
| 248 | }, | |
| 249 | }); | |
| 250 | expect(requests).toEqual([]); | |
| 251 | expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); | |
| 252 | }).pipe( | |
| 253 | Effect.scoped, | |
| 254 | Effect.provide( | |
| 255 | layerResolver((request) => { | |
| 256 | requests.push(request.url); | |
| 257 | return Effect.die("unexpected registry request for a local provider"); | |
| 258 | }), | |
| 259 | ), | |
| 260 | ); | |
| 261 | }); | |
| 262 | ||
| 263 | it.effect("resolves a local executable path and inherits the host environment", () => | |
| 264 | Effect.gen(function* () { | |
| 265 | const fileSystem = yield* FileSystem.FileSystem; | |
| 266 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); | |
| 267 | const commandPath = `${cacheDir}/dsh wrapper`; | |
| 268 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 269 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 270 | const environment = { PATH: "/unused", INHERITED: "host" }; | |
| 271 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 272 | cacheDir, | |
| 273 | toolsDir: `${cacheDir}/tools`, | |
| 274 | registryUrl, | |
| 275 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 276 | const resolved = yield* resolver.resolve( | |
| 277 | decodeAcpRegistrySettings({ source: "local", commandPath }), | |
| 278 | "/workspace", | |
| 279 | ); | |
| 280 | ||
| 281 | expect(resolved.spawn).toEqual({ | |
| 282 | command: commandPath, | |
| 283 | args: [], | |
| 284 | cwd: "/workspace", | |
| 285 | env: environment, | |
| 286 | shell: false, | |
| 287 | }); | |
| 288 | expect(resolved.agent).toBeUndefined(); | |
| 289 | ||
| 290 | for (const extension of [".cmd", ".BAT"]) { | |
| 291 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 292 | cacheDir, | |
| 293 | toolsDir: `${cacheDir}/tools`, | |
| 294 | registryUrl, | |
| 295 | }).pipe( | |
| 296 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 297 | Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), | |
| 298 | ); | |
| 299 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); | |
| 300 | for (const failure of [ | |
| 301 | yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), | |
| 302 | yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), | |
| 303 | ]) { | |
| 304 | expect(failure).toMatchObject({ | |
| 305 | reason: "runner_unavailable", | |
| 306 | detail: expect.stringContaining("node.exe"), | |
| 307 | }); | |
| 308 | } | |
| 309 | } | |
| 310 | }).pipe( | |
| 311 | Effect.scoped, | |
| 312 | Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), | |
| 313 | ), | |
| 314 | ); | |
| 315 | ||
| 316 | it.effect("reports missing or non-executable local commands without registry fallback", () => | |
| 317 | Effect.gen(function* () { | |
| 318 | const fileSystem = yield* FileSystem.FileSystem; | |
| 319 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 320 | prefix: "t3-acp-local-missing-", | |
| 321 | }); | |
| 322 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 323 | cacheDir, | |
| 324 | toolsDir: `${cacheDir}/tools`, | |
| 325 | registryUrl, | |
| 326 | }); | |
| 327 | const environment = { PATH: cacheDir }; | |
| 328 | const unconfigured = decodeAcpRegistrySettings({ source: "local" }); | |
| 329 | expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ | |
| 330 | status: "unconfigured", | |
| 331 | }); | |
| 332 | expect( | |
| 333 | yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), | |
| 334 | ).toMatchObject({ reason: "agent_not_configured" }); | |
| 335 | ||
| 336 | const nonExecutable = `${cacheDir}/non-executable`; | |
| 337 | yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); | |
| 338 | yield* fileSystem.chmod(nonExecutable, 0o644); | |
| 339 | for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { | |
| 340 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); | |
| 341 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 342 | status: "missing_runner", | |
| 343 | version: null, | |
| 344 | distribution: "local", | |
| 345 | }); | |
| 346 | expect( | |
| 347 | yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), | |
| 348 | ).toMatchObject({ | |
| 349 | reason: "runner_unavailable", | |
| 350 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 351 | }); | |
| 352 | } | |
| 353 | }).pipe( | |
| 354 | Effect.scoped, | |
| 355 | Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), | |
| 356 | ), | |
| 357 | ); | |
| 358 | ||
| 359 | it.effect("resolves command overrides while preserving registry args and environment", () => { | |
| 360 | const agent = makeAgent({ | |
| 361 | binary: { | |
| 362 | "linux-x86_64": { | |
| 363 | archive: archiveUrl, | |
| 364 | cmd: "./bin/example-agent", | |
| 365 | args: ["acp", "--stdio"], | |
| 366 | env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" }, | |
| 367 | }, | |
| 368 | }, | |
| 369 | }); | |
| 370 | const requests: Array<string> = []; | |
| 371 | return Effect.gen(function* () { | |
| 372 | const fileSystem = yield* FileSystem.FileSystem; | |
| 373 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 374 | prefix: "t3-acp-registry-override-", | |
| 375 | }); | |
| 376 | const commandPath = `${cacheDir}/example-agent`; | |
| 377 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 378 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 379 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 380 | cacheDir, | |
| 381 | toolsDir: `${cacheDir}/tools`, | |
| 382 | registryUrl, | |
| 383 | }); | |
| 384 | const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", { | |
| 385 | HOST_VALUE: "host", | |
| 386 | OVERRIDE_ME: "host", | |
| 387 | }); | |
| 388 | ||
| 389 | expect(resolved.distribution).toBe("binary"); | |
| 390 | expect(resolved.spawn).toEqual({ | |
| 391 | command: commandPath, | |
| 392 | args: ["acp", "--stdio"], | |
| 393 | cwd: "/workspace", | |
| 394 | env: { | |
| 395 | HOST_VALUE: "host", | |
| 396 | OVERRIDE_ME: "registry", | |
| 397 | REGISTRY_VALUE: "registry", | |
| 398 | }, | |
| 399 | }); | |
| 400 | expect(requests).toEqual([registryUrl]); | |
| 401 | }).pipe( | |
| 402 | Effect.scoped, | |
| 403 | Effect.provide( | |
| 404 | layerResolver((request) => { | |
| 405 | requests.push(request.url); | |
| 406 | return Effect.succeed( | |
| 407 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 408 | ); | |
| 409 | }), | |
| 410 | ), | |
| 411 | ); | |
| 412 | }); | |
| 413 | ||
| 414 | it.effect("installs into T3 home a package and launches its exposed command", () => { | |
| 415 | const agent = makeAgent({ | |
| 416 | npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] }, | |
| 417 | }); | |
| 418 | return Effect.gen(function* () { | |
| 419 | const fileSystem = yield* FileSystem.FileSystem; | |
| 420 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 421 | prefix: "t3-acp-registry-global-package-", | |
| 422 | }); | |
| 423 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 424 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 425 | cacheDir, | |
| 426 | toolsDir: `${cacheDir}/tools`, | |
| 427 | registryUrl, | |
| 428 | }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); | |
| 429 | ||
| 430 | const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 431 | const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 432 | ||
| 433 | expect(first.spawn).toMatchObject({ | |
| 434 | command: toolchain.executablePath, | |
| 435 | args: ["--stdio"], | |
| 436 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 437 | }); | |
| 438 | expect(second.spawn.command).toBe(toolchain.executablePath); | |
| 439 | const npmCommands = yield* fileSystem.readFileString(toolchain.logPath); | |
| 440 | expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1); | |
| 441 | expect(npmCommands).toContain("install --global @example/acp@V1.2.3"); | |
| 442 | }).pipe( | |
| 443 | Effect.scoped, | |
| 444 | Effect.provide( | |
| 445 | layerResolver((request) => | |
| 446 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 447 | ), | |
| 448 | ), | |
| 449 | ); | |
| 450 | }); | |
| 451 | ||
| 452 | it.effect("uses its managed prefix despite a system-owned npm prefix", () => { | |
| 453 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 454 | return Effect.gen(function* () { | |
| 455 | const fileSystem = yield* FileSystem.FileSystem; | |
| 456 | const path = yield* Path.Path; | |
| 457 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 458 | prefix: "t3-acp-registry-user-global-package-", | |
| 459 | }); | |
| 460 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 461 | const systemPrefix = path.join(cacheDir, "system-global"); | |
| 462 | yield* fileSystem.makeDirectory(systemPrefix); | |
| 463 | yield* fileSystem.chmod(systemPrefix, 0o555); | |
| 464 | const userHome = path.join(cacheDir, "home"); | |
| 465 | const environment = { | |
| 466 | ...toolchain.environment, | |
| 467 | HOME: userHome, | |
| 468 | FAKE_NPM_PREFIX: systemPrefix, | |
| 469 | }; | |
| 470 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 471 | cacheDir, | |
| 472 | toolsDir: `${cacheDir}/tools`, | |
| 473 | registryUrl, | |
| 474 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 475 | ||
| 476 | const resolved = yield* resolver.resolve(settings(), "/workspace", environment); | |
| 477 | const userGlobalBin = toolchain.globalBin; | |
| 478 | expect(resolved.spawn).toMatchObject({ | |
| 479 | command: path.join(userGlobalBin, "example-agent"), | |
| 480 | env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) }, | |
| 481 | }); | |
| 482 | }).pipe( | |
| 483 | Effect.scoped, | |
| 484 | Effect.provide( | |
| 485 | layerResolver((request) => | |
| 486 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 487 | ), | |
| 488 | ), | |
| 489 | ); | |
| 490 | }); | |
| 491 | ||
| 492 | it.effect("installs into T3 home a uv tool and launches its exposed command", () => { | |
| 493 | const agent = makeAgent({ | |
| 494 | uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] }, | |
| 495 | }); | |
| 496 | return Effect.gen(function* () { | |
| 497 | const fileSystem = yield* FileSystem.FileSystem; | |
| 498 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 499 | prefix: "t3-acp-registry-global-uv-tool-", | |
| 500 | }); | |
| 501 | const toolchain = yield* makeFakeUvToolchain(cacheDir); | |
| 502 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 503 | cacheDir, | |
| 504 | toolsDir: `${cacheDir}/tools`, | |
| 505 | registryUrl, | |
| 506 | }); | |
| 507 | ||
| 508 | const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 509 | ||
| 510 | expect(resolved.spawn).toMatchObject({ | |
| 511 | command: toolchain.executablePath, | |
| 512 | args: ["--acp"], | |
| 513 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 514 | }); | |
| 515 | expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( | |
| 516 | "tool install --force fast-agent-acp==V0.10.1", | |
| 517 | ); | |
| 518 | }).pipe( | |
| 519 | Effect.scoped, | |
| 520 | Effect.provide( | |
| 521 | layerResolver((request) => | |
| 522 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 523 | ), | |
| 524 | ), | |
| 525 | ); | |
| 526 | }); | |
| 527 | ||
| 528 | it.effect("honors an exact Windows PATH override when launching a global package", () => { | |
| 529 | const agent = makeAgent({ | |
| 530 | uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] }, | |
| 531 | }); | |
| 532 | return Effect.gen(function* () { | |
| 533 | const fileSystem = yield* FileSystem.FileSystem; | |
| 534 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 535 | prefix: "t3-acp-registry-windows-package-path-", | |
| 536 | }); | |
| 537 | const toolchain = yield* makeFakeUvToolchain(cacheDir); | |
| 538 | const linuxResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 539 | cacheDir, | |
| 540 | toolsDir: `${cacheDir}/tools`, | |
| 541 | registryUrl, | |
| 542 | }); | |
| 543 | yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 544 | ||
| 545 | const windowsEnvironment = { | |
| 546 | ...toolchain.environment, | |
| 547 | Path: "C:\\host\\bin", | |
| 548 | PATH: "C:\\provider\\bin", | |
| 549 | }; | |
| 550 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 551 | cacheDir, | |
| 552 | toolsDir: `${cacheDir}/tools`, | |
| 553 | registryUrl, | |
| 554 | }).pipe( | |
| 555 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 556 | Effect.provideService(SpawnExecutableResolution, (command) => { | |
| 557 | if (command === "uv") return toolchain.uvPath; | |
| 558 | if (command === "fast-agent") return toolchain.executablePath; | |
| 559 | return undefined; | |
| 560 | }), | |
| 561 | ); | |
| 562 | const resolved = yield* windowsResolver.resolve( | |
| 563 | settings(), | |
| 564 | "C:\\workspace", | |
| 565 | windowsEnvironment, | |
| 566 | ); | |
| 567 | ||
| 568 | expect(resolved.spawn).toMatchObject({ | |
| 569 | env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` }, | |
| 570 | }); | |
| 571 | }).pipe( | |
| 572 | Effect.scoped, | |
| 573 | Effect.provide( | |
| 574 | layerResolver((request) => | |
| 575 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 576 | ), | |
| 577 | ), | |
| 578 | ); | |
| 579 | }); | |
| 580 | ||
| 581 | it.effect("installs and reuses a registry binary in the managed cache", () => { | |
| 582 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 583 | const agent = makeAgent({ | |
| 584 | binary: { | |
| 585 | "linux-x86_64": { | |
| 586 | archive: archiveUrl, | |
| 587 | cmd: "./bin/example-agent", | |
| 588 | args: ["acp"], | |
| 589 | // SHA-256 of binaryBytes. | |
| 590 | sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7", | |
| 591 | }, | |
| 592 | }, | |
| 593 | }); | |
| 594 | const requests: Array<string> = []; | |
| 595 | return Effect.gen(function* () { | |
| 596 | const fileSystem = yield* FileSystem.FileSystem; | |
| 597 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 598 | prefix: "t3-acp-registry-install-", | |
| 599 | }); | |
| 600 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 601 | cacheDir, | |
| 602 | toolsDir: `${cacheDir}/tools`, | |
| 603 | registryUrl, | |
| 604 | }); | |
| 605 | const first = yield* resolver.resolve(settings(), "/workspace"); | |
| 606 | const second = yield* resolver.resolve(settings(), "/workspace"); | |
| 607 | ||
| 608 | expect(first.spawn.command).toBe(second.spawn.command); | |
| 609 | expect(first.spawn.command).toContain( | |
| 610 | "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent", | |
| 611 | ); | |
| 612 | expect(yield* fileSystem.readFileString(first.spawn.command)).toBe( | |
| 613 | "#!/bin/sh\necho example\n", | |
| 614 | ); | |
| 615 | expect(requests).toEqual([registryUrl, archiveUrl]); | |
| 616 | }).pipe( | |
| 617 | Effect.scoped, | |
| 618 | Effect.provide( | |
| 619 | layerResolver((request) => { | |
| 620 | requests.push(request.url); | |
| 621 | const response = | |
| 622 | request.url === registryUrl | |
| 623 | ? new Response(makeRegistry(agent)) | |
| 624 | : new Response( | |
| 625 | new ReadableStream<Uint8Array>({ | |
| 626 | start(controller) { | |
| 627 | controller.enqueue(binaryBytes.slice(0, 8)); | |
| 628 | controller.enqueue(binaryBytes.slice(8)); | |
| 629 | controller.close(); | |
| 630 | }, | |
| 631 | }), | |
| 632 | ); | |
| 633 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | |
| 634 | }), | |
| 635 | ), | |
| 636 | ); | |
| 637 | }); | |
| 638 | ||
| 639 | it.effect( | |
| 640 | "prepares the registry version despite an older PATH binary and permits explicit overrides", | |
| 641 | () => { | |
| 642 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n"); | |
| 643 | const agent = makeAgent({ | |
| 644 | binary: { | |
| 645 | "linux-x86_64": { | |
| 646 | archive: archiveUrl, | |
| 647 | cmd: "./bin/example-agent", | |
| 648 | args: ["acp"], | |
| 649 | // SHA-256 of binaryBytes. | |
| 650 | sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31", | |
| 651 | }, | |
| 652 | }, | |
| 653 | }); | |
| 654 | const requests: Array<string> = []; | |
| 655 | return Effect.gen(function* () { | |
| 656 | const fileSystem = yield* FileSystem.FileSystem; | |
| 657 | const path = yield* Path.Path; | |
| 658 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 659 | prefix: "t3-acp-registry-system-", | |
| 660 | }); | |
| 661 | const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 662 | prefix: "t3-acp-registry-system-bin-", | |
| 663 | }); | |
| 664 | const systemBinary = path.join(systemBinDir, "example-agent"); | |
| 665 | yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n"); | |
| 666 | yield* fileSystem.chmod(systemBinary, 0o755); | |
| 667 | const environment = { PATH: systemBinDir }; | |
| 668 | ||
| 669 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 670 | cacheDir, | |
| 671 | toolsDir: `${cacheDir}/tools`, | |
| 672 | registryUrl, | |
| 673 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 674 | yield* resolver.search({ query: "example" }); | |
| 675 | expect(yield* resolver.inspect(settings(), environment)).toMatchObject({ | |
| 676 | status: "unprepared", | |
| 677 | }); | |
| 678 | ||
| 679 | const overridden = settings({ commandPath: systemBinary }); | |
| 680 | expect(yield* resolver.inspect(overridden, environment)).toMatchObject({ | |
| 681 | status: "ready", | |
| 682 | version: null, | |
| 683 | }); | |
| 684 | const custom = yield* resolver.resolve(overridden, "/workspace", environment); | |
| 685 | expect(custom.spawn.command).toBe(systemBinary); | |
| 686 | expect(custom.spawn.args).toEqual(["acp"]); | |
| 687 | expect(requests).toEqual([registryUrl]); | |
| 688 | ||
| 689 | yield* resolver.prepare({ agentId: "example-agent" }); | |
| 690 | const resolved = yield* resolver.resolve(settings(), "/workspace", environment); | |
| 691 | expect(resolved.spawn.command).not.toBe(systemBinary); | |
| 692 | expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( | |
| 693 | "#!/bin/sh\necho managed\n", | |
| 694 | ); | |
| 695 | expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n"); | |
| 696 | ||
| 697 | const inspection = yield* resolver.inspect(settings(), environment); | |
| 698 | expect(inspection).toMatchObject({ | |
| 699 | status: "ready", | |
| 700 | distribution: "binary", | |
| 701 | version: "1.2.3", | |
| 702 | }); | |
| 703 | ||
| 704 | expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]); | |
| 705 | }).pipe( | |
| 706 | Effect.scoped, | |
| 707 | Effect.provide( | |
| 708 | layerResolver((request) => { | |
| 709 | requests.push(request.url); | |
| 710 | return Effect.succeed( | |
| 711 | HttpClientResponse.fromWeb( | |
| 712 | request, | |
| 713 | request.url === registryUrl | |
| 714 | ? new Response(makeRegistry(agent)) | |
| 715 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 716 | ), | |
| 717 | ); | |
| 718 | }), | |
| 719 | ), | |
| 720 | ); | |
| 721 | }, | |
| 722 | ); | |
| 723 | ||
| 724 | it.effect("installs a tar archive containing a root directory entry", () => { | |
| 725 | const downloadUrl = "https://registry.test/agent.tar.gz"; | |
| 726 | const agent = makeAgent({ | |
| 727 | binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } }, | |
| 728 | }); | |
| 729 | return Effect.gen(function* () { | |
| 730 | const fileSystem = yield* FileSystem.FileSystem; | |
| 731 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" }); | |
| 732 | const source = `${cacheDir}/source`; | |
| 733 | yield* fileSystem.makeDirectory(source); | |
| 734 | yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n"); | |
| 735 | const archivePath = `${cacheDir}/agent.tar.gz`; | |
| 736 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 737 | const tar = yield* spawner.spawn( | |
| 738 | ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]), | |
| 739 | ); | |
| 740 | expect(yield* tar.exitCode).toBe(0); | |
| 741 | const archive = yield* fileSystem.readFile(archivePath); | |
| 742 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 743 | cacheDir, | |
| 744 | toolsDir: `${cacheDir}/tools`, | |
| 745 | registryUrl, | |
| 746 | }).pipe( | |
| 747 | Effect.provideService( | |
| 748 | HttpClient.HttpClient, | |
| 749 | HttpClient.make((request) => | |
| 750 | Effect.succeed( | |
| 751 | HttpClientResponse.fromWeb( | |
| 752 | request, | |
| 753 | request.url === registryUrl | |
| 754 | ? new Response(makeRegistry(agent)) | |
| 755 | : new Response(archive), | |
| 756 | ), | |
| 757 | ), | |
| 758 | ), | |
| 759 | ), | |
| 760 | ); | |
| 761 | yield* resolver.prepare({ agentId: agent.id }); | |
| 762 | const resolved = yield* resolver.resolve(settings(), "/workspace", {}); | |
| 763 | expect(resolved.spawn.command).toBe( | |
| 764 | yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`), | |
| 765 | ); | |
| 766 | expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( | |
| 767 | "#!/bin/sh\necho managed\n", | |
| 768 | ); | |
| 769 | }).pipe( | |
| 770 | Effect.scoped, | |
| 771 | Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), | |
| 772 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 773 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 774 | ); | |
| 775 | }); | |
| 776 | ||
| 777 | it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => { | |
| 778 | const exact = { | |
| 779 | ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }), | |
| 780 | id: "codex-acp", | |
| 781 | name: "Codex", | |
| 782 | authors: ["OpenAI", "Zed Industries"], | |
| 783 | license: "Apache-2.0", | |
| 784 | website: "https://example.test/codex", | |
| 785 | repository: "https://example.test/codex/source", | |
| 786 | icon: "https://example.test/codex.svg", | |
| 787 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 788 | const descriptionMatch = { | |
| 789 | ...makeAgent({ npx: { package: "other-agent@1.2.3" } }), | |
| 790 | id: "other-agent", | |
| 791 | name: "Other Agent", | |
| 792 | description: "An adapter for Codex workflows", | |
| 793 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 794 | const incompatible = { | |
| 795 | ...makeAgent({ | |
| 796 | binary: { | |
| 797 | "darwin-aarch64": { archive: archiveUrl, cmd: "agent" }, | |
| 798 | }, | |
| 799 | }), | |
| 800 | id: "darwin-only", | |
| 801 | name: "Codex Darwin", | |
| 802 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 803 | ||
| 804 | return Effect.gen(function* () { | |
| 805 | const fileSystem = yield* FileSystem.FileSystem; | |
| 806 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 807 | prefix: "t3-acp-registry-search-", | |
| 808 | }); | |
| 809 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 810 | cacheDir, | |
| 811 | toolsDir: `${cacheDir}/tools`, | |
| 812 | registryUrl, | |
| 813 | }); | |
| 814 | const result = yield* resolver.search({ query: "codex" }); | |
| 815 | ||
| 816 | expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]); | |
| 817 | expect(result.agents[0]).toMatchObject({ | |
| 818 | authors: ["OpenAI", "Zed Industries"], | |
| 819 | distribution: "npx", | |
| 820 | integrity: "registry", | |
| 821 | license: "Apache-2.0", | |
| 822 | }); | |
| 823 | }).pipe( | |
| 824 | Effect.scoped, | |
| 825 | Effect.provide( | |
| 826 | layerResolver((request) => | |
| 827 | Effect.succeed( | |
| 828 | HttpClientResponse.fromWeb( | |
| 829 | request, | |
| 830 | new Response( | |
| 831 | JSON.stringify({ | |
| 832 | version: "1.0.0", | |
| 833 | agents: [descriptionMatch, incompatible, exact], | |
| 834 | }), | |
| 835 | ), | |
| 836 | ), | |
| 837 | ), | |
| 838 | ), | |
| 839 | ), | |
| 840 | ); | |
| 841 | }); | |
| 842 | ||
| 843 | it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => { | |
| 844 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 845 | let requests = 0; | |
| 846 | return Effect.gen(function* () { | |
| 847 | const fileSystem = yield* FileSystem.FileSystem; | |
| 848 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 849 | prefix: "t3-acp-registry-refresh-", | |
| 850 | }); | |
| 851 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 852 | cacheDir, | |
| 853 | toolsDir: `${cacheDir}/tools`, | |
| 854 | registryUrl, | |
| 855 | }); | |
| 856 | yield* Effect.all( | |
| 857 | [resolver.search({ query: "example" }), resolver.search({ query: "example" })], | |
| 858 | { concurrency: "unbounded" }, | |
| 859 | ); | |
| 860 | expect(requests).toBe(1); | |
| 861 | ||
| 862 | yield* resolver.search({ query: "example" }); | |
| 863 | expect(requests).toBe(2); | |
| 864 | }).pipe( | |
| 865 | Effect.scoped, | |
| 866 | Effect.provide( | |
| 867 | layerResolver((request) => { | |
| 868 | requests += 1; | |
| 869 | return Effect.yieldNow.pipe( | |
| 870 | Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 871 | ); | |
| 872 | }), | |
| 873 | ), | |
| 874 | ); | |
| 875 | }); | |
| 876 | ||
| 877 | it.effect("filters runner recipes that the environment cannot prepare", () => { | |
| 878 | const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 879 | const binaryAgent = { | |
| 880 | ...makeAgent({ | |
| 881 | binary: { | |
| 882 | "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, | |
| 883 | }, | |
| 884 | }), | |
| 885 | id: "binary-agent", | |
| 886 | name: "Binary Agent", | |
| 887 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 888 | return Effect.gen(function* () { | |
| 889 | const fileSystem = yield* FileSystem.FileSystem; | |
| 890 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 891 | prefix: "t3-acp-registry-runner-filter-", | |
| 892 | }); | |
| 893 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 894 | cacheDir, | |
| 895 | toolsDir: `${cacheDir}/tools`, | |
| 896 | registryUrl, | |
| 897 | }); | |
| 898 | const result = yield* resolver.search({ query: "" }); | |
| 899 | ||
| 900 | expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]); | |
| 901 | }).pipe( | |
| 902 | Effect.scoped, | |
| 903 | Effect.provide( | |
| 904 | layerResolver( | |
| 905 | (request) => | |
| 906 | Effect.succeed( | |
| 907 | HttpClientResponse.fromWeb( | |
| 908 | request, | |
| 909 | new Response( | |
| 910 | JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }), | |
| 911 | ), | |
| 912 | ), | |
| 913 | ), | |
| 914 | { PATH: "" }, | |
| 915 | ), | |
| 916 | ), | |
| 917 | ); | |
| 918 | }); | |
| 919 | ||
| 920 | it.effect("discards registry agents with blank names or unsafe versions", () => { | |
| 921 | const distribution = { | |
| 922 | binary: { | |
| 923 | "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, | |
| 924 | }, | |
| 925 | } satisfies AcpRegistrySupport.AcpRegistryAgent["distribution"]; | |
| 926 | const valid = makeAgent(distribution); | |
| 927 | const blankName = { ...valid, id: "blank-name", name: " " }; | |
| 928 | const blankVersion = { ...valid, id: "blank-version", version: "\t" }; | |
| 929 | const parentVersion = { ...valid, id: "parent-version", version: ".." }; | |
| 930 | return Effect.gen(function* () { | |
| 931 | const fileSystem = yield* FileSystem.FileSystem; | |
| 932 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 933 | prefix: "t3-acp-registry-blank-fields-", | |
| 934 | }); | |
| 935 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 936 | cacheDir, | |
| 937 | toolsDir: `${cacheDir}/tools`, | |
| 938 | registryUrl, | |
| 939 | }); | |
| 940 | const result = yield* resolver.search({ query: "" }); | |
| 941 | ||
| 942 | expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]); | |
| 943 | }).pipe( | |
| 944 | Effect.scoped, | |
| 945 | Effect.provide( | |
| 946 | layerResolver((request) => | |
| 947 | Effect.succeed( | |
| 948 | HttpClientResponse.fromWeb( | |
| 949 | request, | |
| 950 | new Response( | |
| 951 | JSON.stringify({ | |
| 952 | version: "1.0.0", | |
| 953 | agents: [blankName, blankVersion, parentVersion, valid], | |
| 954 | }), | |
| 955 | ), | |
| 956 | ), | |
| 957 | ), | |
| 958 | ), | |
| 959 | ), | |
| 960 | ); | |
| 961 | }); | |
| 962 | ||
| 963 | it.effect("ignores unpinned runner recipes from the registry", () => { | |
| 964 | const agent = makeAgent({ npx: { package: "@example/acp@latest" } }); | |
| 965 | return Effect.gen(function* () { | |
| 966 | const fileSystem = yield* FileSystem.FileSystem; | |
| 967 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 968 | prefix: "t3-acp-registry-unpinned-", | |
| 969 | }); | |
| 970 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 971 | cacheDir, | |
| 972 | toolsDir: `${cacheDir}/tools`, | |
| 973 | registryUrl, | |
| 974 | }); | |
| 975 | const result = yield* resolver.search({ query: "" }); | |
| 976 | ||
| 977 | expect(result.agents).toEqual([]); | |
| 978 | }).pipe( | |
| 979 | Effect.scoped, | |
| 980 | Effect.provide( | |
| 981 | layerResolver((request) => | |
| 982 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 983 | ), | |
| 984 | ), | |
| 985 | ); | |
| 986 | }); | |
| 987 | ||
| 988 | it.effect("rejects package syntax for the wrong runner", () => { | |
| 989 | const invalidAgents = [ | |
| 990 | { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" }, | |
| 991 | ]; | |
| 992 | const validAgents = [ | |
| 993 | { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" }, | |
| 994 | { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" }, | |
| 995 | ]; | |
| 996 | return Effect.gen(function* () { | |
| 997 | const fileSystem = yield* FileSystem.FileSystem; | |
| 998 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 999 | prefix: "t3-acp-registry-runner-syntax-", | |
| 1000 | }); | |
| 1001 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1002 | cacheDir, | |
| 1003 | toolsDir: `${cacheDir}/tools`, | |
| 1004 | registryUrl, | |
| 1005 | }); | |
| 1006 | const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip); | |
| 1007 | const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip); | |
| 1008 | const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip); | |
| 1009 | ||
| 1010 | expect(invalid.reason).toBe("agent_not_found"); | |
| 1011 | expect(validAt.reason).toBe("runner_unavailable"); | |
| 1012 | expect(validEquals.reason).toBe("runner_unavailable"); | |
| 1013 | }).pipe( | |
| 1014 | Effect.scoped, | |
| 1015 | Effect.provide( | |
| 1016 | layerResolver( | |
| 1017 | (request) => | |
| 1018 | Effect.succeed( | |
| 1019 | HttpClientResponse.fromWeb( | |
| 1020 | request, | |
| 1021 | new Response( | |
| 1022 | JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }), | |
| 1023 | ), | |
| 1024 | ), | |
| 1025 | ), | |
| 1026 | { PATH: "" }, | |
| 1027 | ), | |
| 1028 | ), | |
| 1029 | ); | |
| 1030 | }); | |
| 1031 | ||
| 1032 | it.effect("verifies declared SHA-256 before installing a binary", () => { | |
| 1033 | const agent = makeAgent({ | |
| 1034 | binary: { | |
| 1035 | "linux-x86_64": { | |
| 1036 | archive: archiveUrl, | |
| 1037 | cmd: "example-agent", | |
| 1038 | sha256: "0".repeat(64), | |
| 1039 | }, | |
| 1040 | }, | |
| 1041 | }); | |
| 1042 | const binaryBytes = new TextEncoder().encode("not the declared binary"); | |
| 1043 | ||
| 1044 | return Effect.gen(function* () { | |
| 1045 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1046 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1047 | prefix: "t3-acp-registry-checksum-", | |
| 1048 | }); | |
| 1049 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1050 | cacheDir, | |
| 1051 | toolsDir: `${cacheDir}/tools`, | |
| 1052 | registryUrl, | |
| 1053 | }); | |
| 1054 | const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip); | |
| 1055 | ||
| 1056 | expect(error.reason).toBe("checksum_mismatch"); | |
| 1057 | }).pipe( | |
| 1058 | Effect.scoped, | |
| 1059 | Effect.provide( | |
| 1060 | layerResolver((request) => | |
| 1061 | Effect.succeed( | |
| 1062 | HttpClientResponse.fromWeb( | |
| 1063 | request, | |
| 1064 | request.url === registryUrl | |
| 1065 | ? new Response(makeRegistry(agent)) | |
| 1066 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1067 | ), | |
| 1068 | ), | |
| 1069 | ), | |
| 1070 | ), | |
| 1071 | ); | |
| 1072 | }); | |
| 1073 | ||
| 1074 | it.effect("installs into T3 home package recipes during preparation", () => { | |
| 1075 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } }); | |
| 1076 | const requests: string[] = []; | |
| 1077 | ||
| 1078 | return Effect.gen(function* () { | |
| 1079 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1080 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1081 | prefix: "t3-acp-registry-runner-", | |
| 1082 | }); | |
| 1083 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1084 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1085 | cacheDir, | |
| 1086 | toolsDir: `${cacheDir}/tools`, | |
| 1087 | registryUrl, | |
| 1088 | }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); | |
| 1089 | const prepared = yield* resolver.prepare({ agentId: agent.id }); | |
| 1090 | expect(prepared).toEqual({ | |
| 1091 | agentId: "example-agent", | |
| 1092 | version: "1.2.3", | |
| 1093 | distribution: "npx", | |
| 1094 | prepared: true, | |
| 1095 | }); | |
| 1096 | expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true); | |
| 1097 | expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false); | |
| 1098 | expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( | |
| 1099 | "install --global @example/acp@1.2.3", | |
| 1100 | ); | |
| 1101 | expect(requests).toEqual([registryUrl]); | |
| 1102 | }).pipe( | |
| 1103 | Effect.scoped, | |
| 1104 | Effect.provide( | |
| 1105 | layerResolver((request) => { | |
| 1106 | requests.push(request.url); | |
| 1107 | return Effect.succeed( | |
| 1108 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1109 | ); | |
| 1110 | }, process.env), | |
| 1111 | ), | |
| 1112 | ); | |
| 1113 | }); | |
| 1114 | ||
| 1115 | it.effect("falls back to a valid cached registry index when refresh fails", () => { | |
| 1116 | const agent = makeAgent({ | |
| 1117 | npx: { | |
| 1118 | package: "@example/acp@1.2.3", | |
| 1119 | args: ["--stdio"], | |
| 1120 | }, | |
| 1121 | }); | |
| 1122 | return Effect.gen(function* () { | |
| 1123 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1124 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1125 | prefix: "t3-acp-registry-cache-", | |
| 1126 | }); | |
| 1127 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1128 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1129 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1130 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1131 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1132 | cacheDir, | |
| 1133 | toolsDir: `${cacheDir}/tools`, | |
| 1134 | registryUrl, | |
| 1135 | }); | |
| 1136 | const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 1137 | ||
| 1138 | expect(resolved.spawn).toMatchObject({ | |
| 1139 | command: toolchain.executablePath, | |
| 1140 | args: ["--stdio"], | |
| 1141 | }); | |
| 1142 | }).pipe( | |
| 1143 | Effect.scoped, | |
| 1144 | Effect.provide( | |
| 1145 | layerResolver((request) => | |
| 1146 | Effect.succeed( | |
| 1147 | HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), | |
| 1148 | ), | |
| 1149 | ), | |
| 1150 | ), | |
| 1151 | ); | |
| 1152 | }); | |
| 1153 | ||
| 1154 | it.effect("rejects unsafe command paths before downloading an archive", () => { | |
| 1155 | const agent = makeAgent({ | |
| 1156 | binary: { | |
| 1157 | "linux-x86_64": { | |
| 1158 | archive: archiveUrl, | |
| 1159 | cmd: "../outside", | |
| 1160 | }, | |
| 1161 | }, | |
| 1162 | }); | |
| 1163 | const requests: Array<string> = []; | |
| 1164 | return Effect.gen(function* () { | |
| 1165 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1166 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1167 | prefix: "t3-acp-registry-invalid-", | |
| 1168 | }); | |
| 1169 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1170 | cacheDir, | |
| 1171 | toolsDir: `${cacheDir}/tools`, | |
| 1172 | registryUrl, | |
| 1173 | }); | |
| 1174 | const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip); | |
| 1175 | ||
| 1176 | expect(error.reason).toBe("archive_invalid"); | |
| 1177 | expect(requests).toEqual([registryUrl]); | |
| 1178 | }).pipe( | |
| 1179 | Effect.scoped, | |
| 1180 | Effect.provide( | |
| 1181 | layerResolver((request) => { | |
| 1182 | requests.push(request.url); | |
| 1183 | return Effect.succeed( | |
| 1184 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1185 | ); | |
| 1186 | }), | |
| 1187 | ), | |
| 1188 | ); | |
| 1189 | }); | |
| 1190 | ||
| 1191 | it.effect("inspects from disk without waiting for a registry refresh", () => { | |
| 1192 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 1193 | let requests = 0; | |
| 1194 | return Effect.gen(function* () { | |
| 1195 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1196 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1197 | prefix: "t3-acp-registry-cold-inspect-", | |
| 1198 | }); | |
| 1199 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1200 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1201 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1202 | ||
| 1203 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1204 | cacheDir, | |
| 1205 | toolsDir: `${cacheDir}/tools`, | |
| 1206 | registryUrl, | |
| 1207 | }); | |
| 1208 | const inspection = yield* resolver.inspect(settings()); | |
| 1209 | ||
| 1210 | expect(inspection).toMatchObject({ status: "ready", agentId: agent.id }); | |
| 1211 | expect(requests).toBe(0); | |
| 1212 | }).pipe( | |
| 1213 | Effect.scoped, | |
| 1214 | Effect.provide( | |
| 1215 | layerResolver((request) => { | |
| 1216 | requests += 1; | |
| 1217 | return Effect.succeed( | |
| 1218 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1219 | ); | |
| 1220 | }), | |
| 1221 | ), | |
| 1222 | ); | |
| 1223 | }); | |
| 1224 | ||
| 1225 | it.effect("fails a cold inspection immediately when no local registry is available", () => { | |
| 1226 | let requests = 0; | |
| 1227 | return Effect.gen(function* () { | |
| 1228 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1229 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1230 | prefix: "t3-acp-registry-empty-inspect-", | |
| 1231 | }); | |
| 1232 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1233 | cacheDir, | |
| 1234 | toolsDir: `${cacheDir}/tools`, | |
| 1235 | registryUrl, | |
| 1236 | }); | |
| 1237 | const error = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1238 | ||
| 1239 | expect(error.reason).toBe("registry_unavailable"); | |
| 1240 | expect(requests).toBe(0); | |
| 1241 | }).pipe( | |
| 1242 | Effect.scoped, | |
| 1243 | Effect.provide( | |
| 1244 | layerResolver((request) => { | |
| 1245 | requests += 1; | |
| 1246 | return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))); | |
| 1247 | }), | |
| 1248 | ), | |
| 1249 | ); | |
| 1250 | }); | |
| 1251 | ||
| 1252 | it.effect("uses the effective provider environment for inspection and resolution", () => { | |
| 1253 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 1254 | return Effect.gen(function* () { | |
| 1255 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1256 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1257 | prefix: "t3-acp-registry-effective-env-", | |
| 1258 | }); | |
| 1259 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1260 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1261 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1262 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1263 | ||
| 1264 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1265 | cacheDir, | |
| 1266 | toolsDir: `${cacheDir}/tools`, | |
| 1267 | registryUrl, | |
| 1268 | }); | |
| 1269 | const hostInspection = yield* resolver.inspect(settings()); | |
| 1270 | const providerEnvironment = toolchain.environment; | |
| 1271 | const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment); | |
| 1272 | const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment); | |
| 1273 | ||
| 1274 | expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" }); | |
| 1275 | expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" }); | |
| 1276 | expect(resolved.spawn).toMatchObject({ | |
| 1277 | command: toolchain.executablePath, | |
| 1278 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 1279 | }); | |
| 1280 | }).pipe( | |
| 1281 | Effect.scoped, | |
| 1282 | Effect.provide( | |
| 1283 | layerResolver( | |
| 1284 | (request) => | |
| 1285 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 1286 | { PATH: "" }, | |
| 1287 | ), | |
| 1288 | ), | |
| 1289 | ); | |
| 1290 | }); | |
| 1291 | ||
| 1292 | it.effect("requires a regular executable file in the managed binary cache", () => { | |
| 1293 | const agent = makeAgent({ | |
| 1294 | binary: { | |
| 1295 | "linux-x86_64": { | |
| 1296 | archive: archiveUrl, | |
| 1297 | cmd: "bin/example-agent", | |
| 1298 | }, | |
| 1299 | }, | |
| 1300 | }); | |
| 1301 | return Effect.gen(function* () { | |
| 1302 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1303 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1304 | prefix: "t3-acp-registry-non-file-", | |
| 1305 | }); | |
| 1306 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1307 | const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`; | |
| 1308 | yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true }); | |
| 1309 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1310 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1311 | ||
| 1312 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1313 | cacheDir, | |
| 1314 | toolsDir: `${cacheDir}/tools`, | |
| 1315 | registryUrl, | |
| 1316 | }); | |
| 1317 | const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1318 | ||
| 1319 | expect(directoryError.reason).toBe("archive_invalid"); | |
| 1320 | ||
| 1321 | yield* fileSystem.remove(fakeExecutable, { recursive: true }); | |
| 1322 | yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n"); | |
| 1323 | yield* fileSystem.chmod(fakeExecutable, 0o644); | |
| 1324 | const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1325 | ||
| 1326 | expect(modeError.reason).toBe("archive_invalid"); | |
| 1327 | ||
| 1328 | yield* fileSystem.chmod(fakeExecutable, 0o755); | |
| 1329 | expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); | |
| 1330 | }).pipe( | |
| 1331 | Effect.scoped, | |
| 1332 | Effect.provide( | |
| 1333 | layerResolver((request) => | |
| 1334 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 1335 | ), | |
| 1336 | ), | |
| 1337 | ); | |
| 1338 | }); | |
| 1339 | ||
| 1340 | it.effect.each([ | |
| 1341 | { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, | |
| 1342 | { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, | |
| 1343 | { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, | |
| 1344 | { name: "local command", source: "local", agentId: "example-agent", referenced: false }, | |
| 1345 | ] as const)( | |
| 1346 | "checks $name references before uninstalling only managed binaries", | |
| 1347 | ({ source, agentId, referenced }) => { | |
| 1348 | return Effect.gen(function* () { | |
| 1349 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1350 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1351 | prefix: "t3-acp-registry-uninstall-", | |
| 1352 | }); | |
| 1353 | const agentRoot = `${cacheDir}/tools/example-agent`; | |
| 1354 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | |
| 1355 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); | |
| 1356 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); | |
| 1357 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { | |
| 1358 | recursive: true, | |
| 1359 | }); | |
| 1360 | yield* fileSystem.writeFileString(runnerCache, "{}"); | |
| 1361 | ||
| 1362 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1363 | cacheDir, | |
| 1364 | toolsDir: `${cacheDir}/tools`, | |
| 1365 | registryUrl, | |
| 1366 | }); | |
| 1367 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 1368 | const instanceId = ProviderInstanceId.make("uninstall-reference"); | |
| 1369 | yield* serverSettings.updateProviderInstance({ | |
| 1370 | operation: "upsert", | |
| 1371 | instanceId, | |
| 1372 | instance: { | |
| 1373 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1374 | displayName: "Uninstall reference", | |
| 1375 | enabled: true, | |
| 1376 | config: { | |
| 1377 | agentId, | |
| 1378 | commandPath: "/local/agent", | |
| 1379 | ...(source ? { source } : {}), | |
| 1380 | }, | |
| 1381 | }, | |
| 1382 | }); | |
| 1383 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1384 | expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); | |
| 1385 | expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); | |
| 1386 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | |
| 1387 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1388 | ||
| 1389 | expect(second).toEqual({ agentId: "example-agent", removed: referenced }); | |
| 1390 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1391 | agentId: "example-agent", | |
| 1392 | removed: false, | |
| 1393 | }); | |
| 1394 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | |
| 1395 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | |
| 1396 | }).pipe( | |
| 1397 | Effect.scoped, | |
| 1398 | Effect.provide( | |
| 1399 | layerResolver((request) => | |
| 1400 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | |
| 1401 | ), | |
| 1402 | ), | |
| 1403 | ); | |
| 1404 | }, | |
| 1405 | ); | |
| 1406 | ||
| 1407 | it.effect("keeps managed package installs when removing the same agent's binaries", () => | |
| 1408 | Effect.gen(function* () { | |
| 1409 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1410 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1411 | prefix: "t3-acp-uninstall-packages-", | |
| 1412 | }); | |
| 1413 | const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`; | |
| 1414 | yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true }); | |
| 1415 | yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary"); | |
| 1416 | yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true }); | |
| 1417 | yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command"); | |
| 1418 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1419 | cacheDir, | |
| 1420 | toolsDir: `${cacheDir}/tools`, | |
| 1421 | registryUrl, | |
| 1422 | }); | |
| 1423 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1424 | agentId: "example-agent", | |
| 1425 | removed: true, | |
| 1426 | }); | |
| 1427 | expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false); | |
| 1428 | expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe( | |
| 1429 | "package command", | |
| 1430 | ); | |
| 1431 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1432 | agentId: "example-agent", | |
| 1433 | removed: false, | |
| 1434 | }); | |
| 1435 | }).pipe( | |
| 1436 | Effect.scoped, | |
| 1437 | Effect.provide(layerResolver(() => Effect.die("unexpected HTTP request"))), | |
| 1438 | ), | |
| 1439 | ); | |
| 1440 | ||
| 1441 | it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => { | |
| 1442 | const agent = makeAgent({ | |
| 1443 | binary: { | |
| 1444 | "linux-x86_64": { | |
| 1445 | archive: archiveUrl, | |
| 1446 | cmd: "./bin/example-agent", | |
| 1447 | }, | |
| 1448 | }, | |
| 1449 | }); | |
| 1450 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1451 | return Effect.gen(function* () { | |
| 1452 | const downloadStarted = yield* Deferred.make<void>(); | |
| 1453 | const releaseDownload = yield* Deferred.make<void>(); | |
| 1454 | return yield* Effect.gen(function* () { | |
| 1455 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1456 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1457 | prefix: "t3-acp-registry-uninstall-race-", | |
| 1458 | }); | |
| 1459 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1460 | cacheDir, | |
| 1461 | toolsDir: `${cacheDir}/tools`, | |
| 1462 | registryUrl, | |
| 1463 | }); | |
| 1464 | ||
| 1465 | const prepareFiber = yield* resolver | |
| 1466 | .prepare({ agentId: agent.id }) | |
| 1467 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1468 | yield* Deferred.await(downloadStarted); | |
| 1469 | const uninstallFiber = yield* resolver | |
| 1470 | .uninstallManagedBinary({ agentId: agent.id }) | |
| 1471 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1472 | ||
| 1473 | expect(uninstallFiber.pollUnsafe()).toBeUndefined(); | |
| 1474 | yield* Deferred.succeed(releaseDownload, undefined); | |
| 1475 | ||
| 1476 | expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); | |
| 1477 | expect(yield* Fiber.join(uninstallFiber)).toEqual({ | |
| 1478 | agentId: agent.id, | |
| 1479 | removed: false, | |
| 1480 | }); | |
| 1481 | const agentRoot = `${cacheDir}/tools/${agent.id}`; | |
| 1482 | expect(yield* fileSystem.exists(agentRoot)).toBe(true); | |
| 1483 | ||
| 1484 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 1485 | const instanceId = ProviderInstanceId.make("registry-reference"); | |
| 1486 | yield* serverSettings.updateProviderInstance({ | |
| 1487 | operation: "upsert", | |
| 1488 | instanceId, | |
| 1489 | instance: { | |
| 1490 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1491 | displayName: "Registry reference", | |
| 1492 | enabled: true, | |
| 1493 | config: { agentId: agent.id }, | |
| 1494 | }, | |
| 1495 | }); | |
| 1496 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1497 | agentId: agent.id, | |
| 1498 | removed: false, | |
| 1499 | }); | |
| 1500 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | |
| 1501 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1502 | agentId: agent.id, | |
| 1503 | removed: true, | |
| 1504 | }); | |
| 1505 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1506 | agentId: agent.id, | |
| 1507 | removed: false, | |
| 1508 | }); | |
| 1509 | }).pipe( | |
| 1510 | Effect.scoped, | |
| 1511 | Effect.provide( | |
| 1512 | layerResolver((request) => { | |
| 1513 | if (request.url === registryUrl) { | |
| 1514 | return Effect.succeed( | |
| 1515 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1516 | ); | |
| 1517 | } | |
| 1518 | return Deferred.succeed(downloadStarted, undefined).pipe( | |
| 1519 | Effect.andThen(Deferred.await(releaseDownload)), | |
| 1520 | Effect.as( | |
| 1521 | HttpClientResponse.fromWeb( | |
| 1522 | request, | |
| 1523 | new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1524 | ), | |
| 1525 | ), | |
| 1526 | ); | |
| 1527 | }), | |
| 1528 | ), | |
| 1529 | ); | |
| 1530 | }); | |
| 1531 | }); | |
| 1532 | ||
| 1533 | it.effect("reserves a prepared binary until a configured instance inspects it", () => { | |
| 1534 | const agent = makeAgent({ | |
| 1535 | binary: { | |
| 1536 | "linux-x86_64": { | |
| 1537 | archive: archiveUrl, | |
| 1538 | cmd: "./bin/example-agent", | |
| 1539 | }, | |
| 1540 | }, | |
| 1541 | }); | |
| 1542 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1543 | return Effect.gen(function* () { | |
| 1544 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1545 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1546 | prefix: "t3-acp-registry-uninstall-reservation-", | |
| 1547 | }); | |
| 1548 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1549 | cacheDir, | |
| 1550 | toolsDir: `${cacheDir}/tools`, | |
| 1551 | registryUrl, | |
| 1552 | }); | |
| 1553 | ||
| 1554 | yield* resolver.prepare({ agentId: agent.id }); | |
| 1555 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1556 | agentId: agent.id, | |
| 1557 | removed: false, | |
| 1558 | }); | |
| 1559 | expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); | |
| 1560 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1561 | agentId: agent.id, | |
| 1562 | removed: true, | |
| 1563 | }); | |
| 1564 | }).pipe( | |
| 1565 | Effect.scoped, | |
| 1566 | Effect.provide( | |
| 1567 | layerResolver((request) => | |
| 1568 | Effect.succeed( | |
| 1569 | HttpClientResponse.fromWeb( | |
| 1570 | request, | |
| 1571 | request.url === registryUrl | |
| 1572 | ? new Response(makeRegistry(agent)) | |
| 1573 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1574 | ), | |
| 1575 | ), | |
| 1576 | ), | |
| 1577 | ), | |
| 1578 | ); | |
| 1579 | }); | |
| 1580 | ||
| 1581 | it.effect("expires an abandoned prepared-binary reservation", () => { | |
| 1582 | const agent = makeAgent({ | |
| 1583 | binary: { | |
| 1584 | "linux-x86_64": { | |
| 1585 | archive: archiveUrl, | |
| 1586 | cmd: "./bin/example-agent", | |
| 1587 | }, | |
| 1588 | }, | |
| 1589 | }); | |
| 1590 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1591 | return Effect.gen(function* () { | |
| 1592 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1593 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1594 | prefix: "t3-acp-registry-uninstall-expiry-", | |
| 1595 | }); | |
| 1596 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1597 | cacheDir, | |
| 1598 | toolsDir: `${cacheDir}/tools`, | |
| 1599 | registryUrl, | |
| 1600 | }); | |
| 1601 | ||
| 1602 | yield* resolver.prepare({ agentId: agent.id }); | |
| 1603 | yield* TestClock.adjust("31 seconds"); | |
| 1604 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1605 | agentId: agent.id, | |
| 1606 | removed: true, | |
| 1607 | }); | |
| 1608 | }).pipe( | |
| 1609 | Effect.scoped, | |
| 1610 | Effect.provide( | |
| 1611 | layerResolver((request) => | |
| 1612 | Effect.succeed( | |
| 1613 | HttpClientResponse.fromWeb( | |
| 1614 | request, | |
| 1615 | request.url === registryUrl | |
| 1616 | ? new Response(makeRegistry(agent)) | |
| 1617 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1618 | ), | |
| 1619 | ), | |
| 1620 | ), | |
| 1621 | ), | |
| 1622 | ); | |
| 1623 | }); | |
| 1624 | }); | |
| 1625 | ||
| 1626 | describe("acpRegistryManagedBinaryDirectories", () => { | |
| 1627 | it.effect("lists managed package and binary command directories", () => | |
| 1628 | Effect.gen(function* () { | |
| 1629 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1630 | const path = yield* Path.Path; | |
| 1631 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1632 | prefix: "t3-acp-registry-bins-", | |
| 1633 | }); | |
| 1634 | const install = (agent: string, version: string, target: string, commandDirectory = "") => | |
| 1635 | fileSystem.makeDirectory( | |
| 1636 | path.join(cacheDir, "tools", agent, version, target, commandDirectory), | |
| 1637 | { recursive: true }, | |
| 1638 | ); | |
| 1639 | yield* install("kimi", "1.49.0", "linux-x86_64", "bin"); | |
| 1640 | yield* install("kimi", "1.50.0", "linux-x86_64", "cmd"); | |
| 1641 | yield* install("kimi", "1.50.0", "darwin-aarch64"); | |
| 1642 | yield* install("other-agent", "0.2.0", "darwin-aarch64"); | |
| 1643 | const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin"); | |
| 1644 | const geminiCommand = path.join(globalBin, "gemini"); | |
| 1645 | const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs"); | |
| 1646 | yield* fileSystem.makeDirectory(globalBin, { recursive: true }); | |
| 1647 | yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true }); | |
| 1648 | yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n"); | |
| 1649 | yield* fileSystem.chmod(geminiCommand, 0o755); | |
| 1650 | yield* fileSystem.writeFileString( | |
| 1651 | path.join(receiptsDirectory, "gemini.json"), | |
| 1652 | encodeUnknownJson({ | |
| 1653 | agentId: "gemini", | |
| 1654 | agentVersion: "0.56.0", | |
| 1655 | distribution: "npx", | |
| 1656 | packageSpec: "@google/gemini-cli@0.56.0", | |
| 1657 | managerPath: "/usr/bin/npm", | |
| 1658 | binDirectory: globalBin, | |
| 1659 | executablePath: geminiCommand, | |
| 1660 | packageRoot: path.join( | |
| 1661 | cacheDir, | |
| 1662 | "tools", | |
| 1663 | "gemini", | |
| 1664 | "0.56.0", | |
| 1665 | "npm", | |
| 1666 | "lib", | |
| 1667 | "node_modules", | |
| 1668 | "@google", | |
| 1669 | "gemini-cli", | |
| 1670 | ), | |
| 1671 | packageVersion: "0.56.0", | |
| 1672 | }), | |
| 1673 | ); | |
| 1674 | yield* fileSystem.writeFileString( | |
| 1675 | path.join(cacheDir, "acp-registry", "registry.json"), | |
| 1676 | encodeUnknownJson({ | |
| 1677 | version: "1.0.0", | |
| 1678 | agents: [ | |
| 1679 | { | |
| 1680 | ...makeAgent({ | |
| 1681 | binary: { | |
| 1682 | "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" }, | |
| 1683 | }, | |
| 1684 | }), | |
| 1685 | id: "kimi", | |
| 1686 | name: "Kimi", | |
| 1687 | version: "1.49.0", | |
| 1688 | }, | |
| 1689 | { | |
| 1690 | ...makeAgent({ | |
| 1691 | binary: { | |
| 1692 | "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" }, | |
| 1693 | }, | |
| 1694 | }), | |
| 1695 | id: "kimi", | |
| 1696 | name: "Kimi", | |
| 1697 | version: "1.50.0", | |
| 1698 | }, | |
| 1699 | ], | |
| 1700 | }), | |
| 1701 | ); | |
| 1702 | ||
| 1703 | const directories = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({ | |
| 1704 | fileSystem, | |
| 1705 | path, | |
| 1706 | cacheDir, | |
| 1707 | toolsDir: path.join(cacheDir, "tools"), | |
| 1708 | platform: "linux", | |
| 1709 | architecture: "x64", | |
| 1710 | }); | |
| 1711 | expect(directories).toEqual([ | |
| 1712 | globalBin, | |
| 1713 | path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"), | |
| 1714 | path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"), | |
| 1715 | ]); | |
| 1716 | ||
| 1717 | const missing = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({ | |
| 1718 | fileSystem, | |
| 1719 | path, | |
| 1720 | cacheDir: path.join(cacheDir, "does-not-exist"), | |
| 1721 | toolsDir: path.join(cacheDir, "does-not-exist", "tools"), | |
| 1722 | platform: "linux", | |
| 1723 | architecture: "x64", | |
| 1724 | }); | |
| 1725 | expect(missing).toEqual([]); | |
| 1726 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 1727 | ); | |
| 1728 | }); |
apps/server/src/provider/acp/AcpRegistrySupport.ts deleted-1943| ... | ... | @@ -1,1943 +0,0 @@ |
| 1 | import { | |
| 2 | AcpRegistryOperationError, | |
| 3 | AcpRegistryOperationErrorReason, | |
| 4 | TrimmedNonEmptyString, | |
| 5 | type AcpRegistryManagedBinaryUninstallInput, | |
| 6 | type AcpRegistryManagedBinaryUninstallResult, | |
| 7 | type AcpRegistryPrepareInput, | |
| 8 | type AcpRegistryPrepareResult, | |
| 9 | type AcpRegistrySearchInput, | |
| 10 | type AcpRegistrySearchResult, | |
| 11 | type AcpRegistryDistribution as AcpRegistryDistributionKind, | |
| 12 | type AcpRegistryDistributionPreference, | |
| 13 | type AcpRegistrySettings, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { | |
| 16 | HostProcessArchitecture, | |
| 17 | HostProcessEnvironment, | |
| 18 | HostProcessPlatform, | |
| 19 | } from "@t3tools/shared/hostProcess"; | |
| 20 | import { | |
| 21 | mergePathEntries, | |
| 22 | resolveSpawnCommand, | |
| 23 | SpawnExecutableResolution, | |
| 24 | } from "@t3tools/shared/shell"; | |
| 25 | import * as Clock from "effect/Clock"; | |
| 26 | import * as Context from "effect/Context"; | |
| 27 | import * as Duration from "effect/Duration"; | |
| 28 | import * as Crypto from "effect/Crypto"; | |
| 29 | import * as Effect from "effect/Effect"; | |
| 30 | import * as Hex from "effect/encoding/Hex"; | |
| 31 | import * as FileSystem from "effect/FileSystem"; | |
| 32 | import * as Layer from "effect/Layer"; | |
| 33 | import * as Option from "effect/Option"; | |
| 34 | import * as Path from "effect/Path"; | |
| 35 | import * as PlatformError from "effect/PlatformError"; | |
| 36 | import * as Ref from "effect/Ref"; | |
| 37 | import * as Schema from "effect/Schema"; | |
| 38 | import * as Semaphore from "effect/Semaphore"; | |
| 39 | import * as Stream from "effect/Stream"; | |
| 40 | import { sha256 } from "@noble/hashes/sha2"; | |
| 41 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 42 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 43 | ||
| 44 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 45 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 46 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 47 | ||
| 48 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; | |
| 49 | ||
| 50 | const MAX_REGISTRY_BYTES = 1024 * 1024; | |
| 51 | const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; | |
| 52 | const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024; | |
| 53 | const MAX_SEARCH_RESULTS = 20; | |
| 54 | const REGISTRY_REQUEST_TIMEOUT = "30 seconds"; | |
| 55 | const ARCHIVE_REQUEST_TIMEOUT = "20 minutes"; | |
| 56 | const PACKAGE_INSTALL_TIMEOUT = "20 minutes"; | |
| 57 | const PACKAGE_QUERY_TIMEOUT = "30 seconds"; | |
| 58 | ||
| 59 | const BoundedAgentId = Schema.String.check( | |
| 60 | Schema.isMaxLength(128), | |
| 61 | Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u), | |
| 62 | ); | |
| 63 | const BoundedName = TrimmedNonEmptyString.check(Schema.isMaxLength(160)); | |
| 64 | // Versions become install directory names, so "." and ".." must not decode. | |
| 65 | const BoundedVersion = TrimmedNonEmptyString.check( | |
| 66 | Schema.isMaxLength(128), | |
| 67 | Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u), | |
| 68 | ); | |
| 69 | const BoundedDescription = Schema.String.check(Schema.isMaxLength(1_024)); | |
| 70 | const BoundedMetadata = Schema.String.check(Schema.isMaxLength(256)); | |
| 71 | const BoundedArgument = Schema.String.check(Schema.isMaxLength(1_024)); | |
| 72 | const PackageCommandName = Schema.String.check( | |
| 73 | Schema.isMaxLength(128), | |
| 74 | Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/iu), | |
| 75 | ); | |
| 76 | const isPackageCommandName = Schema.is(PackageCommandName); | |
| 77 | const EXACT_RUNNER_VERSION = "v?[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?"; | |
| 78 | const EXACT_NPX_PACKAGE = new RegExp( | |
| 79 | `^(?:@[^/@\\s]+/[^@\\s]+|[a-z0-9][a-z0-9._-]*)@${EXACT_RUNNER_VERSION}$`, | |
| 80 | "iu", | |
| 81 | ); | |
| 82 | const EXACT_UVX_PACKAGE = new RegExp(`^[a-z0-9][a-z0-9._-]*(?:@|==)${EXACT_RUNNER_VERSION}$`, "iu"); | |
| 83 | const NpxPackage = Schema.String.check( | |
| 84 | Schema.isMaxLength(256), | |
| 85 | Schema.makeFilter( | |
| 86 | (value) => | |
| 87 | EXACT_NPX_PACKAGE.test(value) || "ACP Registry npx packages must include an exact version.", | |
| 88 | ), | |
| 89 | ); | |
| 90 | const UvxPackage = Schema.String.check( | |
| 91 | Schema.isMaxLength(256), | |
| 92 | Schema.makeFilter( | |
| 93 | (value) => | |
| 94 | EXACT_UVX_PACKAGE.test(value) || "ACP Registry uvx packages must include an exact version.", | |
| 95 | ), | |
| 96 | ); | |
| 97 | const HttpsUrl = Schema.String.check( | |
| 98 | Schema.isMaxLength(2_048), | |
| 99 | Schema.makeFilter((value) => { | |
| 100 | try { | |
| 101 | const url = new URL(value); | |
| 102 | return ( | |
| 103 | (url.protocol === "https:" && url.username.length === 0 && url.password.length === 0) || | |
| 104 | "ACP Registry URLs must use HTTPS without embedded credentials." | |
| 105 | ); | |
| 106 | } catch { | |
| 107 | return "ACP Registry URLs must be valid absolute URLs."; | |
| 108 | } | |
| 109 | }), | |
| 110 | ); | |
| 111 | ||
| 112 | const AcpRegistryPackageDistributionFields = { | |
| 113 | args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), | |
| 114 | env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), | |
| 115 | } as const; | |
| 116 | const AcpRegistryNpxDistribution = Schema.Struct({ | |
| 117 | package: NpxPackage, | |
| 118 | ...AcpRegistryPackageDistributionFields, | |
| 119 | }); | |
| 120 | const AcpRegistryUvxDistribution = Schema.Struct({ | |
| 121 | package: UvxPackage, | |
| 122 | ...AcpRegistryPackageDistributionFields, | |
| 123 | }); | |
| 124 | ||
| 125 | const AcpRegistryBinaryTarget = Schema.Struct({ | |
| 126 | archive: HttpsUrl, | |
| 127 | cmd: BoundedArgument, | |
| 128 | sha256: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/iu))), | |
| 129 | args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), | |
| 130 | env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), | |
| 131 | }); | |
| 132 | ||
| 133 | const AcpRegistryAgent = Schema.Struct({ | |
| 134 | id: BoundedAgentId, | |
| 135 | name: BoundedName, | |
| 136 | version: BoundedVersion, | |
| 137 | description: BoundedDescription, | |
| 138 | authors: Schema.optionalKey(Schema.Array(BoundedMetadata).check(Schema.isMaxLength(16))), | |
| 139 | license: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), | |
| 140 | website: Schema.optionalKey(HttpsUrl), | |
| 141 | repository: Schema.optionalKey(HttpsUrl), | |
| 142 | icon: Schema.optionalKey(HttpsUrl), | |
| 143 | distribution: Schema.Struct({ | |
| 144 | binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)), | |
| 145 | npx: Schema.optionalKey(AcpRegistryNpxDistribution), | |
| 146 | uvx: Schema.optionalKey(AcpRegistryUvxDistribution), | |
| 147 | }), | |
| 148 | }); | |
| 149 | export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; | |
| 150 | ||
| 151 | const NpmPackageManifest = Schema.Struct({ | |
| 152 | name: Schema.String, | |
| 153 | version: Schema.String, | |
| 154 | bin: Schema.Union([BoundedArgument, Schema.Record(PackageCommandName, BoundedArgument)]), | |
| 155 | }); | |
| 156 | ||
| 157 | const AcpRegistryPackageInstallReceipt = Schema.Struct({ | |
| 158 | agentId: BoundedAgentId, | |
| 159 | agentVersion: BoundedVersion, | |
| 160 | distribution: Schema.Literals(["npx", "uvx"]), | |
| 161 | packageSpec: Schema.String, | |
| 162 | managerPath: Schema.String, | |
| 163 | binDirectory: Schema.String, | |
| 164 | executablePath: Schema.String, | |
| 165 | packageRoot: Schema.optional(Schema.String), | |
| 166 | packageVersion: Schema.optional(Schema.String), | |
| 167 | }); | |
| 168 | type AcpRegistryPackageInstallReceipt = typeof AcpRegistryPackageInstallReceipt.Type; | |
| 169 | ||
| 170 | const AcpRegistryIndexEnvelope = Schema.Struct({ | |
| 171 | version: BoundedVersion, | |
| 172 | agents: Schema.Array(Schema.Unknown).check(Schema.isMaxLength(512)), | |
| 173 | }); | |
| 174 | export interface AcpRegistryIndex { | |
| 175 | readonly version: string; | |
| 176 | readonly agents: ReadonlyArray<AcpRegistryAgent>; | |
| 177 | } | |
| 178 | ||
| 179 | const decodeRegistryIndexEnvelope = Schema.decodeUnknownEffect(AcpRegistryIndexEnvelope); | |
| 180 | const decodeRegistryAgent = Schema.decodeUnknownOption(AcpRegistryAgent); | |
| 181 | const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 182 | const decodeNpmPackageManifest = Schema.decodeUnknownEffect( | |
| 183 | Schema.fromJsonString(NpmPackageManifest), | |
| 184 | ); | |
| 185 | const decodePackageInstallReceipt = Schema.decodeUnknownOption( | |
| 186 | Schema.fromJsonString(AcpRegistryPackageInstallReceipt), | |
| 187 | ); | |
| 188 | const encodePackageInstallReceipt = Schema.encodeSync( | |
| 189 | Schema.fromJsonString(AcpRegistryPackageInstallReceipt), | |
| 190 | ); | |
| 191 | const decodeHttpsUrl = Schema.decodeUnknownEffect(HttpsUrl); | |
| 192 | const decodeBoundedAgentId = Schema.decodeUnknownEffect(BoundedAgentId); | |
| 193 | ||
| 194 | export const AcpRegistryErrorReason = AcpRegistryOperationErrorReason; | |
| 195 | export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type; | |
| 196 | ||
| 197 | export class AcpRegistryError extends Schema.TaggedError<AcpRegistryError>()("AcpRegistryError", { | |
| 198 | reason: AcpRegistryErrorReason, | |
| 199 | detail: Schema.String, | |
| 200 | cause: Schema.optional(Schema.Defect()), | |
| 201 | }) { | |
| 202 | override get message(): string { | |
| 203 | return this.detail; | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistryOperationError { | |
| 208 | return new AcpRegistryOperationError({ | |
| 209 | reason: error.reason, | |
| 210 | message: error.detail, | |
| 211 | cause: error, | |
| 212 | }); | |
| 213 | } | |
| 214 | ||
| 215 | const isAcpRegistryError = Schema.is(AcpRegistryError); | |
| 216 | ||
| 217 | export type AcpRegistryPlatformTarget = | |
| 218 | | "darwin-aarch64" | |
| 219 | | "darwin-x86_64" | |
| 220 | | "linux-aarch64" | |
| 221 | | "linux-x86_64" | |
| 222 | | "windows-aarch64" | |
| 223 | | "windows-x86_64"; | |
| 224 | ||
| 225 | export function resolveAcpRegistryPlatformTarget( | |
| 226 | platform: NodeJS.Platform, | |
| 227 | architecture: NodeJS.Architecture, | |
| 228 | ): AcpRegistryPlatformTarget | undefined { | |
| 229 | const os = | |
| 230 | platform === "darwin" | |
| 231 | ? "darwin" | |
| 232 | : platform === "linux" | |
| 233 | ? "linux" | |
| 234 | : platform === "win32" | |
| 235 | ? "windows" | |
| 236 | : undefined; | |
| 237 | const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined; | |
| 238 | return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined; | |
| 239 | } | |
| 240 | ||
| 241 | interface ExactPackageSpec { | |
| 242 | readonly name: string; | |
| 243 | readonly version: string; | |
| 244 | } | |
| 245 | ||
| 246 | function parseNpxPackageSpec(packageSpec: string): ExactPackageSpec { | |
| 247 | const separator = packageSpec.lastIndexOf("@"); | |
| 248 | return { | |
| 249 | name: packageSpec.slice(0, separator), | |
| 250 | version: packageSpec.slice(separator + 1).replace(/^v/iu, ""), | |
| 251 | }; | |
| 252 | } | |
| 253 | ||
| 254 | function parseUvxPackageSpec(packageSpec: string): ExactPackageSpec { | |
| 255 | const equalsSeparator = packageSpec.lastIndexOf("=="); | |
| 256 | const separator = equalsSeparator >= 0 ? equalsSeparator : packageSpec.lastIndexOf("@"); | |
| 257 | const separatorLength = equalsSeparator >= 0 ? 2 : 1; | |
| 258 | return { | |
| 259 | name: packageSpec.slice(0, separator), | |
| 260 | version: packageSpec.slice(separator + separatorLength).replace(/^v/iu, ""), | |
| 261 | }; | |
| 262 | } | |
| 263 | ||
| 264 | function packageManagerFor(distribution: AcpRegistryDistributionKind): "npm" | "uv" | undefined { | |
| 265 | return distribution === "npx" ? "npm" : distribution === "uvx" ? "uv" : undefined; | |
| 266 | } | |
| 267 | ||
| 268 | function packageCommandCandidates( | |
| 269 | agent: AcpRegistryAgent, | |
| 270 | packageName: string, | |
| 271 | ): ReadonlyArray<string> { | |
| 272 | const unscopedPackageName = packageName.split("/").pop() ?? packageName; | |
| 273 | return Array.from( | |
| 274 | new Set( | |
| 275 | [agent.id, unscopedPackageName, unscopedPackageName.replace(/-acp$/u, "")].filter( | |
| 276 | isPackageCommandName, | |
| 277 | ), | |
| 278 | ), | |
| 279 | ); | |
| 280 | } | |
| 281 | ||
| 282 | function readEnvironmentPath( | |
| 283 | environment: NodeJS.ProcessEnv, | |
| 284 | platform: NodeJS.Platform, | |
| 285 | ): string | undefined { | |
| 286 | if (platform !== "win32") return environment.PATH; | |
| 287 | return ( | |
| 288 | environment.PATH ?? | |
| 289 | Object.entries(environment).find(([key]) => key.toLowerCase() === "path")?.[1] | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | function withPreferredPath( | |
| 294 | environment: NodeJS.ProcessEnv, | |
| 295 | directory: string, | |
| 296 | platform: NodeJS.Platform, | |
| 297 | ): NodeJS.ProcessEnv { | |
| 298 | return { | |
| 299 | ...environment, | |
| 300 | PATH: mergePathEntries(directory, readEnvironmentPath(environment, platform), platform), | |
| 301 | }; | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * Directories exposing installed ACP Registry commands. The integrated | |
| 306 | * terminal appends them to PATH so managed package commands and | |
| 307 | * managed binary agents are both available by name. Best effort: unreadable | |
| 308 | * directories resolve to no entries. | |
| 309 | */ | |
| 310 | export const acpRegistryManagedBinaryDirectories = (input: { | |
| 311 | readonly fileSystem: FileSystem.FileSystem; | |
| 312 | readonly path: Path.Path; | |
| 313 | readonly cacheDir: string; | |
| 314 | readonly toolsDir: string; | |
| 315 | readonly platform: NodeJS.Platform; | |
| 316 | readonly architecture: NodeJS.Architecture; | |
| 317 | }): Effect.Effect<ReadonlyArray<string>> => | |
| 318 | Effect.gen(function* () { | |
| 319 | const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture); | |
| 320 | const registryDirectory = input.path.join(input.cacheDir, "acp-registry"); | |
| 321 | const installsDirectory = input.toolsDir; | |
| 322 | const packageReceiptsDirectory = input.path.join(registryDirectory, "package-installs"); | |
| 323 | const listDirectories = (directory: string) => | |
| 324 | input.fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): Array<string> => [])); | |
| 325 | const directories: Array<string> = []; | |
| 326 | ||
| 327 | for (const receiptFile of (yield* listDirectories(packageReceiptsDirectory)).toSorted()) { | |
| 328 | const receipt = yield* input.fileSystem | |
| 329 | .readFileString(input.path.join(packageReceiptsDirectory, receiptFile)) | |
| 330 | .pipe( | |
| 331 | Effect.map(decodePackageInstallReceipt), | |
| 332 | Effect.orElseSucceed(() => Option.none()), | |
| 333 | ); | |
| 334 | if ( | |
| 335 | Option.isSome(receipt) && | |
| 336 | receipt.value.binDirectory === | |
| 337 | input.path.join( | |
| 338 | installsDirectory, | |
| 339 | receipt.value.agentId, | |
| 340 | encodeURIComponent(receipt.value.agentVersion), | |
| 341 | receipt.value.distribution === "npx" ? "npm" : "python", | |
| 342 | ...(receipt.value.distribution === "npx" && input.platform === "win32" ? [] : ["bin"]), | |
| 343 | ) && | |
| 344 | input.path.dirname(receipt.value.executablePath) === receipt.value.binDirectory && | |
| 345 | (yield* input.fileSystem | |
| 346 | .exists(receipt.value.executablePath) | |
| 347 | .pipe(Effect.orElseSucceed(() => false))) | |
| 348 | ) { | |
| 349 | directories.push(receipt.value.binDirectory); | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | if (target === undefined) return Array.from(new Set(directories)); | |
| 354 | const cachedAgents = yield* input.fileSystem | |
| 355 | .readFileString(input.path.join(registryDirectory, "registry.json")) | |
| 356 | .pipe( | |
| 357 | Effect.flatMap(decodeJson), | |
| 358 | Effect.flatMap(decodeRegistryIndexEnvelope), | |
| 359 | Effect.map((envelope) => | |
| 360 | envelope.agents.flatMap((candidate) => { | |
| 361 | const decoded = decodeRegistryAgent(candidate); | |
| 362 | return Option.isSome(decoded) ? [decoded.value] : []; | |
| 363 | }), | |
| 364 | ), | |
| 365 | Effect.orElseSucceed((): ReadonlyArray<AcpRegistryAgent> => []), | |
| 366 | ); | |
| 367 | const cachedAgentByInstall = new Map( | |
| 368 | // Install directories are named by the URI-encoded version. | |
| 369 | cachedAgents.map( | |
| 370 | (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const, | |
| 371 | ), | |
| 372 | ); | |
| 373 | for (const agent of (yield* listDirectories(installsDirectory)).toSorted()) { | |
| 374 | const versions = (yield* listDirectories(input.path.join(installsDirectory, agent))).toSorted( | |
| 375 | (left, right) => right.localeCompare(left, undefined, { numeric: true }), | |
| 376 | ); | |
| 377 | for (const version of versions) { | |
| 378 | const installRoot = input.path.join(installsDirectory, agent, version, target); | |
| 379 | if (yield* input.fileSystem.exists(installRoot).pipe(Effect.orElseSucceed(() => false))) { | |
| 380 | const binaryTarget = cachedAgentByInstall.get(`${agent}\0${version}`)?.distribution | |
| 381 | .binary?.[target]; | |
| 382 | const commandSegments = | |
| 383 | binaryTarget === undefined ? undefined : normalizeRegistryCommandPath(binaryTarget.cmd); | |
| 384 | const directory = | |
| 385 | commandSegments === undefined | |
| 386 | ? installRoot | |
| 387 | : input.path.join(installRoot, ...commandSegments.slice(0, -1)); | |
| 388 | if (yield* input.fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) { | |
| 389 | directories.push(directory); | |
| 390 | } | |
| 391 | } | |
| 392 | } | |
| 393 | } | |
| 394 | return Array.from(new Set(directories)); | |
| 395 | }); | |
| 396 | ||
| 397 | export interface ResolvedAcpRegistryDistribution { | |
| 398 | readonly kind: AcpRegistryDistributionKind; | |
| 399 | readonly args: ReadonlyArray<string>; | |
| 400 | readonly env: Readonly<Record<string, string>>; | |
| 401 | readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type; | |
| 402 | readonly packageName?: string; | |
| 403 | } | |
| 404 | ||
| 405 | export function resolveAcpRegistryDistribution(input: { | |
| 406 | readonly agent: AcpRegistryAgent; | |
| 407 | readonly preference: AcpRegistryDistributionPreference; | |
| 408 | readonly platformTarget: AcpRegistryPlatformTarget | undefined; | |
| 409 | }): ResolvedAcpRegistryDistribution | undefined { | |
| 410 | const { agent, platformTarget } = input; | |
| 411 | const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined; | |
| 412 | const candidates: ReadonlyArray<AcpRegistryDistributionKind> = | |
| 413 | input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference]; | |
| 414 | ||
| 415 | for (const kind of candidates) { | |
| 416 | if (kind === "binary" && binary) { | |
| 417 | return { | |
| 418 | kind, | |
| 419 | args: binary.args ?? [], | |
| 420 | env: binary.env ?? {}, | |
| 421 | binaryTarget: binary, | |
| 422 | }; | |
| 423 | } | |
| 424 | if (kind === "npx" && agent.distribution.npx) { | |
| 425 | return { | |
| 426 | kind, | |
| 427 | args: agent.distribution.npx.args ?? [], | |
| 428 | env: agent.distribution.npx.env ?? {}, | |
| 429 | packageName: agent.distribution.npx.package, | |
| 430 | }; | |
| 431 | } | |
| 432 | if (kind === "uvx" && agent.distribution.uvx) { | |
| 433 | return { | |
| 434 | kind, | |
| 435 | args: agent.distribution.uvx.args ?? [], | |
| 436 | env: agent.distribution.uvx.env ?? {}, | |
| 437 | packageName: agent.distribution.uvx.package, | |
| 438 | }; | |
| 439 | } | |
| 440 | } | |
| 441 | return undefined; | |
| 442 | } | |
| 443 | ||
| 444 | export interface ResolvedAcpRegistryAgent { | |
| 445 | readonly agent?: AcpRegistryAgent; | |
| 446 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 447 | readonly spawn: AcpSessionRuntime.AcpSpawnInput; | |
| 448 | } | |
| 449 | ||
| 450 | export type AcpRegistryInspection = | |
| 451 | | { readonly status: "unconfigured" } | |
| 452 | | { readonly status: "not_found"; readonly agentId: string } | |
| 453 | | { | |
| 454 | readonly status: "unsupported"; | |
| 455 | readonly agentId: string; | |
| 456 | readonly version: string; | |
| 457 | } | |
| 458 | | { | |
| 459 | readonly status: "missing_runner"; | |
| 460 | readonly agentId: string; | |
| 461 | readonly version: string; | |
| 462 | readonly distribution: AcpRegistryDistributionKind; | |
| 463 | readonly runner: string; | |
| 464 | } | |
| 465 | | { | |
| 466 | readonly status: "missing_runner"; | |
| 467 | readonly version: null; | |
| 468 | readonly distribution: "local"; | |
| 469 | } | |
| 470 | | { | |
| 471 | readonly status: "unprepared"; | |
| 472 | readonly agentId: string; | |
| 473 | readonly version: string; | |
| 474 | readonly distribution: "binary"; | |
| 475 | } | |
| 476 | | { | |
| 477 | readonly status: "ready"; | |
| 478 | readonly agentId: string; | |
| 479 | readonly version: string | null; | |
| 480 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 481 | readonly documentationUrl?: string; | |
| 482 | }; | |
| 483 | ||
| 484 | export class AcpRegistryCatalog extends Context.Service< | |
| 485 | AcpRegistryCatalog, | |
| 486 | { | |
| 487 | readonly search: ( | |
| 488 | input: AcpRegistrySearchInput, | |
| 489 | ) => Effect.Effect<AcpRegistrySearchResult, AcpRegistryError>; | |
| 490 | readonly prepare: ( | |
| 491 | input: AcpRegistryPrepareInput, | |
| 492 | ) => Effect.Effect<AcpRegistryPrepareResult, AcpRegistryError>; | |
| 493 | readonly inspect: ( | |
| 494 | settings: AcpRegistrySettings, | |
| 495 | environment?: NodeJS.ProcessEnv, | |
| 496 | ) => Effect.Effect<AcpRegistryInspection, AcpRegistryError>; | |
| 497 | readonly resolve: ( | |
| 498 | settings: AcpRegistrySettings, | |
| 499 | cwd: string, | |
| 500 | environment?: NodeJS.ProcessEnv, | |
| 501 | ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; | |
| 502 | readonly uninstallManagedBinary: ( | |
| 503 | input: AcpRegistryManagedBinaryUninstallInput, | |
| 504 | ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>; | |
| 505 | } | |
| 506 | >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { | |
| 507 | static layer(options: AcpRegistryCatalogOptions) { | |
| 508 | return Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); | |
| 509 | } | |
| 510 | } | |
| 511 | ||
| 512 | export interface AcpRegistryCatalogOptions { | |
| 513 | readonly cacheDir: string; | |
| 514 | readonly toolsDir: string; | |
| 515 | readonly registryUrl?: string; | |
| 516 | } | |
| 517 | ||
| 518 | const INSTALL_LOCK_RETRY_COUNT = 300; | |
| 519 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | |
| 520 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | |
| 521 | const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000; | |
| 522 | ||
| 523 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | |
| 524 | return error.reason._tag === "AlreadyExists"; | |
| 525 | } | |
| 526 | ||
| 527 | function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined { | |
| 528 | const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); | |
| 529 | const segments = normalized.split("/").filter((segment) => segment.length > 0); | |
| 530 | if ( | |
| 531 | segments.length === 0 || | |
| 532 | normalized.startsWith("/") || | |
| 533 | /^[a-zA-Z]:/u.test(normalized) || | |
| 534 | segments.some((segment) => segment === "." || segment === "..") | |
| 535 | ) { | |
| 536 | return undefined; | |
| 537 | } | |
| 538 | return segments; | |
| 539 | } | |
| 540 | ||
| 541 | function validateArchiveEntries(output: string): boolean { | |
| 542 | return output | |
| 543 | .split(/\r?\n/u) | |
| 544 | .map((entry) => entry.trim()) | |
| 545 | .filter((entry) => entry.length > 0) | |
| 546 | .every((entry) => entry === "./" || normalizeRegistryCommandPath(entry) !== undefined); | |
| 547 | } | |
| 548 | ||
| 549 | type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip"; | |
| 550 | ||
| 551 | function archiveKind(url: string): ArchiveKind { | |
| 552 | const pathname = new URL(url).pathname.toLowerCase(); | |
| 553 | if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz"; | |
| 554 | if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2"; | |
| 555 | if (pathname.endsWith(".zip")) return "zip"; | |
| 556 | return "raw"; | |
| 557 | } | |
| 558 | ||
| 559 | function archiveFileName(kind: ArchiveKind): string { | |
| 560 | switch (kind) { | |
| 561 | case "tar_gz": | |
| 562 | return "agent.tar.gz"; | |
| 563 | case "tar_bz2": | |
| 564 | return "agent.tar.bz2"; | |
| 565 | case "zip": | |
| 566 | return "agent.zip"; | |
| 567 | case "raw": | |
| 568 | return "agent.bin"; | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | function compareText(left: string, right: string): number { | |
| 573 | return left < right ? -1 : left > right ? 1 : 0; | |
| 574 | } | |
| 575 | ||
| 576 | function searchRank(agent: AcpRegistryAgent, query: string): number | undefined { | |
| 577 | const normalized = query.trim().toLowerCase(); | |
| 578 | if (normalized.length === 0) return 100; | |
| 579 | ||
| 580 | const id = agent.id.toLowerCase(); | |
| 581 | const name = agent.name.toLowerCase(); | |
| 582 | const authors = (agent.authors ?? []).join(" ").toLowerCase(); | |
| 583 | const description = agent.description.toLowerCase(); | |
| 584 | const terms = normalized.split(/\s+/u); | |
| 585 | if (id === normalized || name === normalized) return 0; | |
| 586 | if (id.startsWith(normalized) || name.startsWith(normalized)) return 10; | |
| 587 | ||
| 588 | const identityTokens = `${id} ${name}`.split(/[^a-z0-9]+/u).filter(Boolean); | |
| 589 | if (terms.every((term) => identityTokens.some((token) => token.startsWith(term)))) return 20; | |
| 590 | if (terms.every((term) => id.includes(term) || name.includes(term))) return 30; | |
| 591 | if (terms.every((term) => authors.includes(term))) return 40; | |
| 592 | if (terms.every((term) => `${id} ${name} ${authors} ${description}`.includes(term))) return 50; | |
| 593 | return undefined; | |
| 594 | } | |
| 595 | ||
| 596 | export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(function* ( | |
| 597 | input: AcpRegistryCatalogOptions, | |
| 598 | ): Effect.fn.Return< | |
| 599 | AcpRegistryCatalog["Service"], | |
| 600 | never, | |
| 601 | | ChildProcessSpawner.ChildProcessSpawner | |
| 602 | | Crypto.Crypto | |
| 603 | | FileSystem.FileSystem | |
| 604 | | HttpClient.HttpClient | |
| 605 | | Path.Path | |
| 606 | | ServerSettings.ServerSettingsService | |
| 607 | > { | |
| 608 | const crypto = yield* Crypto.Crypto; | |
| 609 | const sha256Hex = (data: Uint8Array) => | |
| 610 | crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie); | |
| 611 | const fileSystem = yield* FileSystem.FileSystem; | |
| 612 | const path = yield* Path.Path; | |
| 613 | const httpClient = yield* HttpClient.HttpClient; | |
| 614 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 615 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 616 | const platform = yield* HostProcessPlatform; | |
| 617 | const architecture = yield* HostProcessArchitecture; | |
| 618 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 619 | const resolveExecutable = yield* SpawnExecutableResolution; | |
| 620 | const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture); | |
| 621 | const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL; | |
| 622 | const registryDirectory = path.join(input.cacheDir, "acp-registry"); | |
| 623 | const registryCachePath = path.join(registryDirectory, "registry.json"); | |
| 624 | const installsDirectory = input.toolsDir; | |
| 625 | const agentInstallRoot = (agent: AcpRegistryAgent) => | |
| 626 | path.join(installsDirectory, agent.id, encodeURIComponent(agent.version)); | |
| 627 | const packageInstallRoot = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => | |
| 628 | path.join(agentInstallRoot(agent), distribution === "npx" ? "npm" : "python"); | |
| 629 | const packageBinDirectory = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => | |
| 630 | distribution === "npx" && platform === "win32" | |
| 631 | ? packageInstallRoot(agent, distribution) | |
| 632 | : path.join(packageInstallRoot(agent, distribution), "bin"); | |
| 633 | const packageReceiptsDirectory = path.join(registryDirectory, "package-installs"); | |
| 634 | const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined); | |
| 635 | const registryRevision = yield* Ref.make(0); | |
| 636 | const registrySemaphore = yield* Semaphore.make(1); | |
| 637 | const installSemaphore = yield* Semaphore.make(1); | |
| 638 | const preparedBinaryReservations = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 639 | ||
| 640 | const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* ( | |
| 641 | runner: string, | |
| 642 | ) { | |
| 643 | const linkedTarget = yield* fileSystem.readLink(runner).pipe(Effect.option); | |
| 644 | if (Option.isNone(linkedTarget)) return path.dirname(runner); | |
| 645 | const target = path.isAbsolute(linkedTarget.value) | |
| 646 | ? linkedTarget.value | |
| 647 | : path.resolve(path.dirname(runner), linkedTarget.value); | |
| 648 | // Some service installs expose npm/uv through a wrapper symlink into the | |
| 649 | // actual toolchain bin directory. Do not mistake npm's internal | |
| 650 | // `npm-cli.js` directory for the global command directory. | |
| 651 | return path.basename(target) === path.basename(runner) | |
| 652 | ? path.dirname(target) | |
| 653 | : path.dirname(runner); | |
| 654 | }); | |
| 655 | ||
| 656 | const reservePreparedBinary = Effect.fn("AcpRegistryCatalog.reservePreparedBinary")(function* ( | |
| 657 | agentId: string, | |
| 658 | ) { | |
| 659 | const now = yield* Clock.currentTimeMillis; | |
| 660 | yield* Ref.update(preparedBinaryReservations, (current) => { | |
| 661 | const next = new Map(Array.from(current).filter(([, expiresAt]) => expiresAt > now)); | |
| 662 | next.set(agentId, now + PREPARED_BINARY_RESERVATION_MS); | |
| 663 | return next; | |
| 664 | }); | |
| 665 | }); | |
| 666 | ||
| 667 | const consumePreparedBinaryReservation = (agentId: string) => | |
| 668 | Ref.update(preparedBinaryReservations, (current) => { | |
| 669 | if (!current.has(agentId)) return current; | |
| 670 | const next = new Map(current); | |
| 671 | next.delete(agentId); | |
| 672 | return next; | |
| 673 | }); | |
| 674 | ||
| 675 | const hasPreparedBinaryReservation = Effect.fn("AcpRegistryCatalog.hasPreparedBinaryReservation")( | |
| 676 | function* (agentId: string) { | |
| 677 | const now = yield* Clock.currentTimeMillis; | |
| 678 | return yield* Ref.modify(preparedBinaryReservations, (current) => { | |
| 679 | const expiresAt = current.get(agentId); | |
| 680 | if (expiresAt === undefined || expiresAt <= now) { | |
| 681 | if (expiresAt === undefined) return [false, current] as const; | |
| 682 | const next = new Map(current); | |
| 683 | next.delete(agentId); | |
| 684 | return [false, next] as const; | |
| 685 | } | |
| 686 | return [true, current] as const; | |
| 687 | }); | |
| 688 | }, | |
| 689 | ); | |
| 690 | ||
| 691 | const assertHttpsUrl = Effect.fn("AcpRegistryCatalog.assertHttpsUrl")(function* ( | |
| 692 | value: string, | |
| 693 | detail: string, | |
| 694 | ) { | |
| 695 | const valid = yield* decodeHttpsUrl(value).pipe(Effect.option); | |
| 696 | if (Option.isNone(valid)) { | |
| 697 | return yield* new AcpRegistryError({ reason: "registry_unavailable", detail }); | |
| 698 | } | |
| 699 | return value; | |
| 700 | }); | |
| 701 | ||
| 702 | const decodeRegistryText = Effect.fn("AcpRegistryCatalog.decodeRegistryText")(function* ( | |
| 703 | text: string, | |
| 704 | ) { | |
| 705 | const decoded = yield* decodeJson(text).pipe( | |
| 706 | Effect.mapError( | |
| 707 | (cause) => | |
| 708 | new AcpRegistryError({ | |
| 709 | reason: "registry_unavailable", | |
| 710 | detail: "ACP Registry returned invalid JSON.", | |
| 711 | cause, | |
| 712 | }), | |
| 713 | ), | |
| 714 | ); | |
| 715 | const envelope = yield* decodeRegistryIndexEnvelope(decoded).pipe( | |
| 716 | Effect.mapError( | |
| 717 | (cause) => | |
| 718 | new AcpRegistryError({ | |
| 719 | reason: "registry_unavailable", | |
| 720 | detail: "ACP Registry returned an invalid index.", | |
| 721 | cause, | |
| 722 | }), | |
| 723 | ), | |
| 724 | ); | |
| 725 | const agents = envelope.agents.flatMap((candidate) => { | |
| 726 | const decodedAgent = decodeRegistryAgent(candidate); | |
| 727 | return Option.isSome(decodedAgent) ? [decodedAgent.value] : []; | |
| 728 | }); | |
| 729 | const discarded = envelope.agents.length - agents.length; | |
| 730 | if (discarded > 0) { | |
| 731 | yield* Effect.logWarning("ignored invalid ACP Registry entries", { discarded }); | |
| 732 | } | |
| 733 | return { version: envelope.version, agents } satisfies AcpRegistryIndex; | |
| 734 | }); | |
| 735 | ||
| 736 | const readCachedRegistry = fileSystem | |
| 737 | .readFileString(registryCachePath) | |
| 738 | .pipe(Effect.flatMap(decodeRegistryText), Effect.option); | |
| 739 | ||
| 740 | const writeRegistryCache = (text: string) => | |
| 741 | crypto.randomUUIDv4.pipe( | |
| 742 | Effect.flatMap((id) => { | |
| 743 | const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`; | |
| 744 | return fileSystem | |
| 745 | .makeDirectory(registryDirectory, { recursive: true }) | |
| 746 | .pipe( | |
| 747 | Effect.andThen(fileSystem.writeFileString(temporaryPath, text)), | |
| 748 | Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)), | |
| 749 | Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)), | |
| 750 | ); | |
| 751 | }), | |
| 752 | Effect.ignore, | |
| 753 | ); | |
| 754 | ||
| 755 | const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () { | |
| 756 | yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS."); | |
| 757 | const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe( | |
| 758 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 759 | Effect.mapError( | |
| 760 | (cause) => | |
| 761 | new AcpRegistryError({ | |
| 762 | reason: "registry_unavailable", | |
| 763 | detail: `Could not fetch ACP Registry index from ${registryUrl}.`, | |
| 764 | cause, | |
| 765 | }), | |
| 766 | ), | |
| 767 | Effect.timeoutOrElse({ | |
| 768 | duration: REGISTRY_REQUEST_TIMEOUT, | |
| 769 | orElse: () => | |
| 770 | Effect.fail( | |
| 771 | new AcpRegistryError({ | |
| 772 | reason: "registry_unavailable", | |
| 773 | detail: "Timed out fetching ACP Registry index.", | |
| 774 | }), | |
| 775 | ), | |
| 776 | }), | |
| 777 | ); | |
| 778 | const collected = yield* collectUint8StreamText({ | |
| 779 | stream: response.stream, | |
| 780 | maxBytes: MAX_REGISTRY_BYTES + 1, | |
| 781 | }).pipe( | |
| 782 | Effect.mapError( | |
| 783 | (cause) => | |
| 784 | new AcpRegistryError({ | |
| 785 | reason: "registry_unavailable", | |
| 786 | detail: "Could not read ACP Registry response body.", | |
| 787 | cause, | |
| 788 | }), | |
| 789 | ), | |
| 790 | Effect.timeoutOrElse({ | |
| 791 | duration: REGISTRY_REQUEST_TIMEOUT, | |
| 792 | orElse: () => | |
| 793 | Effect.fail( | |
| 794 | new AcpRegistryError({ | |
| 795 | reason: "registry_unavailable", | |
| 796 | detail: "Timed out reading ACP Registry response body.", | |
| 797 | }), | |
| 798 | ), | |
| 799 | }), | |
| 800 | ); | |
| 801 | if (collected.truncated || collected.bytes > MAX_REGISTRY_BYTES) { | |
| 802 | return yield* new AcpRegistryError({ | |
| 803 | reason: "registry_unavailable", | |
| 804 | detail: `ACP Registry index exceeds ${MAX_REGISTRY_BYTES} bytes.`, | |
| 805 | }); | |
| 806 | } | |
| 807 | if (collected.invalidUtf8) { | |
| 808 | return yield* new AcpRegistryError({ | |
| 809 | reason: "registry_unavailable", | |
| 810 | detail: "ACP Registry index is not valid UTF-8.", | |
| 811 | }); | |
| 812 | } | |
| 813 | const registry = yield* decodeRegistryText(collected.text); | |
| 814 | yield* writeRegistryCache(collected.text); | |
| 815 | return registry; | |
| 816 | }); | |
| 817 | ||
| 818 | const refreshRegistry = Effect.fn("AcpRegistryCatalog.refreshRegistry")(function* () { | |
| 819 | const observedRevision = yield* Ref.get(registryRevision); | |
| 820 | return yield* registrySemaphore.withPermits(1)( | |
| 821 | Effect.gen(function* () { | |
| 822 | const currentRevision = yield* Ref.get(registryRevision); | |
| 823 | const current = yield* Ref.get(registryRef); | |
| 824 | if (current !== undefined && currentRevision !== observedRevision) return current; | |
| 825 | ||
| 826 | const registry = yield* fetchRegistry().pipe( | |
| 827 | Effect.catch((networkError) => | |
| 828 | readCachedRegistry.pipe( | |
| 829 | Effect.flatMap( | |
| 830 | Option.match({ | |
| 831 | onNone: () => Effect.fail(networkError), | |
| 832 | onSome: Effect.succeed, | |
| 833 | }), | |
| 834 | ), | |
| 835 | ), | |
| 836 | ), | |
| 837 | ); | |
| 838 | yield* Ref.set(registryRef, registry); | |
| 839 | yield* Ref.update(registryRevision, (revision) => revision + 1); | |
| 840 | return registry; | |
| 841 | }), | |
| 842 | ); | |
| 843 | }); | |
| 844 | ||
| 845 | const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () { | |
| 846 | const current = yield* Ref.get(registryRef); | |
| 847 | if (current !== undefined) return current; | |
| 848 | ||
| 849 | const cached = yield* readCachedRegistry; | |
| 850 | if (Option.isNone(cached)) { | |
| 851 | return yield* new AcpRegistryError({ | |
| 852 | reason: "registry_unavailable", | |
| 853 | detail: "No valid cached ACP Registry index is available.", | |
| 854 | }); | |
| 855 | } | |
| 856 | yield* Ref.set(registryRef, cached.value); | |
| 857 | return cached.value; | |
| 858 | }); | |
| 859 | ||
| 860 | const loadRegistry = Effect.fn("AcpRegistryCatalog.loadRegistry")(function* () { | |
| 861 | return yield* loadCachedRegistry().pipe(Effect.catch(() => refreshRegistry())); | |
| 862 | }); | |
| 863 | ||
| 864 | const runCommand = Effect.fn("AcpRegistryCatalog.runCommand")(function* ( | |
| 865 | command: string, | |
| 866 | args: ReadonlyArray<string>, | |
| 867 | options: { | |
| 868 | readonly cwd?: string; | |
| 869 | readonly env?: NodeJS.ProcessEnv; | |
| 870 | readonly timeout?: Duration.Input; | |
| 871 | readonly truncatedOutputReason?: AcpRegistryErrorReason; | |
| 872 | } = {}, | |
| 873 | ) { | |
| 874 | const collect = Effect.gen(function* () { | |
| 875 | const resolved = yield* resolveSpawnCommand( | |
| 876 | command, | |
| 877 | args, | |
| 878 | options.env === undefined ? {} : { env: options.env }, | |
| 879 | ); | |
| 880 | const child = yield* spawner.spawn( | |
| 881 | ChildProcess.make(resolved.command, resolved.args, { | |
| 882 | ...(options.cwd ? { cwd: options.cwd } : {}), | |
| 883 | ...(options.env === undefined ? {} : { env: options.env }), | |
| 884 | shell: resolved.shell, | |
| 885 | }), | |
| 886 | ); | |
| 887 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | |
| 888 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 889 | [ | |
| 890 | collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }), | |
| 891 | collectUint8StreamText({ stream: child.stderr, maxBytes: 1024 * 1024 }), | |
| 892 | child.exitCode, | |
| 893 | ], | |
| 894 | { concurrency: "unbounded" }, | |
| 895 | ); | |
| 896 | if (Number(exitCode) !== 0) { | |
| 897 | return yield* new AcpRegistryError({ | |
| 898 | reason: "install_failed", | |
| 899 | detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`, | |
| 900 | }); | |
| 901 | } | |
| 902 | // Archive listings feed validateArchiveEntries; a truncated listing would let | |
| 903 | // unvalidated entries past the traversal check, so oversized output is fatal. | |
| 904 | if (stdout.truncated) { | |
| 905 | return yield* new AcpRegistryError({ | |
| 906 | reason: options.truncatedOutputReason ?? "archive_invalid", | |
| 907 | detail: `ACP Registry install command '${command}' produced more output than expected.`, | |
| 908 | }); | |
| 909 | } | |
| 910 | return stdout.text; | |
| 911 | }); | |
| 912 | const scoped = collect.pipe( | |
| 913 | Effect.scoped, | |
| 914 | Effect.mapError((cause) => | |
| 915 | isAcpRegistryError(cause) | |
| 916 | ? cause | |
| 917 | : new AcpRegistryError({ | |
| 918 | reason: "install_failed", | |
| 919 | detail: `Could not run ACP Registry install command '${command}'.`, | |
| 920 | cause, | |
| 921 | }), | |
| 922 | ), | |
| 923 | ); | |
| 924 | return yield* options.timeout === undefined | |
| 925 | ? scoped | |
| 926 | : scoped.pipe( | |
| 927 | Effect.timeoutOrElse({ | |
| 928 | duration: options.timeout, | |
| 929 | orElse: () => | |
| 930 | Effect.fail( | |
| 931 | new AcpRegistryError({ | |
| 932 | reason: "install_failed", | |
| 933 | detail: `Timed out running ACP Registry install command '${command}'.`, | |
| 934 | }), | |
| 935 | ), | |
| 936 | }), | |
| 937 | ); | |
| 938 | }); | |
| 939 | ||
| 940 | const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) => | |
| 941 | sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe( | |
| 942 | Effect.map((digest) => | |
| 943 | path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`), | |
| 944 | ), | |
| 945 | ); | |
| 946 | ||
| 947 | const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* ( | |
| 948 | agent: AcpRegistryAgent, | |
| 949 | distribution: "npx" | "uvx", | |
| 950 | packageSpec: string, | |
| 951 | managerPath: string, | |
| 952 | ) { | |
| 953 | const receipt = yield* fileSystem | |
| 954 | .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath)) | |
| 955 | .pipe( | |
| 956 | Effect.map(decodePackageInstallReceipt), | |
| 957 | Effect.orElseSucceed(() => Option.none()), | |
| 958 | ); | |
| 959 | if ( | |
| 960 | Option.isNone(receipt) || | |
| 961 | receipt.value.agentId !== agent.id || | |
| 962 | receipt.value.agentVersion !== agent.version || | |
| 963 | receipt.value.distribution !== distribution || | |
| 964 | receipt.value.packageSpec !== packageSpec || | |
| 965 | receipt.value.managerPath !== managerPath || | |
| 966 | receipt.value.binDirectory !== packageBinDirectory(agent, distribution) || | |
| 967 | path.dirname(receipt.value.executablePath) !== receipt.value.binDirectory | |
| 968 | ) { | |
| 969 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 970 | } | |
| 971 | const executableExists = yield* fileSystem | |
| 972 | .exists(receipt.value.executablePath) | |
| 973 | .pipe(Effect.orElseSucceed(() => false)); | |
| 974 | if (!executableExists) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 975 | ||
| 976 | if (distribution === "npx") { | |
| 977 | const packageIdentity = parseNpxPackageSpec(packageSpec); | |
| 978 | if (receipt.value.packageRoot === undefined || receipt.value.packageVersion === undefined) { | |
| 979 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 980 | } | |
| 981 | const manifest = yield* fileSystem | |
| 982 | .readFileString(path.join(receipt.value.packageRoot, "package.json")) | |
| 983 | .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); | |
| 984 | if ( | |
| 985 | Option.isNone(manifest) || | |
| 986 | manifest.value.name !== packageIdentity.name || | |
| 987 | manifest.value.version !== packageIdentity.version || | |
| 988 | receipt.value.packageVersion !== packageIdentity.version | |
| 989 | ) { | |
| 990 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 991 | } | |
| 992 | } | |
| 993 | return receipt; | |
| 994 | }); | |
| 995 | ||
| 996 | const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")( | |
| 997 | function* (receipt: AcpRegistryPackageInstallReceipt) { | |
| 998 | const receiptPath = yield* packageReceiptPath( | |
| 999 | receipt.agentId, | |
| 1000 | receipt.distribution, | |
| 1001 | receipt.managerPath, | |
| 1002 | ); | |
| 1003 | const temporaryPath = `${receiptPath}.${process.pid}.tmp`; | |
| 1004 | yield* fileSystem.makeDirectory(packageReceiptsDirectory, { recursive: true }); | |
| 1005 | yield* fileSystem.remove(temporaryPath, { force: true }); | |
| 1006 | yield* fileSystem.writeFileString(temporaryPath, `${encodePackageInstallReceipt(receipt)}\n`); | |
| 1007 | yield* fileSystem.remove(receiptPath, { force: true }); | |
| 1008 | yield* fileSystem.rename(temporaryPath, receiptPath); | |
| 1009 | }, | |
| 1010 | Effect.mapError((cause) => | |
| 1011 | isAcpRegistryError(cause) | |
| 1012 | ? cause | |
| 1013 | : new AcpRegistryError({ | |
| 1014 | reason: "install_failed", | |
| 1015 | detail: "Could not record the globally installed ACP Registry command.", | |
| 1016 | cause, | |
| 1017 | }), | |
| 1018 | ), | |
| 1019 | ); | |
| 1020 | ||
| 1021 | const parsePackageManagerPath = Effect.fn("AcpRegistryCatalog.parsePackageManagerPath")( | |
| 1022 | function* (managerPath: string, output: string) { | |
| 1023 | const lines = output | |
| 1024 | .split(/\r?\n/u) | |
| 1025 | .map((line) => line.trim()) | |
| 1026 | .filter((line) => line.length > 0); | |
| 1027 | if (lines.length !== 1 || !path.isAbsolute(lines[0]!)) { | |
| 1028 | return yield* new AcpRegistryError({ | |
| 1029 | reason: "install_failed", | |
| 1030 | detail: `ACP Registry package manager '${managerPath}' returned an invalid global path.`, | |
| 1031 | }); | |
| 1032 | } | |
| 1033 | return lines[0]!; | |
| 1034 | }, | |
| 1035 | ); | |
| 1036 | ||
| 1037 | const npmCommandName = ( | |
| 1038 | agent: AcpRegistryAgent, | |
| 1039 | packageName: string, | |
| 1040 | manifest: typeof NpmPackageManifest.Type, | |
| 1041 | ): string | undefined => { | |
| 1042 | const packageBaseName = packageName.split("/").pop() ?? packageName; | |
| 1043 | if (typeof manifest.bin === "string") return packageBaseName; | |
| 1044 | const entries = Object.entries(manifest.bin).toSorted(([left], [right]) => | |
| 1045 | compareText(left, right), | |
| 1046 | ); | |
| 1047 | if (entries.length === 1) return entries[0]![0]; | |
| 1048 | if (manifest.bin[packageBaseName] !== undefined) return packageBaseName; | |
| 1049 | if (manifest.bin[agent.id] !== undefined) return agent.id; | |
| 1050 | return new Set(entries.map(([, commandPath]) => commandPath)).size === 1 | |
| 1051 | ? entries[0]?.[0] | |
| 1052 | : undefined; | |
| 1053 | }; | |
| 1054 | ||
| 1055 | const discoverNpmGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverNpmGlobalPackage")( | |
| 1056 | function* ( | |
| 1057 | agent: AcpRegistryAgent, | |
| 1058 | packageSpec: string, | |
| 1059 | managerPath: string, | |
| 1060 | environment: NodeJS.ProcessEnv, | |
| 1061 | ) { | |
| 1062 | const packageIdentity = parseNpxPackageSpec(packageSpec); | |
| 1063 | const commandOptions = { | |
| 1064 | env: environment, | |
| 1065 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1066 | truncatedOutputReason: "install_failed" as const, | |
| 1067 | } as const; | |
| 1068 | const globalRoot = yield* runCommand(managerPath, ["root", "--global"], commandOptions).pipe( | |
| 1069 | Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)), | |
| 1070 | ); | |
| 1071 | const globalPrefix = yield* runCommand( | |
| 1072 | managerPath, | |
| 1073 | ["prefix", "--global"], | |
| 1074 | commandOptions, | |
| 1075 | ).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); | |
| 1076 | const packageRoot = path.join(globalRoot, ...packageIdentity.name.split("/")); | |
| 1077 | const manifestPath = path.join(packageRoot, "package.json"); | |
| 1078 | const manifestInfo = yield* fileSystem.stat(manifestPath).pipe(Effect.option); | |
| 1079 | if (Option.isNone(manifestInfo) || manifestInfo.value.size > MAX_PACKAGE_MANIFEST_BYTES) { | |
| 1080 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1081 | } | |
| 1082 | const manifest = yield* fileSystem | |
| 1083 | .readFileString(manifestPath) | |
| 1084 | .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); | |
| 1085 | if ( | |
| 1086 | Option.isNone(manifest) || | |
| 1087 | manifest.value.name !== packageIdentity.name || | |
| 1088 | manifest.value.version !== packageIdentity.version | |
| 1089 | ) { | |
| 1090 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1091 | } | |
| 1092 | const commandName = npmCommandName(agent, packageIdentity.name, manifest.value); | |
| 1093 | if (commandName === undefined) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1094 | const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin"); | |
| 1095 | const executablePath = resolveExecutable( | |
| 1096 | commandName, | |
| 1097 | platform, | |
| 1098 | withPreferredPath(environment, binDirectory, platform), | |
| 1099 | ); | |
| 1100 | return executablePath === undefined | |
| 1101 | ? Option.none<AcpRegistryPackageInstallReceipt>() | |
| 1102 | : Option.some<AcpRegistryPackageInstallReceipt>({ | |
| 1103 | agentId: agent.id, | |
| 1104 | agentVersion: agent.version, | |
| 1105 | distribution: "npx" as const, | |
| 1106 | packageSpec, | |
| 1107 | managerPath, | |
| 1108 | binDirectory, | |
| 1109 | executablePath, | |
| 1110 | packageRoot, | |
| 1111 | packageVersion: manifest.value.version, | |
| 1112 | }); | |
| 1113 | }, | |
| 1114 | ); | |
| 1115 | ||
| 1116 | const discoverUvGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverUvGlobalPackage")( | |
| 1117 | function* ( | |
| 1118 | agent: AcpRegistryAgent, | |
| 1119 | packageSpec: string, | |
| 1120 | managerPath: string, | |
| 1121 | environment: NodeJS.ProcessEnv, | |
| 1122 | ) { | |
| 1123 | const packageIdentity = parseUvxPackageSpec(packageSpec); | |
| 1124 | const installedTools = yield* runCommand(managerPath, ["tool", "list"], { | |
| 1125 | env: environment, | |
| 1126 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1127 | truncatedOutputReason: "install_failed", | |
| 1128 | }); | |
| 1129 | const normalizedPackageName = packageIdentity.name.toLowerCase().replace(/[._-]+/gu, "-"); | |
| 1130 | const exactVersionInstalled = installedTools.split(/\r?\n/u).some((line) => { | |
| 1131 | const match = /^(\S+) v(\S+)(?:\s|$)/u.exec(line); | |
| 1132 | return ( | |
| 1133 | match?.[1]?.toLowerCase().replace(/[._-]+/gu, "-") === normalizedPackageName && | |
| 1134 | match[2] === packageIdentity.version | |
| 1135 | ); | |
| 1136 | }); | |
| 1137 | if (!exactVersionInstalled) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1138 | const binDirectory = yield* runCommand(managerPath, ["tool", "dir", "--bin"], { | |
| 1139 | env: environment, | |
| 1140 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1141 | truncatedOutputReason: "install_failed", | |
| 1142 | }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); | |
| 1143 | const commandEnvironment = withPreferredPath(environment, binDirectory, platform); | |
| 1144 | const executablePath = packageCommandCandidates(agent, packageIdentity.name) | |
| 1145 | .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment)) | |
| 1146 | .find((candidate): candidate is string => candidate !== undefined); | |
| 1147 | return executablePath === undefined | |
| 1148 | ? Option.none<AcpRegistryPackageInstallReceipt>() | |
| 1149 | : Option.some<AcpRegistryPackageInstallReceipt>({ | |
| 1150 | agentId: agent.id, | |
| 1151 | agentVersion: agent.version, | |
| 1152 | distribution: "uvx" as const, | |
| 1153 | packageSpec, | |
| 1154 | managerPath, | |
| 1155 | binDirectory, | |
| 1156 | executablePath, | |
| 1157 | packageVersion: packageIdentity.version, | |
| 1158 | }); | |
| 1159 | }, | |
| 1160 | ); | |
| 1161 | ||
| 1162 | const ensureGlobalPackage = Effect.fn("AcpRegistryCatalog.ensureGlobalPackage")(function* ( | |
| 1163 | agent: AcpRegistryAgent, | |
| 1164 | distribution: "npx" | "uvx", | |
| 1165 | packageSpec: string, | |
| 1166 | environment: NodeJS.ProcessEnv, | |
| 1167 | ) { | |
| 1168 | const managerName = packageManagerFor(distribution)!; | |
| 1169 | const managerPath = resolveExecutable(managerName, platform, environment); | |
| 1170 | if (managerPath === undefined) { | |
| 1171 | return yield* new AcpRegistryError({ | |
| 1172 | reason: "runner_unavailable", | |
| 1173 | detail: `ACP Registry agent ${agent.id} requires '${managerName}', but it is not available on this environment's PATH.`, | |
| 1174 | }); | |
| 1175 | } | |
| 1176 | const receipt = yield* readPackageReceipt(agent, distribution, packageSpec, managerPath); | |
| 1177 | if (Option.isSome(receipt) && distribution === "npx") return receipt.value; | |
| 1178 | ||
| 1179 | const packageRoot = packageInstallRoot(agent, distribution); | |
| 1180 | yield* fileSystem.makeDirectory(packageRoot, { recursive: true }).pipe( | |
| 1181 | Effect.mapError( | |
| 1182 | (cause) => | |
| 1183 | new AcpRegistryError({ | |
| 1184 | reason: "install_failed", | |
| 1185 | detail: `Could not create the managed install directory for ${agent.id}.`, | |
| 1186 | cause, | |
| 1187 | }), | |
| 1188 | ), | |
| 1189 | ); | |
| 1190 | const packageEnvironment = | |
| 1191 | distribution === "npx" | |
| 1192 | ? { ...environment, npm_config_prefix: packageRoot } | |
| 1193 | : { | |
| 1194 | ...environment, | |
| 1195 | UV_TOOL_DIR: packageRoot, | |
| 1196 | UV_TOOL_BIN_DIR: packageBinDirectory(agent, distribution), | |
| 1197 | }; | |
| 1198 | if (Option.isSome(receipt)) { | |
| 1199 | const installed = yield* discoverUvGlobalPackage( | |
| 1200 | agent, | |
| 1201 | packageSpec, | |
| 1202 | managerPath, | |
| 1203 | packageEnvironment, | |
| 1204 | ); | |
| 1205 | if (Option.isSome(installed)) return installed.value; | |
| 1206 | } | |
| 1207 | ||
| 1208 | const discover = | |
| 1209 | distribution === "npx" | |
| 1210 | ? () => discoverNpmGlobalPackage(agent, packageSpec, managerPath, packageEnvironment) | |
| 1211 | : () => discoverUvGlobalPackage(agent, packageSpec, managerPath, packageEnvironment); | |
| 1212 | const existing = yield* discover(); | |
| 1213 | if (Option.isSome(existing)) { | |
| 1214 | yield* writePackageReceipt(existing.value); | |
| 1215 | return existing.value; | |
| 1216 | } | |
| 1217 | ||
| 1218 | const managerDirectory = yield* managerBinDirectory(managerPath); | |
| 1219 | const installEnvironment = withPreferredPath(packageEnvironment, managerDirectory, platform); | |
| 1220 | if (distribution === "npx") { | |
| 1221 | yield* runCommand(managerPath, ["install", "--global", packageSpec], { | |
| 1222 | env: installEnvironment, | |
| 1223 | timeout: PACKAGE_INSTALL_TIMEOUT, | |
| 1224 | truncatedOutputReason: "install_failed", | |
| 1225 | }); | |
| 1226 | } else { | |
| 1227 | yield* runCommand(managerPath, ["tool", "install", "--force", packageSpec], { | |
| 1228 | env: installEnvironment, | |
| 1229 | timeout: PACKAGE_INSTALL_TIMEOUT, | |
| 1230 | truncatedOutputReason: "install_failed", | |
| 1231 | }); | |
| 1232 | } | |
| 1233 | ||
| 1234 | if (distribution === "npx") { | |
| 1235 | const installed = yield* discoverNpmGlobalPackage( | |
| 1236 | agent, | |
| 1237 | packageSpec, | |
| 1238 | managerPath, | |
| 1239 | packageEnvironment, | |
| 1240 | ); | |
| 1241 | if (Option.isNone(installed)) { | |
| 1242 | return yield* new AcpRegistryError({ | |
| 1243 | reason: "install_failed", | |
| 1244 | detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, | |
| 1245 | }); | |
| 1246 | } | |
| 1247 | yield* writePackageReceipt(installed.value); | |
| 1248 | return installed.value; | |
| 1249 | } | |
| 1250 | ||
| 1251 | const installed = yield* discoverUvGlobalPackage( | |
| 1252 | agent, | |
| 1253 | packageSpec, | |
| 1254 | managerPath, | |
| 1255 | packageEnvironment, | |
| 1256 | ); | |
| 1257 | if (Option.isNone(installed)) { | |
| 1258 | return yield* new AcpRegistryError({ | |
| 1259 | reason: "install_failed", | |
| 1260 | detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, | |
| 1261 | }); | |
| 1262 | } | |
| 1263 | yield* writePackageReceipt(installed.value); | |
| 1264 | return installed.value; | |
| 1265 | }); | |
| 1266 | ||
| 1267 | const acquireInstallLock = Effect.fn("AcpRegistryCatalog.acquireInstallLock")(function* ( | |
| 1268 | lockPath: string, | |
| 1269 | ) { | |
| 1270 | for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | |
| 1271 | const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | |
| 1272 | Effect.as(true), | |
| 1273 | Effect.catch((error) => | |
| 1274 | isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | |
| 1275 | ), | |
| 1276 | ); | |
| 1277 | if (acquired) return; | |
| 1278 | const now = yield* Clock.currentTimeMillis; | |
| 1279 | const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | |
| 1280 | const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | |
| 1281 | if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | |
| 1282 | yield* fileSystem.remove(lockPath, { force: true }); | |
| 1283 | continue; | |
| 1284 | } | |
| 1285 | yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | |
| 1286 | } | |
| 1287 | return yield* new AcpRegistryError({ | |
| 1288 | reason: "install_failed", | |
| 1289 | detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`, | |
| 1290 | }); | |
| 1291 | }); | |
| 1292 | ||
| 1293 | const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* ( | |
| 1294 | root: string, | |
| 1295 | executablePath: string, | |
| 1296 | ) { | |
| 1297 | const rootRealPath = yield* fileSystem.realPath(root); | |
| 1298 | const executableRealPath = yield* fileSystem.realPath(executablePath); | |
| 1299 | const relative = path.relative(rootRealPath, executableRealPath); | |
| 1300 | if (relative.startsWith("..") || path.isAbsolute(relative)) { | |
| 1301 | return yield* new AcpRegistryError({ | |
| 1302 | reason: "archive_invalid", | |
| 1303 | detail: "ACP Registry archive command resolves outside its installation directory.", | |
| 1304 | }); | |
| 1305 | } | |
| 1306 | const info = yield* fileSystem.stat(executableRealPath); | |
| 1307 | const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0; | |
| 1308 | if (info.type !== "File" || !hasExecutableMode) { | |
| 1309 | return yield* new AcpRegistryError({ | |
| 1310 | reason: "archive_invalid", | |
| 1311 | detail: "ACP Registry archive command is not a regular executable file.", | |
| 1312 | }); | |
| 1313 | } | |
| 1314 | return executableRealPath; | |
| 1315 | }); | |
| 1316 | ||
| 1317 | const binaryPaths = (agent: AcpRegistryAgent, target: typeof AcpRegistryBinaryTarget.Type) => { | |
| 1318 | const commandSegments = normalizeRegistryCommandPath(target.cmd); | |
| 1319 | if (platformTarget === undefined || commandSegments === undefined) return undefined; | |
| 1320 | const installRoot = path.join(agentInstallRoot(agent), platformTarget); | |
| 1321 | return { | |
| 1322 | commandSegments, | |
| 1323 | installRoot, | |
| 1324 | executablePath: path.join(installRoot, ...commandSegments), | |
| 1325 | }; | |
| 1326 | }; | |
| 1327 | ||
| 1328 | const installBinary = Effect.fn("AcpRegistryCatalog.installBinary")(function* ( | |
| 1329 | agent: AcpRegistryAgent, | |
| 1330 | target: typeof AcpRegistryBinaryTarget.Type, | |
| 1331 | ) { | |
| 1332 | if (platformTarget === undefined) { | |
| 1333 | return yield* new AcpRegistryError({ | |
| 1334 | reason: "unsupported_platform", | |
| 1335 | detail: `ACP Registry does not support platform ${platform}-${architecture}.`, | |
| 1336 | }); | |
| 1337 | } | |
| 1338 | const paths = binaryPaths(agent, target); | |
| 1339 | if (paths === undefined) { | |
| 1340 | return yield* new AcpRegistryError({ | |
| 1341 | reason: "archive_invalid", | |
| 1342 | detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`, | |
| 1343 | }); | |
| 1344 | } | |
| 1345 | const { commandSegments, installRoot, executablePath } = paths; | |
| 1346 | if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 1347 | return yield* assertExecutableInRoot(installRoot, executablePath).pipe( | |
| 1348 | Effect.mapError((cause) => | |
| 1349 | isAcpRegistryError(cause) | |
| 1350 | ? cause | |
| 1351 | : new AcpRegistryError({ | |
| 1352 | reason: "install_failed", | |
| 1353 | detail: `Could not validate cached ACP Registry agent ${agent.id}.`, | |
| 1354 | cause, | |
| 1355 | }), | |
| 1356 | ), | |
| 1357 | ); | |
| 1358 | } | |
| 1359 | ||
| 1360 | yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe( | |
| 1361 | Effect.mapError( | |
| 1362 | (cause) => | |
| 1363 | new AcpRegistryError({ | |
| 1364 | reason: "install_failed", | |
| 1365 | detail: `Could not create ACP Registry cache for ${agent.id}.`, | |
| 1366 | cause, | |
| 1367 | }), | |
| 1368 | ), | |
| 1369 | ); | |
| 1370 | const lockPath = `${installRoot}.lock`; | |
| 1371 | yield* acquireInstallLock(lockPath).pipe( | |
| 1372 | Effect.mapError((cause) => | |
| 1373 | isAcpRegistryError(cause) | |
| 1374 | ? cause | |
| 1375 | : new AcpRegistryError({ | |
| 1376 | reason: "install_failed", | |
| 1377 | detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`, | |
| 1378 | cause, | |
| 1379 | }), | |
| 1380 | ), | |
| 1381 | ); | |
| 1382 | ||
| 1383 | return yield* Effect.gen(function* () { | |
| 1384 | if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 1385 | return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 1386 | } | |
| 1387 | const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1388 | directory: path.dirname(installRoot), | |
| 1389 | prefix: `.${agent.id}-install-`, | |
| 1390 | }); | |
| 1391 | const extractionRoot = path.join(temporaryRoot, "extracted"); | |
| 1392 | yield* fileSystem.makeDirectory(extractionRoot, { recursive: true }); | |
| 1393 | const kind = archiveKind(target.archive); | |
| 1394 | const archivePath = path.join(temporaryRoot, archiveFileName(kind)); | |
| 1395 | const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe( | |
| 1396 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 1397 | Effect.mapError( | |
| 1398 | (cause) => | |
| 1399 | new AcpRegistryError({ | |
| 1400 | reason: "download_failed", | |
| 1401 | detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1402 | cause, | |
| 1403 | }), | |
| 1404 | ), | |
| 1405 | Effect.timeoutOrElse({ | |
| 1406 | duration: ARCHIVE_REQUEST_TIMEOUT, | |
| 1407 | orElse: () => | |
| 1408 | Effect.fail( | |
| 1409 | new AcpRegistryError({ | |
| 1410 | reason: "download_failed", | |
| 1411 | detail: `Timed out downloading ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1412 | }), | |
| 1413 | ), | |
| 1414 | }), | |
| 1415 | ); | |
| 1416 | // Hash while streaming: archives can be up to MAX_ARCHIVE_BYTES, and | |
| 1417 | // Effect's Crypto only digests a whole buffer. | |
| 1418 | const hash = sha256.create(); | |
| 1419 | let downloadedBytes = 0; | |
| 1420 | yield* response.stream.pipe( | |
| 1421 | Stream.tap((chunk) => { | |
| 1422 | if (downloadedBytes + chunk.byteLength > MAX_ARCHIVE_BYTES) { | |
| 1423 | return Effect.fail( | |
| 1424 | new AcpRegistryError({ | |
| 1425 | reason: "archive_invalid", | |
| 1426 | detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`, | |
| 1427 | }), | |
| 1428 | ); | |
| 1429 | } | |
| 1430 | downloadedBytes += chunk.byteLength; | |
| 1431 | hash.update(chunk); | |
| 1432 | return Effect.void; | |
| 1433 | }), | |
| 1434 | Stream.run(fileSystem.sink(archivePath)), | |
| 1435 | Effect.mapError((cause) => | |
| 1436 | isAcpRegistryError(cause) | |
| 1437 | ? cause | |
| 1438 | : new AcpRegistryError({ | |
| 1439 | reason: "download_failed", | |
| 1440 | detail: `Could not save ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1441 | cause, | |
| 1442 | }), | |
| 1443 | ), | |
| 1444 | Effect.timeoutOrElse({ | |
| 1445 | duration: ARCHIVE_REQUEST_TIMEOUT, | |
| 1446 | orElse: () => | |
| 1447 | Effect.fail( | |
| 1448 | new AcpRegistryError({ | |
| 1449 | reason: "download_failed", | |
| 1450 | detail: `Timed out reading ACP Registry agent ${agent.id} download.`, | |
| 1451 | }), | |
| 1452 | ), | |
| 1453 | }), | |
| 1454 | ); | |
| 1455 | if (target.sha256 !== undefined) { | |
| 1456 | const actual = Hex.encode(hash.digest()); | |
| 1457 | if (actual !== target.sha256.toLowerCase()) { | |
| 1458 | return yield* new AcpRegistryError({ | |
| 1459 | reason: "checksum_mismatch", | |
| 1460 | detail: `ACP Registry agent ${agent.id} download did not match its declared SHA-256.`, | |
| 1461 | }); | |
| 1462 | } | |
| 1463 | } | |
| 1464 | ||
| 1465 | if (kind === "raw") { | |
| 1466 | const targetPath = path.join(extractionRoot, ...commandSegments); | |
| 1467 | yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); | |
| 1468 | yield* fileSystem.rename(archivePath, targetPath); | |
| 1469 | } else { | |
| 1470 | const listArgs = | |
| 1471 | kind === "tar_gz" | |
| 1472 | ? ["-tzf", archivePath] | |
| 1473 | : kind === "tar_bz2" | |
| 1474 | ? ["-tjf", archivePath] | |
| 1475 | : platform === "win32" | |
| 1476 | ? ["-tf", archivePath] | |
| 1477 | : undefined; | |
| 1478 | const entries = | |
| 1479 | listArgs === undefined | |
| 1480 | ? yield* runCommand("unzip", ["-Z1", archivePath]) | |
| 1481 | : yield* runCommand("tar", listArgs); | |
| 1482 | if (!validateArchiveEntries(entries)) { | |
| 1483 | return yield* new AcpRegistryError({ | |
| 1484 | reason: "archive_invalid", | |
| 1485 | detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`, | |
| 1486 | }); | |
| 1487 | } | |
| 1488 | if (kind === "zip" && platform !== "win32") { | |
| 1489 | yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]); | |
| 1490 | } else { | |
| 1491 | const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf"; | |
| 1492 | yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]); | |
| 1493 | } | |
| 1494 | } | |
| 1495 | ||
| 1496 | const stagedExecutable = path.join(extractionRoot, ...commandSegments); | |
| 1497 | if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) { | |
| 1498 | return yield* new AcpRegistryError({ | |
| 1499 | reason: "archive_invalid", | |
| 1500 | detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`, | |
| 1501 | }); | |
| 1502 | } | |
| 1503 | if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755); | |
| 1504 | yield* assertExecutableInRoot(extractionRoot, stagedExecutable); | |
| 1505 | yield* fileSystem.remove(installRoot, { recursive: true, force: true }); | |
| 1506 | yield* fileSystem.rename(extractionRoot, installRoot); | |
| 1507 | return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 1508 | }).pipe( | |
| 1509 | Effect.scoped, | |
| 1510 | Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), | |
| 1511 | Effect.mapError((cause) => | |
| 1512 | isAcpRegistryError(cause) | |
| 1513 | ? cause | |
| 1514 | : new AcpRegistryError({ | |
| 1515 | reason: "install_failed", | |
| 1516 | detail: `Could not install ACP Registry agent ${agent.id}.`, | |
| 1517 | cause, | |
| 1518 | }), | |
| 1519 | ), | |
| 1520 | ); | |
| 1521 | }); | |
| 1522 | ||
| 1523 | const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* ( | |
| 1524 | registry: AcpRegistryIndex, | |
| 1525 | agentId: string, | |
| 1526 | ) { | |
| 1527 | const agent = registry.agents.find((candidate) => candidate.id === agentId.trim()); | |
| 1528 | if (agent === undefined) { | |
| 1529 | return yield* new AcpRegistryError({ | |
| 1530 | reason: "agent_not_found", | |
| 1531 | detail: `ACP Registry does not contain agent '${agentId.trim()}'.`, | |
| 1532 | }); | |
| 1533 | } | |
| 1534 | return agent; | |
| 1535 | }); | |
| 1536 | ||
| 1537 | const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* ( | |
| 1538 | agent: AcpRegistryAgent, | |
| 1539 | preference: AcpRegistryDistributionPreference, | |
| 1540 | ) { | |
| 1541 | const distribution = resolveAcpRegistryDistribution({ | |
| 1542 | agent, | |
| 1543 | preference, | |
| 1544 | platformTarget, | |
| 1545 | }); | |
| 1546 | if (distribution === undefined) { | |
| 1547 | return yield* new AcpRegistryError({ | |
| 1548 | reason: platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution", | |
| 1549 | detail: `ACP Registry agent ${agent.id} has no ${preference === "auto" ? "compatible" : preference} distribution for ${platform}-${architecture}.`, | |
| 1550 | }); | |
| 1551 | } | |
| 1552 | return distribution; | |
| 1553 | }); | |
| 1554 | ||
| 1555 | const search: AcpRegistryCatalog["Service"]["search"] = (input) => | |
| 1556 | Effect.gen(function* () { | |
| 1557 | const registry = yield* refreshRegistry(); | |
| 1558 | const ranked = registry.agents.flatMap((agent) => { | |
| 1559 | const distribution = resolveAcpRegistryDistribution({ | |
| 1560 | agent, | |
| 1561 | preference: "auto", | |
| 1562 | platformTarget, | |
| 1563 | }); | |
| 1564 | const rank = searchRank(agent, input.query); | |
| 1565 | const packageManager = | |
| 1566 | distribution === undefined ? undefined : packageManagerFor(distribution.kind); | |
| 1567 | const packageManagerAvailable = | |
| 1568 | packageManager === undefined || | |
| 1569 | resolveExecutable(packageManager, platform, hostEnvironment) !== undefined; | |
| 1570 | return distribution === undefined || rank === undefined || !packageManagerAvailable | |
| 1571 | ? [] | |
| 1572 | : [{ agent, distribution, rank }]; | |
| 1573 | }); | |
| 1574 | ranked.sort( | |
| 1575 | (left, right) => | |
| 1576 | left.rank - right.rank || | |
| 1577 | compareText(left.agent.name.toLowerCase(), right.agent.name.toLowerCase()) || | |
| 1578 | compareText(left.agent.id, right.agent.id), | |
| 1579 | ); | |
| 1580 | return { | |
| 1581 | agents: ranked.slice(0, MAX_SEARCH_RESULTS).map(({ agent, distribution }) => ({ | |
| 1582 | id: agent.id, | |
| 1583 | name: agent.name, | |
| 1584 | version: agent.version, | |
| 1585 | description: agent.description, | |
| 1586 | authors: agent.authors ?? [], | |
| 1587 | license: agent.license ?? null, | |
| 1588 | website: agent.website ?? null, | |
| 1589 | repository: agent.repository ?? null, | |
| 1590 | icon: agent.icon ?? null, | |
| 1591 | distribution: distribution.kind, | |
| 1592 | integrity: | |
| 1593 | distribution.kind === "binary" && distribution.binaryTarget?.sha256 !== undefined | |
| 1594 | ? "sha256" | |
| 1595 | : "registry", | |
| 1596 | })), | |
| 1597 | } satisfies AcpRegistrySearchResult; | |
| 1598 | }); | |
| 1599 | ||
| 1600 | const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) => | |
| 1601 | Effect.gen(function* () { | |
| 1602 | const registry = yield* refreshRegistry(); | |
| 1603 | const agent = yield* findAgent(registry, input.agentId); | |
| 1604 | const distribution = yield* compatibleDistribution(agent, "auto"); | |
| 1605 | if (distribution.kind === "binary") { | |
| 1606 | // A PATH executable can be a different version with different ACP | |
| 1607 | // capabilities. Only an explicit command override opts into that copy. | |
| 1608 | yield* installSemaphore.withPermits(1)( | |
| 1609 | installBinary(agent, distribution.binaryTarget!).pipe( | |
| 1610 | Effect.tap(() => reservePreparedBinary(agent.id)), | |
| 1611 | ), | |
| 1612 | ); | |
| 1613 | } else { | |
| 1614 | yield* installSemaphore.withPermits(1)( | |
| 1615 | ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment), | |
| 1616 | ); | |
| 1617 | } | |
| 1618 | return { | |
| 1619 | agentId: agent.id, | |
| 1620 | version: agent.version, | |
| 1621 | distribution: distribution.kind, | |
| 1622 | prepared: true, | |
| 1623 | } satisfies AcpRegistryPrepareResult; | |
| 1624 | }); | |
| 1625 | ||
| 1626 | const validateLocalExecutable = (command: string) => | |
| 1627 | platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) | |
| 1628 | ? Effect.fail( | |
| 1629 | new AcpRegistryError({ | |
| 1630 | reason: "runner_unavailable", | |
| 1631 | detail: | |
| 1632 | "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.", | |
| 1633 | }), | |
| 1634 | ) | |
| 1635 | : Effect.void; | |
| 1636 | ||
| 1637 | const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => | |
| 1638 | Effect.gen(function* () { | |
| 1639 | if (settings.source === "local") { | |
| 1640 | const command = settings.commandPath.trim(); | |
| 1641 | if (!command) return { status: "unconfigured" } as const; | |
| 1642 | const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); | |
| 1643 | if (executable !== undefined) yield* validateLocalExecutable(executable); | |
| 1644 | return executable === undefined | |
| 1645 | ? ({ | |
| 1646 | status: "missing_runner", | |
| 1647 | version: null, | |
| 1648 | distribution: "local", | |
| 1649 | } as const) | |
| 1650 | : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); | |
| 1651 | } | |
| 1652 | const agentId = settings.agentId.trim(); | |
| 1653 | if (agentId.length === 0) return { status: "unconfigured" } as const; | |
| 1654 | const registry = yield* loadCachedRegistry(); | |
| 1655 | const agent = registry.agents.find((candidate) => candidate.id === agentId); | |
| 1656 | if (agent === undefined) return { status: "not_found", agentId } as const; | |
| 1657 | const documentationUrl = agent.website ?? agent.repository; | |
| 1658 | const distribution = resolveAcpRegistryDistribution({ | |
| 1659 | agent, | |
| 1660 | preference: settings.distribution, | |
| 1661 | platformTarget, | |
| 1662 | }); | |
| 1663 | if (distribution === undefined) { | |
| 1664 | return { status: "unsupported", agentId, version: agent.version } as const; | |
| 1665 | } | |
| 1666 | ||
| 1667 | const commandOverride = settings.commandPath.trim(); | |
| 1668 | if (commandOverride.length > 0) { | |
| 1669 | const available = | |
| 1670 | resolveExecutable(commandOverride, platform, environment ?? hostEnvironment) !== | |
| 1671 | undefined; | |
| 1672 | return available | |
| 1673 | ? ({ | |
| 1674 | status: "ready", | |
| 1675 | agentId, | |
| 1676 | // The catalog version does not describe an overridden executable. | |
| 1677 | version: null, | |
| 1678 | distribution: distribution.kind, | |
| 1679 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1680 | } as const) | |
| 1681 | : ({ | |
| 1682 | status: "missing_runner", | |
| 1683 | agentId, | |
| 1684 | version: agent.version, | |
| 1685 | distribution: distribution.kind, | |
| 1686 | runner: commandOverride, | |
| 1687 | } as const); | |
| 1688 | } | |
| 1689 | ||
| 1690 | if (distribution.kind === "binary") { | |
| 1691 | return yield* installSemaphore.withPermits(1)( | |
| 1692 | Effect.gen(function* () { | |
| 1693 | const paths = binaryPaths(agent, distribution.binaryTarget!); | |
| 1694 | if (paths === undefined) { | |
| 1695 | return { status: "unsupported", agentId, version: agent.version } as const; | |
| 1696 | } | |
| 1697 | yield* consumePreparedBinaryReservation(agent.id); | |
| 1698 | const exists = yield* fileSystem | |
| 1699 | .exists(paths.executablePath) | |
| 1700 | .pipe(Effect.orElseSucceed(() => false)); | |
| 1701 | if (!exists) { | |
| 1702 | return { | |
| 1703 | status: "unprepared", | |
| 1704 | agentId, | |
| 1705 | version: agent.version, | |
| 1706 | distribution: "binary", | |
| 1707 | } as const; | |
| 1708 | } | |
| 1709 | yield* assertExecutableInRoot(paths.installRoot, paths.executablePath).pipe( | |
| 1710 | Effect.mapError((cause) => | |
| 1711 | isAcpRegistryError(cause) | |
| 1712 | ? cause | |
| 1713 | : new AcpRegistryError({ | |
| 1714 | reason: "install_failed", | |
| 1715 | detail: `Could not validate cached ACP Registry agent ${agent.id}.`, | |
| 1716 | cause, | |
| 1717 | }), | |
| 1718 | ), | |
| 1719 | ); | |
| 1720 | return { | |
| 1721 | status: "ready", | |
| 1722 | agentId, | |
| 1723 | version: agent.version, | |
| 1724 | distribution: "binary", | |
| 1725 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1726 | } as const; | |
| 1727 | }), | |
| 1728 | ); | |
| 1729 | } | |
| 1730 | ||
| 1731 | const runner = packageManagerFor(distribution.kind)!; | |
| 1732 | const available = | |
| 1733 | resolveExecutable(runner, platform, environment ?? hostEnvironment) !== undefined; | |
| 1734 | return available | |
| 1735 | ? ({ | |
| 1736 | status: "ready", | |
| 1737 | agentId, | |
| 1738 | version: agent.version, | |
| 1739 | distribution: distribution.kind, | |
| 1740 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1741 | } as const) | |
| 1742 | : ({ | |
| 1743 | status: "missing_runner", | |
| 1744 | agentId, | |
| 1745 | version: agent.version, | |
| 1746 | distribution: distribution.kind, | |
| 1747 | runner, | |
| 1748 | } as const); | |
| 1749 | }); | |
| 1750 | ||
| 1751 | const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => | |
| 1752 | Effect.gen(function* () { | |
| 1753 | if (settings.source === "local") { | |
| 1754 | const executable = settings.commandPath.trim(); | |
| 1755 | if (!executable) { | |
| 1756 | return yield* new AcpRegistryError({ | |
| 1757 | reason: "agent_not_configured", | |
| 1758 | detail: "Local ACP provider requires an executable.", | |
| 1759 | }); | |
| 1760 | } | |
| 1761 | const env = environment ?? hostEnvironment; | |
| 1762 | const command = resolveExecutable(executable, platform, env); | |
| 1763 | if (command === undefined) { | |
| 1764 | return yield* new AcpRegistryError({ | |
| 1765 | reason: "runner_unavailable", | |
| 1766 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 1767 | }); | |
| 1768 | } | |
| 1769 | yield* validateLocalExecutable(command); | |
| 1770 | return { | |
| 1771 | distribution: "local", | |
| 1772 | spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, | |
| 1773 | } satisfies ResolvedAcpRegistryAgent; | |
| 1774 | } | |
| 1775 | const agentId = settings.agentId.trim(); | |
| 1776 | if (agentId.length === 0) { | |
| 1777 | return yield* new AcpRegistryError({ | |
| 1778 | reason: "agent_not_configured", | |
| 1779 | detail: "ACP Registry provider requires a registry agent ID.", | |
| 1780 | }); | |
| 1781 | } | |
| 1782 | const registry = yield* loadRegistry(); | |
| 1783 | const agent = yield* findAgent(registry, agentId); | |
| 1784 | const distribution = yield* compatibleDistribution(agent, settings.distribution); | |
| 1785 | ||
| 1786 | let command: string; | |
| 1787 | let args: ReadonlyArray<string>; | |
| 1788 | let commandBinDirectory: string | undefined; | |
| 1789 | const effectiveEnvironment = environment ?? hostEnvironment; | |
| 1790 | const commandOverride = settings.commandPath.trim(); | |
| 1791 | if (commandOverride.length > 0) { | |
| 1792 | const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment); | |
| 1793 | if (resolvedOverride === undefined) { | |
| 1794 | return yield* new AcpRegistryError({ | |
| 1795 | reason: "runner_unavailable", | |
| 1796 | detail: `ACP Registry agent ${agent.id} requires '${commandOverride}', but it is not available on this provider instance's PATH.`, | |
| 1797 | }); | |
| 1798 | } | |
| 1799 | command = resolvedOverride; | |
| 1800 | args = distribution.args; | |
| 1801 | } else if (distribution.kind === "npx" || distribution.kind === "uvx") { | |
| 1802 | const installed = yield* installSemaphore.withPermits(1)( | |
| 1803 | ensureGlobalPackage( | |
| 1804 | agent, | |
| 1805 | distribution.kind, | |
| 1806 | distribution.packageName!, | |
| 1807 | effectiveEnvironment, | |
| 1808 | ), | |
| 1809 | ); | |
| 1810 | command = installed.executablePath; | |
| 1811 | args = distribution.args; | |
| 1812 | commandBinDirectory = installed.binDirectory; | |
| 1813 | } else { | |
| 1814 | const target = distribution.binaryTarget!; | |
| 1815 | command = yield* installSemaphore.withPermits(1)( | |
| 1816 | installBinary(agent, target).pipe( | |
| 1817 | Effect.tap(() => consumePreparedBinaryReservation(agent.id)), | |
| 1818 | ), | |
| 1819 | ); | |
| 1820 | args = distribution.args; | |
| 1821 | } | |
| 1822 | ||
| 1823 | const baseSpawnEnvironment = { | |
| 1824 | ...effectiveEnvironment, | |
| 1825 | ...distribution.env, | |
| 1826 | }; | |
| 1827 | const spawnEnvironment = | |
| 1828 | commandBinDirectory === undefined | |
| 1829 | ? baseSpawnEnvironment | |
| 1830 | : withPreferredPath(baseSpawnEnvironment, commandBinDirectory, platform); | |
| 1831 | ||
| 1832 | return { | |
| 1833 | agent, | |
| 1834 | distribution: distribution.kind, | |
| 1835 | spawn: { | |
| 1836 | command, | |
| 1837 | args, | |
| 1838 | cwd, | |
| 1839 | env: spawnEnvironment, | |
| 1840 | }, | |
| 1841 | } satisfies ResolvedAcpRegistryAgent; | |
| 1842 | }); | |
| 1843 | ||
| 1844 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => | |
| 1845 | serverSettings | |
| 1846 | .withSettingsSnapshot((settings) => | |
| 1847 | installSemaphore.withPermits(1)( | |
| 1848 | Effect.gen(function* () { | |
| 1849 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( | |
| 1850 | Effect.mapError( | |
| 1851 | (cause) => | |
| 1852 | new AcpRegistryError({ | |
| 1853 | reason: "install_failed", | |
| 1854 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", | |
| 1855 | cause, | |
| 1856 | }), | |
| 1857 | ), | |
| 1858 | ); | |
| 1859 | const isReferenced = Object.values(settings.providerInstances).some((instance) => { | |
| 1860 | if ( | |
| 1861 | instance.driver !== "acpRegistry" || | |
| 1862 | instance.config === null || | |
| 1863 | typeof instance.config !== "object" | |
| 1864 | ) | |
| 1865 | return false; | |
| 1866 | const config = instance.config as Record<string, unknown>; | |
| 1867 | return ( | |
| 1868 | config.source !== "local" && | |
| 1869 | typeof config.agentId === "string" && | |
| 1870 | config.agentId.trim() === safeAgentId | |
| 1871 | ); | |
| 1872 | }); | |
| 1873 | if (isReferenced) { | |
| 1874 | yield* consumePreparedBinaryReservation(safeAgentId); | |
| 1875 | return { agentId: safeAgentId, removed: false }; | |
| 1876 | } | |
| 1877 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { | |
| 1878 | return { agentId: safeAgentId, removed: false }; | |
| 1879 | } | |
| 1880 | const agentRoot = path.join(installsDirectory, safeAgentId); | |
| 1881 | const existed = yield* fileSystem.exists(agentRoot).pipe( | |
| 1882 | Effect.mapError( | |
| 1883 | (cause) => | |
| 1884 | new AcpRegistryError({ | |
| 1885 | reason: "install_failed", | |
| 1886 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, | |
| 1887 | cause, | |
| 1888 | }), | |
| 1889 | ), | |
| 1890 | ); | |
| 1891 | if (!existed) return { agentId: safeAgentId, removed: false }; | |
| 1892 | ||
| 1893 | let removed = false; | |
| 1894 | yield* Effect.gen(function* () { | |
| 1895 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | |
| 1896 | const versionRoot = path.join(agentRoot, version); | |
| 1897 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | |
| 1898 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | |
| 1899 | yield* fileSystem.remove(path.join(versionRoot, entry), { | |
| 1900 | recursive: true, | |
| 1901 | force: true, | |
| 1902 | }); | |
| 1903 | removed = true; | |
| 1904 | } | |
| 1905 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | |
| 1906 | yield* fileSystem.remove(versionRoot, { recursive: true }); | |
| 1907 | } | |
| 1908 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | |
| 1909 | yield* fileSystem.remove(agentRoot, { recursive: true }); | |
| 1910 | }).pipe( | |
| 1911 | Effect.mapError( | |
| 1912 | (cause) => | |
| 1913 | new AcpRegistryError({ | |
| 1914 | reason: "install_failed", | |
| 1915 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | |
| 1916 | cause, | |
| 1917 | }), | |
| 1918 | ), | |
| 1919 | ); | |
| 1920 | return { agentId: safeAgentId, removed }; | |
| 1921 | }), | |
| 1922 | ), | |
| 1923 | ) | |
| 1924 | .pipe( | |
| 1925 | Effect.catchTags({ | |
| 1926 | ServerSettingsError: (cause) => | |
| 1927 | new AcpRegistryError({ | |
| 1928 | reason: "install_failed", | |
| 1929 | detail: | |
| 1930 | "Could not read provider settings while checking managed ACP binary references.", | |
| 1931 | cause, | |
| 1932 | }), | |
| 1933 | }), | |
| 1934 | ); | |
| 1935 | ||
| 1936 | return AcpRegistryCatalog.of({ | |
| 1937 | search, | |
| 1938 | prepare, | |
| 1939 | inspect, | |
| 1940 | resolve, | |
| 1941 | uninstallManagedBinary, | |
| 1942 | }); | |
| 1943 | }); |
apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh deleted-21| ... | ... | @@ -1,21 +0,0 @@ |
| 1 | #!/bin/sh | |
| 2 | printf '%s\n' "$*" >> "$FAKE_NPM_LOG" | |
| 3 | prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}" | |
| 4 | if [ "$1" = "root" ] && [ "$2" = "--global" ]; then | |
| 5 | printf '%s\n' "$prefix/lib/node_modules" | |
| 6 | exit 0 | |
| 7 | fi | |
| 8 | if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then | |
| 9 | printf '%s\n' "$prefix" | |
| 10 | exit 0 | |
| 11 | fi | |
| 12 | if [ "$1" = "install" ] && [ "$2" = "--global" ]; then | |
| 13 | package_root="$prefix/lib/node_modules/@example/acp" | |
| 14 | executable="$prefix/bin/example-agent" | |
| 15 | mkdir -p "$package_root" "$prefix/bin" | |
| 16 | printf '%s' "$FAKE_NPM_MANIFEST" > "$package_root/package.json" | |
| 17 | printf '#!/bin/sh\n' > "$executable" | |
| 18 | chmod 755 "$executable" | |
| 19 | exit 0 | |
| 20 | fi | |
| 21 | exit 64 |
apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh deleted-22| ... | ... | @@ -1,22 +0,0 @@ |
| 1 | #!/bin/sh | |
| 2 | printf '%s\n' "$*" >> "$FAKE_UV_LOG" | |
| 3 | tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}" | |
| 4 | executable="$tool_bin/fast-agent" | |
| 5 | printf "tool-dir=%s bin-dir=%s\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG" | |
| 6 | if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then | |
| 7 | printf '%s\n' "$tool_bin" | |
| 8 | exit 0 | |
| 9 | fi | |
| 10 | if [ "$1" = "tool" ] && [ "$2" = "list" ]; then | |
| 11 | if [ -x "$executable" ]; then | |
| 12 | printf 'fast-agent-acp v0.10.1\n- fast-agent\n' | |
| 13 | fi | |
| 14 | exit 0 | |
| 15 | fi | |
| 16 | if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then | |
| 17 | mkdir -p "$tool_bin" | |
| 18 | printf '#!/bin/sh\n' > "$executable" | |
| 19 | chmod 755 "$executable" | |
| 20 | exit 0 | |
| 21 | fi | |
| 22 | exit 64 |
apps/server/src/provider/builtInDrivers.ts+4-1| ... | ... | @@ -20,7 +20,10 @@ |
| 20 | 20 | * |
| 21 | 21 | * @module provider/builtInDrivers |
| 22 | 22 | */ |
| 23 | import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; | |
| 23 | import { | |
| 24 | AcpRegistryDriver, | |
| 25 | type AcpRegistryDriverEnv, | |
| 26 | } from "@t3tools/provider-acp-registry/server"; | |
| 24 | 27 | import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; |
| 25 | 28 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 26 | 29 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
apps/server/src/server.ts+3-2| ... | ... | @@ -45,7 +45,8 @@ import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; |
| 45 | 45 | import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; |
| 46 | 46 | import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; |
| 47 | 47 | import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; |
| 48 | import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; | |
| 48 | import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; | |
| 49 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; | |
| 49 | 50 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 50 | 51 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 51 | 52 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| ... | ... | @@ -597,7 +598,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 597 | 598 | Layer.provideMerge(layerPtyAdapter), |
| 598 | 599 | // Search, prepare, status inspection, and turn launch share one registry |
| 599 | 600 | // cache so every client and provider instance sees the same prepared agents. |
| 600 | Layer.provideMerge(AcpRegistryCatalog.layer.pipe(Layer.provide(layerServerSettings))), | |
| 601 | Layer.provideMerge(AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer))), | |
| 601 | 602 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 602 | 603 | // V2 drivers and the orchestration runtime. Provide resource attribution so |
| 603 | 604 | // the rewritten telemetry pipeline can account for logical NDJSON writes. |
apps/server/src/terminal/Manager.test.ts+1-1| ... | ... | @@ -41,7 +41,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 41 | 41 | import * as ProcessRunner from "../processRunner.ts"; |
| 42 | 42 | import * as ServerSettings from "../serverSettings.ts"; |
| 43 | 43 | import * as TerminalManager from "./Manager.ts"; |
| 44 | import * as PtyAdapter from "./PtyAdapter.ts"; | |
| 44 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 45 | 45 | |
| 46 | 46 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 47 | 47 |
apps/server/src/terminal/Manager.ts+2-2| ... | ... | @@ -45,7 +45,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; |
| 45 | 45 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 46 | 46 | import { mergePathEntries } from "@t3tools/shared/shell"; |
| 47 | 47 | |
| 48 | import { acpRegistryManagedBinaryDirectories } from "../provider/acp/AcpRegistrySupport.ts"; | |
| 48 | import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; | |
| 49 | 49 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 50 | 50 | import * as DateTime from "effect/DateTime"; |
| 51 | 51 | import * as Context from "effect/Context"; |
| ... | ... | @@ -77,7 +77,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 77 | 77 | import * as ProcessRunner from "../processRunner.ts"; |
| 78 | 78 | import * as PortScanner from "../preview/PortScanner.ts"; |
| 79 | 79 | import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; |
| 80 | import * as PtyAdapter from "./PtyAdapter.ts"; | |
| 80 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 81 | 81 | |
| 82 | 82 | export { |
| 83 | 83 | TerminalCwdError, |
apps/server/src/terminal/NodePtyAdapter.test.ts+1-1| ... | ... | @@ -14,7 +14,7 @@ import * as Scheduler from "effect/Scheduler"; |
| 14 | 14 | import { expect, vi } from "vite-plus/test"; |
| 15 | 15 | |
| 16 | 16 | import * as NodePtyAdapter from "./NodePtyAdapter.ts"; |
| 17 | import * as PtyAdapter from "./PtyAdapter.ts"; | |
| 17 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 18 | 18 | |
| 19 | 19 | function makeNativeProcess(pid = 42) { |
| 20 | 20 | const events = new NodeEvents.EventEmitter(); |
apps/server/src/terminal/NodePtyAdapter.ts+1-1| ... | ... | @@ -9,7 +9,7 @@ import * as Path from "effect/Path"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 11 | 11 | |
| 12 | import * as PtyAdapter from "./PtyAdapter.ts"; | |
| 12 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 13 | 13 | |
| 14 | 14 | export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoadError>()( |
| 15 | 15 | "NodePtyModuleLoadError", |
apps/server/src/terminal/PtyAdapter.ts deleted-66| ... | ... | @@ -1,66 +0,0 @@ |
| 1 | /** | |
| 2 | * PtyAdapter - Terminal PTY adapter service contract. | |
| 3 | * | |
| 4 | * Defines the process primitives required by terminal session management | |
| 5 | * without binding to a specific PTY implementation. | |
| 6 | * | |
| 7 | * @module PtyAdapter | |
| 8 | */ | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | /** | |
| 14 | * PtySpawnError - Error type for PTY spawn failures. | |
| 15 | */ | |
| 16 | export class PtySpawnError extends Schema.TaggedError<PtySpawnError>()("PtySpawnError", { | |
| 17 | adapter: Schema.String, | |
| 18 | shell: Schema.optional(Schema.String), | |
| 19 | attemptedShells: Schema.optional(Schema.Array(Schema.String)), | |
| 20 | cause: Schema.optional(Schema.Defect()), | |
| 21 | }) { | |
| 22 | override get message(): string { | |
| 23 | const shell = this.shell === undefined ? "" : ` '${this.shell}'`; | |
| 24 | const attemptedShells = | |
| 25 | this.attemptedShells === undefined || this.attemptedShells.length === 0 | |
| 26 | ? "" | |
| 27 | : ` Tried shells: ${this.attemptedShells.join(", ")}.`; | |
| 28 | return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export interface PtyExitEvent { | |
| 33 | exitCode: number; | |
| 34 | signal: number | null; | |
| 35 | } | |
| 36 | ||
| 37 | export interface PtyProcess { | |
| 38 | readonly pid: number; | |
| 39 | write(data: string): void; | |
| 40 | resize(cols: number, rows: number): void; | |
| 41 | kill(signal?: string): void; | |
| 42 | onData(callback: (data: string) => void): () => void; | |
| 43 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 44 | } | |
| 45 | ||
| 46 | export interface PtySpawnInput { | |
| 47 | shell: string; | |
| 48 | args?: string[]; | |
| 49 | cwd: string; | |
| 50 | cols: number; | |
| 51 | rows: number; | |
| 52 | env: NodeJS.ProcessEnv; | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * PtyAdapter - Service tag for PTY process integration. | |
| 57 | */ | |
| 58 | export class PtyAdapter extends Context.Service< | |
| 59 | PtyAdapter, | |
| 60 | { | |
| 61 | /** | |
| 62 | * Spawn a PTY process for a terminal session. | |
| 63 | */ | |
| 64 | readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>; | |
| 65 | } | |
| 66 | >()("t3/terminal/PtyAdapter") {} |
apps/server/src/ws.ts+2-2| ... | ... | @@ -165,8 +165,8 @@ import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore. |
| 165 | 165 | import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; |
| 166 | 166 | import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; |
| 167 | 167 | import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; |
| 168 | import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; | |
| 169 | import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; | |
| 168 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; | |
| 169 | import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; | |
| 170 | 170 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 171 | 171 | import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; |
| 172 | 172 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
apps/web/package.json+1| ... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 | "@pierre/trees": "1.0.0-beta.6", |
| 29 | 29 | "@t3tools/client-runtime": "workspace:*", |
| 30 | 30 | "@t3tools/contracts": "workspace:*", |
| 31 | "@t3tools/provider-acp-registry": "workspace:*", | |
| 31 | 32 | "@t3tools/provider-core": "workspace:*", |
| 32 | 33 | "@t3tools/provider-cursor": "workspace:*", |
| 33 | 34 | "@t3tools/provider-grok": "workspace:*", |
apps/web/src/components/settings/providerDriverMeta.ts+2-7| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | import { |
| 2 | AcpRegistrySettings, | |
| 3 | 2 | AntigravitySettings, |
| 4 | 3 | ClaudeSettings, |
| 5 | 4 | CodexSettings, |
| 6 | 5 | ProviderDriverKind, |
| 7 | 6 | } from "@t3tools/contracts"; |
| 7 | import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; | |
| 8 | 8 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 9 | 9 | import { cursorClient } from "@t3tools/provider-cursor/client"; |
| 10 | 10 | import { grokClient } from "@t3tools/provider-grok/client"; |
| ... | ... | @@ -34,10 +34,5 @@ export const providerClients = makeProviderClientRegistry([ |
| 34 | 34 | }, |
| 35 | 35 | museClient, |
| 36 | 36 | piClient, |
| 37 | { | |
| 38 | driverKind: ProviderDriverKind.make("acpRegistry"), | |
| 39 | label: "ACP Registry", | |
| 40 | settingsSchema: AcpRegistrySettings, | |
| 41 | hasDefaultInstance: false, | |
| 42 | }, | |
| 37 | acpRegistryClient, | |
| 43 | 38 | ]); |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 31 | "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/contracts/src/settings.ts-67| ... | ... | @@ -788,73 +788,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( |
| 788 | 788 | ); |
| 789 | 789 | export type AntigravitySettings = typeof AntigravitySettings.Type; |
| 790 | 790 | |
| 791 | export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); | |
| 792 | export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; | |
| 793 | ||
| 794 | export const AcpRegistrySettings = makeProviderSettingsSchema( | |
| 795 | { | |
| 796 | source: Schema.Literals(["registry", "local"]).pipe( | |
| 797 | Schema.withDecodingDefault(Effect.succeed("registry")), | |
| 798 | Schema.annotateKey({ | |
| 799 | title: "ACP source", | |
| 800 | providerSettingsForm: { | |
| 801 | control: "select", | |
| 802 | options: [ | |
| 803 | { value: "registry", label: "ACP Registry" }, | |
| 804 | { value: "local", label: "Local command" }, | |
| 805 | ], | |
| 806 | }, | |
| 807 | }), | |
| 808 | ), | |
| 809 | enabled: Schema.Boolean.pipe( | |
| 810 | Schema.withDecodingDefault(Effect.succeed(true)), | |
| 811 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 812 | ), | |
| 813 | agentId: TrimmedString.pipe( | |
| 814 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 815 | Schema.annotateKey({ | |
| 816 | title: "Registry agent ID", | |
| 817 | description: "Agent identifier from the official ACP Registry, for example 'devin'.", | |
| 818 | providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, | |
| 819 | }), | |
| 820 | ), | |
| 821 | commandPath: TrimmedString.pipe( | |
| 822 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 823 | Schema.annotateKey({ | |
| 824 | title: "Executable override", | |
| 825 | description: | |
| 826 | "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", | |
| 827 | providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, | |
| 828 | }), | |
| 829 | ), | |
| 830 | commandArgs: Schema.Array(Schema.String).pipe( | |
| 831 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 832 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 833 | ), | |
| 834 | authMethodId: TrimmedString.pipe( | |
| 835 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 836 | Schema.annotateKey({ | |
| 837 | title: "Authentication method", | |
| 838 | description: | |
| 839 | "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", | |
| 840 | providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, | |
| 841 | }), | |
| 842 | ), | |
| 843 | distribution: AcpRegistryDistributionPreference.pipe( | |
| 844 | Schema.withDecodingDefault(Effect.succeed("auto")), | |
| 845 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 846 | ), | |
| 847 | customModels: Schema.Array(Schema.String).pipe( | |
| 848 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 849 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 850 | ), | |
| 851 | }, | |
| 852 | { | |
| 853 | order: ["source", "agentId", "commandPath", "authMethodId"], | |
| 854 | }, | |
| 855 | ); | |
| 856 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; | |
| 857 | ||
| 858 | 791 | /** |
| 859 | 792 | * A read-only quota source outside this environment's provider CLIs. The |
| 860 | 793 | * only kind today is a CLIProxyAPI hub, whose management API reports the |
packages/provider-acp-registry/package.json created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/provider-acp-registry", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./client": { | |
| 7 | "types": "./src/client.ts", | |
| 8 | "import": "./src/client.ts" | |
| 9 | }, | |
| 10 | "./settings": { | |
| 11 | "types": "./src/settings.ts", | |
| 12 | "import": "./src/settings.ts" | |
| 13 | }, | |
| 14 | "./server": { | |
| 15 | "types": "./src/server.ts", | |
| 16 | "import": "./src/server.ts" | |
| 17 | }, | |
| 18 | "./testing": { | |
| 19 | "types": "./src/testing.ts", | |
| 20 | "import": "./src/testing.ts" | |
| 21 | }, | |
| 22 | "./server/AcpRegistrySupport": { | |
| 23 | "types": "./src/server/AcpRegistrySupport.ts", | |
| 24 | "import": "./src/server/AcpRegistrySupport.ts" | |
| 25 | }, | |
| 26 | "./server/AcpRegistryRuntimeCoordinator": { | |
| 27 | "types": "./src/server/AcpRegistryRuntimeCoordinator.ts", | |
| 28 | "import": "./src/server/AcpRegistryRuntimeCoordinator.ts" | |
| 29 | } | |
| 30 | }, | |
| 31 | "scripts": { | |
| 32 | "typecheck": "tsc --noEmit", | |
| 33 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 34 | }, | |
| 35 | "dependencies": { | |
| 36 | "@noble/hashes": "catalog:", | |
| 37 | "@t3tools/contracts": "workspace:*", | |
| 38 | "@t3tools/provider-acp": "workspace:*", | |
| 39 | "@t3tools/provider-core": "workspace:*", | |
| 40 | "@t3tools/shared": "workspace:*", | |
| 41 | "effect": "catalog:" | |
| 42 | }, | |
| 43 | "devDependencies": { | |
| 44 | "@effect/platform-node": "catalog:", | |
| 45 | "@effect/vitest": "catalog:", | |
| 46 | "@t3tools/provider-testing": "workspace:*", | |
| 47 | "@types/node": "catalog:", | |
| 48 | "effect-acp": "workspace:*", | |
| 49 | "vite-plus": "catalog:" | |
| 50 | } | |
| 51 | } |
packages/provider-acp-registry/src/client.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | /** | |
| 2 | * ACP Registry's client definition: settings form, label, and the registry | |
| 3 | * glyph clients fall back to while an agent's own icon loads. | |
| 4 | * Browser- and React Native-safe. | |
| 5 | * | |
| 6 | * @module provider-acp-registry/client | |
| 7 | */ | |
| 8 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 9 | import { defineProviderClient } from "@t3tools/provider-core/client"; | |
| 10 | ||
| 11 | import { AcpRegistrySettings } from "./settings.ts"; | |
| 12 | ||
| 13 | export const acpRegistryClient = defineProviderClient({ | |
| 14 | driverKind: ProviderDriverKind.make("acpRegistry"), | |
| 15 | label: "ACP Registry", | |
| 16 | settingsSchema: AcpRegistrySettings, | |
| 17 | // Each instance is an agent the user adds from the registry. | |
| 18 | hasDefaultInstance: false, | |
| 19 | icon: { | |
| 20 | viewBox: "0 0 576 220", | |
| 21 | fill: { light: "#000000", dark: "#FFFFFF" }, | |
| 22 | paths: [ | |
| 23 | { | |
| 24 | d: "M568.003 115.821L517.278 27.9661C507.183 10.4816 489.084 0.0227367 468.894 0.0227367C448.727 0.0227367 430.674 10.4361 420.556 27.8752L343.251 161.749H242.755C236.23 161.749 230.386 158.384 227.135 152.745C223.861 147.106 223.861 140.376 227.135 134.715L277.861 46.8603C281.112 41.2216 286.955 37.8338 293.481 37.8338C300.006 37.8338 305.827 41.1988 309.101 46.8603L312.125 52.0897C313.353 54.2042 315.604 55.5002 318.036 55.5002C320.469 55.5002 322.743 54.1815 323.948 52.067L337.385 28.5118C338.795 26.0335 338.522 22.9413 336.703 20.7586C325.699 7.57131 309.874 0 293.322 0C292.662 0 292.003 0 291.321 0.0454733C272.04 0.75031 254.76 11.1864 245.074 27.9434L200.215 105.657L155.81 29.1484C145.465 11.2092 126.594 0.0227367 106.608 0.0227367C105.949 0.0227367 105.289 0.0227367 104.607 0.06821C85.3265 0.773047 68.0467 11.2092 58.3608 27.9661L7.65806 115.821C-6.25678 139.899 -0.868187 168.82 21.05 187.759C29.8945 195.422 41.6039 199.628 54.0181 199.628H148.648C151.081 199.628 153.332 198.332 154.56 196.217L168.52 172.026C169.748 169.911 169.748 167.319 168.52 165.205C167.292 163.09 165.041 161.794 162.608 161.794H56.0417C49.5163 161.794 43.6729 158.429 40.4216 152.79C37.1475 147.152 37.1475 140.422 40.4216 134.76L91.1471 46.9057C94.3985 41.2671 100.242 37.8793 106.767 37.8793C113.293 37.8793 119.113 41.2443 122.387 46.9057L194.826 172.526C195.031 172.89 195.258 173.208 195.531 173.526C198.76 178.665 202.83 183.485 207.786 187.782C216.631 195.444 228.34 199.651 240.754 199.651H321.424L315.581 209.769C314.353 211.883 314.353 214.475 315.581 216.59C316.809 218.704 319.059 220 321.492 220H349.436C351.868 220 354.119 218.704 355.347 216.59L364.396 200.901L367.17 196.468C367.17 196.468 367.261 196.331 367.284 196.263L453.274 46.9285C456.525 41.2898 462.369 37.902 468.894 37.902C475.42 37.902 481.263 41.2671 484.514 46.9285L535.24 134.783C538.491 140.422 538.514 147.174 535.24 152.813C531.988 158.452 526.145 161.84 519.62 161.84H418.669C416.236 161.84 413.985 163.136 412.757 165.25L398.774 189.442C397.546 191.556 397.546 194.148 398.774 196.263C400.002 198.377 402.253 199.673 404.686 199.673H518.21C539.81 199.673 559.295 188.237 569.026 169.843C578.053 152.79 577.666 132.623 567.981 115.843L568.003 115.821Z", | |
| 25 | }, | |
| 26 | ], | |
| 27 | }, | |
| 28 | }); |
packages/provider-acp-registry/src/server.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | /** | |
| 2 | * ACP Registry's server entry: the driver and adapter driver the server | |
| 3 | * registers, the catalog layer it provides once, and the managed-binary | |
| 4 | * directories the terminal manager keeps off agent PATHs. | |
| 5 | * | |
| 6 | * The catalog and runtime coordinator are services; import them from | |
| 7 | * `./server/AcpRegistrySupport` and `./server/AcpRegistryRuntimeCoordinator`. | |
| 8 | * | |
| 9 | * @module provider-acp-registry/server | |
| 10 | */ | |
| 11 | export { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./server/driver.ts"; | |
| 12 | export { | |
| 13 | AcpRegistryAdapterV2Driver, | |
| 14 | type AcpRegistryAdapterV2DriverEnv, | |
| 15 | } from "./server/adapter.ts"; | |
| 16 | export { acpRegistryManagedBinaryDirectories } from "./server/AcpRegistrySupport.ts"; |
packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.test.ts created+336| ... | ... | @@ -0,0 +1,336 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | ||
| 9 | import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts"; | |
| 10 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 11 | ||
| 12 | describe("AcpRegistryRuntimeCoordinator", () => { | |
| 13 | it.effect("suppresses a background probe while foreground startup is active", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 16 | const foregroundStarted = yield* Deferred.make<void>(); | |
| 17 | const releaseForeground = yield* Deferred.make<void>(); | |
| 18 | const foreground = yield* coordinator | |
| 19 | .withForegroundStartup( | |
| 20 | "kilo", | |
| 21 | Deferred.succeed(foregroundStarted, undefined).pipe( | |
| 22 | Effect.andThen(Deferred.await(releaseForeground)), | |
| 23 | ), | |
| 24 | ) | |
| 25 | .pipe(Effect.forkChild); | |
| 26 | yield* Deferred.await(foregroundStarted); | |
| 27 | ||
| 28 | const probed = yield* coordinator.runBackgroundProbe("kilo", Effect.succeed("unexpected")); | |
| 29 | expect(Option.isNone(probed)).toBe(true); | |
| 30 | ||
| 31 | yield* Deferred.succeed(releaseForeground, undefined); | |
| 32 | yield* Fiber.join(foreground); | |
| 33 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 34 | ); | |
| 35 | ||
| 36 | it.effect("interrupts an active background probe when foreground startup begins", () => | |
| 37 | Effect.gen(function* () { | |
| 38 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 39 | const probeStarted = yield* Deferred.make<void>(); | |
| 40 | const probeFinalized = yield* Deferred.make<void>(); | |
| 41 | const releaseForeground = yield* Deferred.make<void>(); | |
| 42 | const probe = yield* coordinator | |
| 43 | .runBackgroundProbe( | |
| 44 | "kilo", | |
| 45 | Deferred.succeed(probeStarted, undefined).pipe( | |
| 46 | Effect.andThen(Effect.never), | |
| 47 | Effect.ensuring(Deferred.succeed(probeFinalized, undefined).pipe(Effect.ignore)), | |
| 48 | ), | |
| 49 | ) | |
| 50 | .pipe(Effect.forkChild); | |
| 51 | yield* Deferred.await(probeStarted); | |
| 52 | ||
| 53 | const foreground = yield* coordinator | |
| 54 | .withForegroundStartup("kilo", Deferred.await(releaseForeground)) | |
| 55 | .pipe(Effect.forkChild); | |
| 56 | expect(Option.isNone(yield* Fiber.join(probe))).toBe(true); | |
| 57 | yield* Deferred.await(probeFinalized); | |
| 58 | ||
| 59 | yield* Deferred.succeed(releaseForeground, undefined); | |
| 60 | yield* Fiber.join(foreground); | |
| 61 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 62 | ); | |
| 63 | ||
| 64 | it.effect("serializes native session mutations across callers", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 67 | const firstEntered = yield* Deferred.make<void>(); | |
| 68 | const releaseFirst = yield* Deferred.make<void>(); | |
| 69 | const secondEntered = yield* Deferred.make<void>(); | |
| 70 | const first = yield* coordinator | |
| 71 | .withSessionMutation( | |
| 72 | Deferred.succeed(firstEntered, undefined).pipe( | |
| 73 | Effect.andThen(Deferred.await(releaseFirst)), | |
| 74 | ), | |
| 75 | ) | |
| 76 | .pipe(Effect.forkChild); | |
| 77 | yield* Deferred.await(firstEntered); | |
| 78 | ||
| 79 | const second = yield* coordinator | |
| 80 | .withSessionMutation(Deferred.succeed(secondEntered, undefined)) | |
| 81 | .pipe(Effect.forkChild); | |
| 82 | expect(yield* Deferred.poll(secondEntered)).toEqual(Option.none()); | |
| 83 | ||
| 84 | yield* Deferred.succeed(releaseFirst, undefined); | |
| 85 | yield* Fiber.join(first); | |
| 86 | yield* Fiber.join(second); | |
| 87 | expect(Option.isSome(yield* Deferred.poll(secondEntered))).toBe(true); | |
| 88 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 89 | ); | |
| 90 | ||
| 91 | it.effect("replays and replaces late command advertisements per provider instance", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 94 | const codex = ProviderInstanceId.make("acpRegistry_codex"); | |
| 95 | const kilo = ProviderInstanceId.make("acpRegistry_kilo"); | |
| 96 | const firstSeen = yield* Deferred.make<void>(); | |
| 97 | const replacementSeen = yield* Deferred.make<void>(); | |
| 98 | const received: Array<AcpRegistryAvailableCommands> = []; | |
| 99 | ||
| 100 | yield* coordinator.publishAvailableCommands(codex, { | |
| 101 | slashCommands: [{ name: "status" }], | |
| 102 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 103 | }); | |
| 104 | const consumer = yield* coordinator | |
| 105 | .watchAvailableCommands(codex, (commands) => | |
| 106 | Effect.gen(function* () { | |
| 107 | received.push(commands); | |
| 108 | yield* received.length === 1 | |
| 109 | ? Deferred.succeed(firstSeen, undefined) | |
| 110 | : Deferred.succeed(replacementSeen, undefined); | |
| 111 | }), | |
| 112 | ) | |
| 113 | .pipe(Effect.forkChild); | |
| 114 | yield* Deferred.await(firstSeen); | |
| 115 | ||
| 116 | yield* coordinator.publishAvailableCommands(kilo, { | |
| 117 | slashCommands: [{ name: "review" }], | |
| 118 | skills: [], | |
| 119 | }); | |
| 120 | yield* coordinator.publishAvailableCommands(codex, { slashCommands: [], skills: [] }); | |
| 121 | yield* Deferred.await(replacementSeen); | |
| 122 | yield* Fiber.interrupt(consumer); | |
| 123 | ||
| 124 | expect(received).toEqual([ | |
| 125 | { | |
| 126 | slashCommands: [{ name: "status" }], | |
| 127 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 128 | }, | |
| 129 | { slashCommands: [], skills: [] }, | |
| 130 | ]); | |
| 131 | expect(yield* coordinator.getAvailableCommands(kilo)).toEqual( | |
| 132 | Option.some({ slashCommands: [{ name: "review" }], skills: [] }), | |
| 133 | ); | |
| 134 | yield* coordinator.clearAvailableCommands(codex); | |
| 135 | expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true); | |
| 136 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("replays and replaces live configuration per provider instance", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 142 | const instanceId = ProviderInstanceId.make("acpRegistry_kilo"); | |
| 143 | const firstSeen = yield* Deferred.make<void>(); | |
| 144 | const replacementSeen = yield* Deferred.make<void>(); | |
| 145 | const received: Array<AcpRegistryLiveConfiguration> = []; | |
| 146 | const first = { | |
| 147 | models: [{ id: "sonnet", name: "Sonnet", description: null }], | |
| 148 | currentModelId: "sonnet", | |
| 149 | configOptions: [], | |
| 150 | } satisfies AcpRegistryLiveConfiguration; | |
| 151 | const replacement = { | |
| 152 | models: [{ id: "opus", name: "Opus", description: null }], | |
| 153 | currentModelId: "opus", | |
| 154 | configOptions: [], | |
| 155 | } satisfies AcpRegistryLiveConfiguration; | |
| 156 | ||
| 157 | yield* coordinator.publishLiveConfiguration(instanceId, first); | |
| 158 | const consumer = yield* coordinator | |
| 159 | .watchLiveConfiguration(instanceId, (configuration) => | |
| 160 | Effect.gen(function* () { | |
| 161 | received.push(configuration); | |
| 162 | yield* received.length === 1 | |
| 163 | ? Deferred.succeed(firstSeen, undefined) | |
| 164 | : Deferred.succeed(replacementSeen, undefined); | |
| 165 | }), | |
| 166 | ) | |
| 167 | .pipe(Effect.forkChild); | |
| 168 | yield* Deferred.await(firstSeen); | |
| 169 | yield* coordinator.publishLiveConfiguration(instanceId, replacement); | |
| 170 | yield* Deferred.await(replacementSeen); | |
| 171 | yield* Fiber.interrupt(consumer); | |
| 172 | ||
| 173 | expect(received).toEqual([first, replacement]); | |
| 174 | expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement)); | |
| 175 | yield* coordinator.clearLiveConfiguration(instanceId); | |
| 176 | expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true); | |
| 177 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 178 | ); | |
| 179 | ||
| 180 | it.effect("requires matching user consent before accepting URL authentication", () => | |
| 181 | Effect.gen(function* () { | |
| 182 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 183 | const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); | |
| 184 | const action = { | |
| 185 | elicitationId: "login-1", | |
| 186 | url: "https://accounts.example.com/login", | |
| 187 | message: "Continue in your browser", | |
| 188 | }; | |
| 189 | const actionSeen = yield* Deferred.make<void>(); | |
| 190 | const consumer = yield* coordinator | |
| 191 | .watchUrlAuthAction(instanceId, (current) => | |
| 192 | current === null | |
| 193 | ? Effect.void | |
| 194 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 195 | ) | |
| 196 | .pipe(Effect.forkChild); | |
| 197 | const request = yield* coordinator | |
| 198 | .requestUrlAuthentication(instanceId, action) | |
| 199 | .pipe(Effect.forkChild); | |
| 200 | yield* Deferred.await(actionSeen); | |
| 201 | ||
| 202 | expect(yield* coordinator.getUrlAuthAction(instanceId)).toEqual( | |
| 203 | Option.some({ | |
| 204 | ...action, | |
| 205 | createdAt: "1970-01-01T00:00:00.000Z", | |
| 206 | expiresAt: "1970-01-01T00:10:00.000Z", | |
| 207 | }), | |
| 208 | ); | |
| 209 | ||
| 210 | expect( | |
| 211 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "stale" }), | |
| 212 | ).toBe(false); | |
| 213 | expect( | |
| 214 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-1" }), | |
| 215 | ).toBe(true); | |
| 216 | expect(yield* Fiber.join(request)).toBe(true); | |
| 217 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 218 | yield* Fiber.interrupt(consumer); | |
| 219 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 220 | ); | |
| 221 | ||
| 222 | it.effect("expires stale URL authentication actions without accepting them", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 225 | const instanceId = ProviderInstanceId.make("acpRegistry_expired"); | |
| 226 | const actionSeen = yield* Deferred.make<void>(); | |
| 227 | const consumer = yield* coordinator | |
| 228 | .watchUrlAuthAction(instanceId, (current) => | |
| 229 | current === null | |
| 230 | ? Effect.void | |
| 231 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 232 | ) | |
| 233 | .pipe(Effect.forkChild); | |
| 234 | const request = yield* coordinator | |
| 235 | .requestUrlAuthentication(instanceId, { | |
| 236 | elicitationId: "expired-login", | |
| 237 | url: "https://accounts.example.com/login", | |
| 238 | message: "Continue in your browser", | |
| 239 | }) | |
| 240 | .pipe(Effect.forkChild); | |
| 241 | yield* Deferred.await(actionSeen); | |
| 242 | yield* TestClock.adjust("10 minutes"); | |
| 243 | ||
| 244 | expect(yield* Fiber.join(request)).toBe(false); | |
| 245 | expect( | |
| 246 | yield* coordinator.acceptUrlAuthentication({ | |
| 247 | instanceId, | |
| 248 | elicitationId: "expired-login", | |
| 249 | }), | |
| 250 | ).toBe(false); | |
| 251 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 252 | yield* Fiber.interrupt(consumer); | |
| 253 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 254 | ); | |
| 255 | ||
| 256 | it.effect("publishes a replacement URL action before retiring the previous request", () => | |
| 257 | Effect.gen(function* () { | |
| 258 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 259 | const instanceId = ProviderInstanceId.make("acpRegistry_replacement"); | |
| 260 | const received: Array<string | null> = []; | |
| 261 | const firstSeen = yield* Deferred.make<void>(); | |
| 262 | const replacementSeen = yield* Deferred.make<void>(); | |
| 263 | const consumer = yield* coordinator | |
| 264 | .watchUrlAuthAction(instanceId, (current) => | |
| 265 | Effect.sync(() => { | |
| 266 | received.push(current?.elicitationId ?? null); | |
| 267 | }).pipe( | |
| 268 | Effect.andThen( | |
| 269 | current?.elicitationId === "login-1" | |
| 270 | ? Deferred.succeed(firstSeen, undefined).pipe(Effect.asVoid) | |
| 271 | : current?.elicitationId === "login-2" | |
| 272 | ? Deferred.succeed(replacementSeen, undefined).pipe(Effect.asVoid) | |
| 273 | : Effect.void, | |
| 274 | ), | |
| 275 | ), | |
| 276 | ) | |
| 277 | .pipe(Effect.forkChild); | |
| 278 | const first = yield* coordinator | |
| 279 | .requestUrlAuthentication(instanceId, { | |
| 280 | elicitationId: "login-1", | |
| 281 | url: "https://accounts.example.com/first", | |
| 282 | message: "Continue with the first login", | |
| 283 | }) | |
| 284 | .pipe(Effect.forkChild); | |
| 285 | yield* Deferred.await(firstSeen); | |
| 286 | const replacement = yield* coordinator | |
| 287 | .requestUrlAuthentication(instanceId, { | |
| 288 | elicitationId: "login-2", | |
| 289 | url: "https://accounts.example.com/replacement", | |
| 290 | message: "Continue with the replacement login", | |
| 291 | }) | |
| 292 | .pipe(Effect.forkChild); | |
| 293 | yield* Deferred.await(replacementSeen); | |
| 294 | ||
| 295 | expect(yield* Fiber.join(first)).toBe(false); | |
| 296 | expect( | |
| 297 | (yield* coordinator.getUrlAuthAction(instanceId)).pipe(Option.getOrThrow), | |
| 298 | ).toMatchObject({ elicitationId: "login-2" }); | |
| 299 | expect(received.slice(0, 2)).toEqual(["login-1", "login-2"]); | |
| 300 | expect( | |
| 301 | yield* coordinator.acceptUrlAuthentication({ instanceId, elicitationId: "login-2" }), | |
| 302 | ).toBe(true); | |
| 303 | expect(yield* Fiber.join(replacement)).toBe(true); | |
| 304 | yield* Fiber.interrupt(consumer); | |
| 305 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 306 | ); | |
| 307 | ||
| 308 | it.effect("clears a published URL action when its request is interrupted", () => | |
| 309 | Effect.gen(function* () { | |
| 310 | const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; | |
| 311 | const instanceId = ProviderInstanceId.make("acpRegistry_interrupted"); | |
| 312 | const actionSeen = yield* Deferred.make<void>(); | |
| 313 | const cleared = yield* Deferred.make<void>(); | |
| 314 | const consumer = yield* coordinator | |
| 315 | .watchUrlAuthAction(instanceId, (current) => | |
| 316 | current === null | |
| 317 | ? Deferred.succeed(cleared, undefined).pipe(Effect.asVoid) | |
| 318 | : Deferred.succeed(actionSeen, undefined).pipe(Effect.asVoid), | |
| 319 | ) | |
| 320 | .pipe(Effect.forkChild); | |
| 321 | const request = yield* coordinator | |
| 322 | .requestUrlAuthentication(instanceId, { | |
| 323 | elicitationId: "interrupted-login", | |
| 324 | url: "https://accounts.example.com/interrupted", | |
| 325 | message: "Continue with login", | |
| 326 | }) | |
| 327 | .pipe(Effect.forkChild); | |
| 328 | yield* Deferred.await(actionSeen); | |
| 329 | ||
| 330 | yield* Fiber.interrupt(request); | |
| 331 | yield* Deferred.await(cleared); | |
| 332 | expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); | |
| 333 | yield* Fiber.interrupt(consumer); | |
| 334 | }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), | |
| 335 | ); | |
| 336 | }); |
packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts created+362| ... | ... | @@ -0,0 +1,362 @@ |
| 1 | import { | |
| 2 | type AcpRegistryAcceptUrlAuthInput, | |
| 3 | type AcpRegistryUrlAuthAction, | |
| 4 | type ProviderInstanceId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import * as DateTime from "effect/DateTime"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as PubSub from "effect/PubSub"; | |
| 14 | import * as Ref from "effect/Ref"; | |
| 15 | import * as Semaphore from "effect/Semaphore"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | ||
| 18 | import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts"; | |
| 19 | ||
| 20 | interface AvailableCommandsUpdate { | |
| 21 | readonly instanceId: ProviderInstanceId; | |
| 22 | readonly commands: AcpRegistryAvailableCommands; | |
| 23 | } | |
| 24 | ||
| 25 | interface LiveConfigurationUpdate { | |
| 26 | readonly instanceId: ProviderInstanceId; | |
| 27 | readonly configuration: AcpRegistryLiveConfiguration; | |
| 28 | } | |
| 29 | ||
| 30 | interface UrlAuthActionUpdate { | |
| 31 | readonly instanceId: ProviderInstanceId; | |
| 32 | readonly action: AcpRegistryUrlAuthAction | null; | |
| 33 | } | |
| 34 | ||
| 35 | /** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */ | |
| 36 | const URL_AUTH_ACTION_TTL = Duration.minutes(10); | |
| 37 | ||
| 38 | interface PendingUrlAuthAction { | |
| 39 | readonly action: AcpRegistryUrlAuthAction; | |
| 40 | readonly consent: Deferred.Deferred<boolean>; | |
| 41 | readonly expiresAt: DateTime.Utc; | |
| 42 | } | |
| 43 | ||
| 44 | /** Gives a user-started ACP process priority over disposable discovery for the same agent. */ | |
| 45 | export class AcpRegistryRuntimeCoordinator extends Context.Service< | |
| 46 | AcpRegistryRuntimeCoordinator, | |
| 47 | { | |
| 48 | readonly withForegroundStartup: <A, E, R>( | |
| 49 | agentId: string, | |
| 50 | effect: Effect.Effect<A, E, R>, | |
| 51 | ) => Effect.Effect<A, E, R>; | |
| 52 | readonly runBackgroundProbe: <A, E, R>( | |
| 53 | agentId: string, | |
| 54 | effect: Effect.Effect<A, E, R>, | |
| 55 | ) => Effect.Effect<Option.Option<A>, E, R>; | |
| 56 | /** Serializes native-session imports and deletes across all client connections. */ | |
| 57 | readonly withSessionMutation: <A, E, R>( | |
| 58 | effect: Effect.Effect<A, E, R>, | |
| 59 | ) => Effect.Effect<A, E, R>; | |
| 60 | readonly clearAvailableCommands: (instanceId: ProviderInstanceId) => Effect.Effect<void>; | |
| 61 | readonly publishAvailableCommands: ( | |
| 62 | instanceId: ProviderInstanceId, | |
| 63 | commands: AcpRegistryAvailableCommands, | |
| 64 | ) => Effect.Effect<void>; | |
| 65 | readonly getAvailableCommands: ( | |
| 66 | instanceId: ProviderInstanceId, | |
| 67 | ) => Effect.Effect<Option.Option<AcpRegistryAvailableCommands>>; | |
| 68 | readonly watchAvailableCommands: ( | |
| 69 | instanceId: ProviderInstanceId, | |
| 70 | onUpdate: (commands: AcpRegistryAvailableCommands) => Effect.Effect<void>, | |
| 71 | ) => Effect.Effect<void>; | |
| 72 | readonly clearLiveConfiguration: (instanceId: ProviderInstanceId) => Effect.Effect<void>; | |
| 73 | readonly publishLiveConfiguration: ( | |
| 74 | instanceId: ProviderInstanceId, | |
| 75 | configuration: AcpRegistryLiveConfiguration, | |
| 76 | ) => Effect.Effect<void>; | |
| 77 | readonly getLiveConfiguration: ( | |
| 78 | instanceId: ProviderInstanceId, | |
| 79 | ) => Effect.Effect<Option.Option<AcpRegistryLiveConfiguration>>; | |
| 80 | readonly watchLiveConfiguration: ( | |
| 81 | instanceId: ProviderInstanceId, | |
| 82 | onUpdate: (configuration: AcpRegistryLiveConfiguration) => Effect.Effect<void>, | |
| 83 | ) => Effect.Effect<void>; | |
| 84 | readonly requestUrlAuthentication: ( | |
| 85 | instanceId: ProviderInstanceId, | |
| 86 | action: AcpRegistryUrlAuthAction, | |
| 87 | ) => Effect.Effect<boolean>; | |
| 88 | readonly acceptUrlAuthentication: ( | |
| 89 | input: AcpRegistryAcceptUrlAuthInput, | |
| 90 | ) => Effect.Effect<boolean>; | |
| 91 | readonly getUrlAuthAction: ( | |
| 92 | instanceId: ProviderInstanceId, | |
| 93 | ) => Effect.Effect<Option.Option<AcpRegistryUrlAuthAction>>; | |
| 94 | readonly watchUrlAuthAction: ( | |
| 95 | instanceId: ProviderInstanceId, | |
| 96 | onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect<void>, | |
| 97 | ) => Effect.Effect<void>; | |
| 98 | } | |
| 99 | >()("@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator") {} | |
| 100 | ||
| 101 | const make = Effect.gen(function* () { | |
| 102 | const foregroundStarts = yield* PubSub.unbounded<string>(); | |
| 103 | const activeForegroundCounts = yield* Ref.make(new Map<string, number>()); | |
| 104 | const availableCommandsUpdates = yield* PubSub.unbounded<AvailableCommandsUpdate>(); | |
| 105 | const availableCommandsByInstance = yield* Ref.make( | |
| 106 | new Map<ProviderInstanceId, AcpRegistryAvailableCommands>(), | |
| 107 | ); | |
| 108 | const liveConfigurationUpdates = yield* PubSub.unbounded<LiveConfigurationUpdate>(); | |
| 109 | const liveConfigurationByInstance = yield* Ref.make( | |
| 110 | new Map<ProviderInstanceId, AcpRegistryLiveConfiguration>(), | |
| 111 | ); | |
| 112 | const urlAuthActionUpdates = yield* PubSub.unbounded<UrlAuthActionUpdate>(); | |
| 113 | const pendingUrlAuthActions = yield* Ref.make( | |
| 114 | new Map<ProviderInstanceId, PendingUrlAuthAction>(), | |
| 115 | ); | |
| 116 | const urlAuthActionPermit = yield* Semaphore.make(1); | |
| 117 | const sessionMutationPermit = yield* Semaphore.make(1); | |
| 118 | ||
| 119 | const withForegroundStartup: AcpRegistryRuntimeCoordinator["Service"]["withForegroundStartup"] = ( | |
| 120 | agentId, | |
| 121 | effect, | |
| 122 | ) => | |
| 123 | Effect.acquireUseRelease( | |
| 124 | Ref.update(activeForegroundCounts, (current) => { | |
| 125 | const next = new Map(current); | |
| 126 | next.set(agentId, (next.get(agentId) ?? 0) + 1); | |
| 127 | return next; | |
| 128 | }).pipe(Effect.andThen(PubSub.publish(foregroundStarts, agentId))), | |
| 129 | () => effect, | |
| 130 | () => | |
| 131 | Ref.update(activeForegroundCounts, (current) => { | |
| 132 | const next = new Map(current); | |
| 133 | const remaining = (next.get(agentId) ?? 1) - 1; | |
| 134 | if (remaining <= 0) next.delete(agentId); | |
| 135 | else next.set(agentId, remaining); | |
| 136 | return next; | |
| 137 | }), | |
| 138 | ); | |
| 139 | ||
| 140 | const runBackgroundProbe: AcpRegistryRuntimeCoordinator["Service"]["runBackgroundProbe"] = ( | |
| 141 | agentId, | |
| 142 | effect, | |
| 143 | ) => | |
| 144 | Effect.scoped( | |
| 145 | Effect.gen(function* () { | |
| 146 | // Subscribe before checking state so a foreground start cannot land | |
| 147 | // in the gap between the check and the interruptible probe. | |
| 148 | const subscription = yield* PubSub.subscribe(foregroundStarts); | |
| 149 | if ((yield* Ref.get(activeForegroundCounts)).has(agentId)) { | |
| 150 | return Option.none(); | |
| 151 | } | |
| 152 | const foregroundStarted = Stream.fromSubscription(subscription).pipe( | |
| 153 | Stream.filter((candidate) => candidate === agentId), | |
| 154 | Stream.runHead, | |
| 155 | Effect.as(Option.none()), | |
| 156 | ); | |
| 157 | return yield* Effect.raceFirst(effect.pipe(Effect.map(Option.some)), foregroundStarted); | |
| 158 | }), | |
| 159 | ); | |
| 160 | ||
| 161 | const withSessionMutation: AcpRegistryRuntimeCoordinator["Service"]["withSessionMutation"] = ( | |
| 162 | effect, | |
| 163 | ) => sessionMutationPermit.withPermit(effect); | |
| 164 | ||
| 165 | const clearAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["clearAvailableCommands"] = | |
| 166 | (instanceId) => | |
| 167 | Ref.update(availableCommandsByInstance, (current) => { | |
| 168 | const next = new Map(current); | |
| 169 | next.delete(instanceId); | |
| 170 | return next; | |
| 171 | }); | |
| 172 | ||
| 173 | const publishAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["publishAvailableCommands"] = | |
| 174 | (instanceId, commands) => | |
| 175 | Ref.update(availableCommandsByInstance, (current) => { | |
| 176 | const next = new Map(current); | |
| 177 | next.set(instanceId, commands); | |
| 178 | return next; | |
| 179 | }).pipe( | |
| 180 | Effect.andThen( | |
| 181 | PubSub.publish(availableCommandsUpdates, { | |
| 182 | instanceId, | |
| 183 | commands, | |
| 184 | }), | |
| 185 | ), | |
| 186 | Effect.asVoid, | |
| 187 | ); | |
| 188 | ||
| 189 | const getAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["getAvailableCommands"] = | |
| 190 | Effect.fn("AcpRegistryRuntimeCoordinator.getAvailableCommands")(function* (instanceId) { | |
| 191 | return Option.fromNullishOr((yield* Ref.get(availableCommandsByInstance)).get(instanceId)); | |
| 192 | }); | |
| 193 | ||
| 194 | const watchAvailableCommands: AcpRegistryRuntimeCoordinator["Service"]["watchAvailableCommands"] = | |
| 195 | (instanceId, onUpdate) => | |
| 196 | Effect.scoped( | |
| 197 | Effect.gen(function* () { | |
| 198 | // Subscribe before reading the current value so a publication in | |
| 199 | // between is either observed from the snapshot, the queue, or both. | |
| 200 | const subscription = yield* PubSub.subscribe(availableCommandsUpdates); | |
| 201 | const current = yield* getAvailableCommands(instanceId); | |
| 202 | if (Option.isSome(current)) { | |
| 203 | yield* onUpdate(current.value); | |
| 204 | } | |
| 205 | yield* Stream.fromSubscription(subscription).pipe( | |
| 206 | Stream.filter((update) => update.instanceId === instanceId), | |
| 207 | Stream.runForEach((update) => onUpdate(update.commands)), | |
| 208 | ); | |
| 209 | }), | |
| 210 | ); | |
| 211 | ||
| 212 | const clearLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["clearLiveConfiguration"] = | |
| 213 | (instanceId) => | |
| 214 | Ref.update(liveConfigurationByInstance, (current) => { | |
| 215 | const next = new Map(current); | |
| 216 | next.delete(instanceId); | |
| 217 | return next; | |
| 218 | }); | |
| 219 | ||
| 220 | const publishLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["publishLiveConfiguration"] = | |
| 221 | (instanceId, configuration) => | |
| 222 | Ref.update(liveConfigurationByInstance, (current) => | |
| 223 | new Map(current).set(instanceId, configuration), | |
| 224 | ).pipe( | |
| 225 | Effect.andThen( | |
| 226 | PubSub.publish(liveConfigurationUpdates, { | |
| 227 | instanceId, | |
| 228 | configuration, | |
| 229 | }), | |
| 230 | ), | |
| 231 | Effect.asVoid, | |
| 232 | ); | |
| 233 | ||
| 234 | const getLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["getLiveConfiguration"] = | |
| 235 | Effect.fn("AcpRegistryRuntimeCoordinator.getLiveConfiguration")(function* (instanceId) { | |
| 236 | return Option.fromNullishOr((yield* Ref.get(liveConfigurationByInstance)).get(instanceId)); | |
| 237 | }); | |
| 238 | ||
| 239 | const watchLiveConfiguration: AcpRegistryRuntimeCoordinator["Service"]["watchLiveConfiguration"] = | |
| 240 | (instanceId, onUpdate) => | |
| 241 | Effect.scoped( | |
| 242 | Effect.gen(function* () { | |
| 243 | const subscription = yield* PubSub.subscribe(liveConfigurationUpdates); | |
| 244 | const current = yield* getLiveConfiguration(instanceId); | |
| 245 | if (Option.isSome(current)) { | |
| 246 | yield* onUpdate(current.value); | |
| 247 | } | |
| 248 | yield* Stream.fromSubscription(subscription).pipe( | |
| 249 | Stream.filter((update) => update.instanceId === instanceId), | |
| 250 | Stream.runForEach((update) => onUpdate(update.configuration)), | |
| 251 | ); | |
| 252 | }), | |
| 253 | ); | |
| 254 | ||
| 255 | const publishUrlAuthAction = ( | |
| 256 | instanceId: ProviderInstanceId, | |
| 257 | action: AcpRegistryUrlAuthAction | null, | |
| 258 | ) => PubSub.publish(urlAuthActionUpdates, { instanceId, action }).pipe(Effect.asVoid); | |
| 259 | ||
| 260 | const requestUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["requestUrlAuthentication"] = | |
| 261 | Effect.fn("AcpRegistryRuntimeCoordinator.requestUrlAuthentication")( | |
| 262 | function* (instanceId, action) { | |
| 263 | const consent = yield* Deferred.make<boolean>(); | |
| 264 | const createdAt = yield* DateTime.now; | |
| 265 | const expiresAt = DateTime.addDuration(createdAt, URL_AUTH_ACTION_TTL); | |
| 266 | const publishedAction = { | |
| 267 | ...action, | |
| 268 | createdAt: DateTime.formatIso(createdAt), | |
| 269 | expiresAt: DateTime.formatIso(expiresAt), | |
| 270 | } satisfies AcpRegistryUrlAuthAction; | |
| 271 | const cleanup = Ref.modify(pendingUrlAuthActions, (current) => { | |
| 272 | if (current.get(instanceId)?.consent !== consent) { | |
| 273 | return [false, current] as const; | |
| 274 | } | |
| 275 | const next = new Map(current); | |
| 276 | next.delete(instanceId); | |
| 277 | return [true, next] as const; | |
| 278 | }).pipe( | |
| 279 | Effect.flatMap((removed) => | |
| 280 | removed ? publishUrlAuthAction(instanceId, null) : Effect.void, | |
| 281 | ), | |
| 282 | ); | |
| 283 | return yield* Effect.gen(function* () { | |
| 284 | yield* urlAuthActionPermit.withPermits(1)( | |
| 285 | Effect.gen(function* () { | |
| 286 | const previous = yield* Ref.modify(pendingUrlAuthActions, (current) => { | |
| 287 | const next = new Map(current); | |
| 288 | const existing = next.get(instanceId); | |
| 289 | next.set(instanceId, { action: publishedAction, consent, expiresAt }); | |
| 290 | return [existing, next] as const; | |
| 291 | }); | |
| 292 | if (previous !== undefined) { | |
| 293 | yield* Deferred.succeed(previous.consent, false).pipe(Effect.ignore); | |
| 294 | } | |
| 295 | yield* publishUrlAuthAction(instanceId, publishedAction); | |
| 296 | }), | |
| 297 | ); | |
| 298 | return yield* Deferred.await(consent).pipe( | |
| 299 | Effect.timeoutOrElse({ | |
| 300 | duration: URL_AUTH_ACTION_TTL, | |
| 301 | orElse: () => Effect.succeed(false), | |
| 302 | }), | |
| 303 | ); | |
| 304 | }).pipe(Effect.ensuring(cleanup)); | |
| 305 | }, | |
| 306 | ); | |
| 307 | ||
| 308 | const acceptUrlAuthentication: AcpRegistryRuntimeCoordinator["Service"]["acceptUrlAuthentication"] = | |
| 309 | Effect.fn("AcpRegistryRuntimeCoordinator.acceptUrlAuthentication")(function* (input) { | |
| 310 | const pending = (yield* Ref.get(pendingUrlAuthActions)).get(input.instanceId); | |
| 311 | if (pending?.action.elicitationId !== input.elicitationId) return false; | |
| 312 | if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, pending.expiresAt)) { | |
| 313 | yield* Deferred.succeed(pending.consent, false).pipe(Effect.ignore); | |
| 314 | return false; | |
| 315 | } | |
| 316 | return yield* Deferred.succeed(pending.consent, true); | |
| 317 | }); | |
| 318 | ||
| 319 | const getUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["getUrlAuthAction"] = Effect.fn( | |
| 320 | "AcpRegistryRuntimeCoordinator.getUrlAuthAction", | |
| 321 | )(function* (instanceId) { | |
| 322 | return Option.fromNullishOr((yield* Ref.get(pendingUrlAuthActions)).get(instanceId)?.action); | |
| 323 | }); | |
| 324 | ||
| 325 | const watchUrlAuthAction: AcpRegistryRuntimeCoordinator["Service"]["watchUrlAuthAction"] = ( | |
| 326 | instanceId, | |
| 327 | onUpdate, | |
| 328 | ) => | |
| 329 | Effect.scoped( | |
| 330 | Effect.gen(function* () { | |
| 331 | const subscription = yield* PubSub.subscribe(urlAuthActionUpdates); | |
| 332 | const current = yield* getUrlAuthAction(instanceId); | |
| 333 | if (Option.isSome(current)) { | |
| 334 | yield* onUpdate(current.value); | |
| 335 | } | |
| 336 | yield* Stream.fromSubscription(subscription).pipe( | |
| 337 | Stream.filter((update) => update.instanceId === instanceId), | |
| 338 | Stream.runForEach((update) => onUpdate(update.action)), | |
| 339 | ); | |
| 340 | }), | |
| 341 | ); | |
| 342 | ||
| 343 | return AcpRegistryRuntimeCoordinator.of({ | |
| 344 | withForegroundStartup, | |
| 345 | runBackgroundProbe, | |
| 346 | withSessionMutation, | |
| 347 | clearAvailableCommands, | |
| 348 | publishAvailableCommands, | |
| 349 | getAvailableCommands, | |
| 350 | watchAvailableCommands, | |
| 351 | clearLiveConfiguration, | |
| 352 | publishLiveConfiguration, | |
| 353 | getLiveConfiguration, | |
| 354 | watchLiveConfiguration, | |
| 355 | requestUrlAuthentication, | |
| 356 | acceptUrlAuthentication, | |
| 357 | getUrlAuthAction, | |
| 358 | watchUrlAuthAction, | |
| 359 | }); | |
| 360 | }); | |
| 361 | ||
| 362 | export const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make); |
packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts created+1740| ... | ... | @@ -0,0 +1,1740 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | DEFAULT_SERVER_SETTINGS, | |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 9 | import { | |
| 10 | HostProcessArchitecture, | |
| 11 | HostProcessEnvironment, | |
| 12 | HostProcessPlatform, | |
| 13 | } from "@t3tools/shared/hostProcess"; | |
| 14 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 15 | import * as Deferred from "effect/Deferred"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as FileSystem from "effect/FileSystem"; | |
| 18 | import * as Path from "effect/Path"; | |
| 19 | import * as Fiber from "effect/Fiber"; | |
| 20 | import * as Layer from "effect/Layer"; | |
| 21 | import * as Schema from "effect/Schema"; | |
| 22 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 23 | import * as TestClock from "effect/testing/TestClock"; | |
| 24 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 25 | ||
| 26 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 27 | import { layerTestProviderHost, TestProviderHostSettings } from "@t3tools/provider-testing/host"; | |
| 28 | ||
| 29 | const registryUrl = "https://registry.test/registry.json"; | |
| 30 | const archiveUrl = "https://registry.test/example-agent.bin"; | |
| 31 | const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); | |
| 32 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 33 | ||
| 34 | function makeAgent( | |
| 35 | distribution: AcpRegistrySupport.AcpRegistryAgent["distribution"], | |
| 36 | ): AcpRegistrySupport.AcpRegistryAgent { | |
| 37 | return { | |
| 38 | id: "example-agent", | |
| 39 | name: "Example Agent", | |
| 40 | version: "1.2.3", | |
| 41 | description: "ACP Registry test agent", | |
| 42 | distribution, | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | function makeRegistry(agent: AcpRegistrySupport.AcpRegistryAgent): string { | |
| 47 | return JSON.stringify({ version: "1.0.0", agents: [agent] }); | |
| 48 | } | |
| 49 | ||
| 50 | function settings(input: Partial<AcpRegistrySettings> = {}): AcpRegistrySettings { | |
| 51 | return decodeAcpRegistrySettings({ | |
| 52 | agentId: "example-agent", | |
| 53 | ...input, | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | function layerResolver( | |
| 58 | execute: Parameters<typeof HttpClient.make>[0], | |
| 59 | environment: NodeJS.ProcessEnv = process.env, | |
| 60 | ) { | |
| 61 | return Layer.mergeAll( | |
| 62 | NodeServices.layer, | |
| 63 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 64 | Layer.succeed(HostProcessPlatform, "linux"), | |
| 65 | Layer.succeed(HostProcessArchitecture, "x64"), | |
| 66 | Layer.succeed(HostProcessEnvironment, environment), | |
| 67 | Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute)), | |
| 68 | ); | |
| 69 | } | |
| 70 | ||
| 71 | const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolchain")(function* ( | |
| 72 | rootDirectory: string, | |
| 73 | ) { | |
| 74 | const fileSystem = yield* FileSystem.FileSystem; | |
| 75 | const path = yield* Path.Path; | |
| 76 | const toolchainBin = path.join(rootDirectory, "fake-node", "bin"); | |
| 77 | const globalPrefix = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "npm"); | |
| 78 | const globalBin = path.join(globalPrefix, "bin"); | |
| 79 | const executablePath = path.join(globalBin, "example-agent"); | |
| 80 | const npmPath = path.join(toolchainBin, "npm"); | |
| 81 | const logPath = path.join(rootDirectory, "npm.log"); | |
| 82 | yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); | |
| 83 | yield* fileSystem.makeDirectory(globalPrefix, { recursive: true }); | |
| 84 | yield* fileSystem.writeFileString(logPath, ""); | |
| 85 | yield* fileSystem.copyFile( | |
| 86 | yield* path.fromFileUrl( | |
| 87 | new URL("./testing/AcpRegistrySupport.npm.fixture.sh", import.meta.url), | |
| 88 | ), | |
| 89 | npmPath, | |
| 90 | ); | |
| 91 | yield* fileSystem.chmod(npmPath, 0o755); | |
| 92 | return { | |
| 93 | environment: { | |
| 94 | ...process.env, | |
| 95 | PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, | |
| 96 | FAKE_NPM_LOG: logPath, | |
| 97 | FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"), | |
| 98 | FAKE_NPM_MANIFEST: encodeUnknownJson({ | |
| 99 | name: "@example/acp", | |
| 100 | version: "1.2.3", | |
| 101 | bin: { "example-agent": "dist/cli.js" }, | |
| 102 | }), | |
| 103 | } satisfies NodeJS.ProcessEnv, | |
| 104 | executablePath, | |
| 105 | globalBin, | |
| 106 | logPath, | |
| 107 | npmPath, | |
| 108 | }; | |
| 109 | }); | |
| 110 | ||
| 111 | const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolchain")(function* ( | |
| 112 | rootDirectory: string, | |
| 113 | ) { | |
| 114 | const fileSystem = yield* FileSystem.FileSystem; | |
| 115 | const path = yield* Path.Path; | |
| 116 | const toolchainBin = path.join(rootDirectory, "fake-uv", "bin"); | |
| 117 | const globalBin = path.join(rootDirectory, "tools", "example-agent", "1.2.3", "python", "bin"); | |
| 118 | const executablePath = path.join(globalBin, "fast-agent"); | |
| 119 | const uvPath = path.join(toolchainBin, "uv"); | |
| 120 | const logPath = path.join(rootDirectory, "uv.log"); | |
| 121 | yield* fileSystem.makeDirectory(toolchainBin, { recursive: true }); | |
| 122 | yield* fileSystem.writeFileString(logPath, ""); | |
| 123 | yield* fileSystem.copyFile( | |
| 124 | yield* path.fromFileUrl(new URL("./testing/AcpRegistrySupport.uv.fixture.sh", import.meta.url)), | |
| 125 | uvPath, | |
| 126 | ); | |
| 127 | yield* fileSystem.chmod(uvPath, 0o755); | |
| 128 | return { | |
| 129 | environment: { | |
| 130 | ...process.env, | |
| 131 | PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, | |
| 132 | FAKE_UV_LOG: logPath, | |
| 133 | FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"), | |
| 134 | FAKE_UV_EXECUTABLE: executablePath, | |
| 135 | } satisfies NodeJS.ProcessEnv, | |
| 136 | executablePath, | |
| 137 | globalBin, | |
| 138 | logPath, | |
| 139 | uvPath, | |
| 140 | }; | |
| 141 | }); | |
| 142 | ||
| 143 | describe("AcpRegistrySupport", () => { | |
| 144 | it("preserves the registry failure when translating it for clients", () => { | |
| 145 | const cause = new Error("registry unavailable"); | |
| 146 | const failure = new AcpRegistrySupport.AcpRegistryError({ | |
| 147 | reason: "registry_unavailable", | |
| 148 | detail: "Could not load the ACP Registry.", | |
| 149 | cause, | |
| 150 | }); | |
| 151 | ||
| 152 | expect(AcpRegistrySupport.toAcpRegistryOperationError(failure)).toMatchObject({ | |
| 153 | reason: "registry_unavailable", | |
| 154 | message: "Could not load the ACP Registry.", | |
| 155 | cause: failure, | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | it("maps supported Node platforms to ACP Registry target keys", () => { | |
| 160 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("darwin", "arm64")).toBe( | |
| 161 | "darwin-aarch64", | |
| 162 | ); | |
| 163 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "x64")).toBe( | |
| 164 | "linux-x86_64", | |
| 165 | ); | |
| 166 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("win32", "arm64")).toBe( | |
| 167 | "windows-aarch64", | |
| 168 | ); | |
| 169 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("freebsd", "x64")).toBeUndefined(); | |
| 170 | expect(AcpRegistrySupport.resolveAcpRegistryPlatformTarget("linux", "ia32")).toBeUndefined(); | |
| 171 | }); | |
| 172 | ||
| 173 | it("selects the preferred compatible distribution", () => { | |
| 174 | const agent = makeAgent({ | |
| 175 | binary: { | |
| 176 | "linux-x86_64": { | |
| 177 | archive: archiveUrl, | |
| 178 | cmd: "./bin/example-agent", | |
| 179 | args: ["acp"], | |
| 180 | }, | |
| 181 | }, | |
| 182 | npx: { | |
| 183 | package: "@example/acp@1.2.3", | |
| 184 | args: ["--stdio"], | |
| 185 | }, | |
| 186 | }); | |
| 187 | ||
| 188 | expect( | |
| 189 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 190 | agent, | |
| 191 | preference: "auto", | |
| 192 | platformTarget: "linux-x86_64", | |
| 193 | }), | |
| 194 | ).toMatchObject({ kind: "binary", args: ["acp"] }); | |
| 195 | expect( | |
| 196 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 197 | agent, | |
| 198 | preference: "npx", | |
| 199 | platformTarget: "linux-x86_64", | |
| 200 | }), | |
| 201 | ).toEqual({ | |
| 202 | kind: "npx", | |
| 203 | packageName: "@example/acp@1.2.3", | |
| 204 | args: ["--stdio"], | |
| 205 | env: {}, | |
| 206 | }); | |
| 207 | expect( | |
| 208 | AcpRegistrySupport.resolveAcpRegistryDistribution({ | |
| 209 | agent, | |
| 210 | preference: "binary", | |
| 211 | platformTarget: "darwin-aarch64", | |
| 212 | }), | |
| 213 | ).toBeUndefined(); | |
| 214 | }); | |
| 215 | ||
| 216 | it.effect("resolves a local command on the selected environment without the registry", () => { | |
| 217 | const requests: Array<string> = []; | |
| 218 | return Effect.gen(function* () { | |
| 219 | const fileSystem = yield* FileSystem.FileSystem; | |
| 220 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); | |
| 221 | const commandPath = `${cacheDir}/dsh`; | |
| 222 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 223 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 224 | const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; | |
| 225 | const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; | |
| 226 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 227 | cacheDir, | |
| 228 | toolsDir: `${cacheDir}/tools`, | |
| 229 | registryUrl, | |
| 230 | }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); | |
| 231 | const localSettings = decodeAcpRegistrySettings({ | |
| 232 | source: "local", | |
| 233 | commandPath: "dsh", | |
| 234 | commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 235 | }); | |
| 236 | ||
| 237 | expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); | |
| 238 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 239 | status: "ready", | |
| 240 | agentId: "dsh", | |
| 241 | version: null, | |
| 242 | distribution: "local", | |
| 243 | }); | |
| 244 | const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); | |
| 245 | expect(resolved).toEqual({ | |
| 246 | distribution: "local", | |
| 247 | spawn: { | |
| 248 | command: commandPath, | |
| 249 | args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 250 | cwd: "/workspace", | |
| 251 | env: environment, | |
| 252 | shell: false, | |
| 253 | }, | |
| 254 | }); | |
| 255 | expect(requests).toEqual([]); | |
| 256 | expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); | |
| 257 | }).pipe( | |
| 258 | Effect.scoped, | |
| 259 | Effect.provide( | |
| 260 | layerResolver((request) => { | |
| 261 | requests.push(request.url); | |
| 262 | return Effect.die("unexpected registry request for a local provider"); | |
| 263 | }), | |
| 264 | ), | |
| 265 | ); | |
| 266 | }); | |
| 267 | ||
| 268 | it.effect("resolves a local executable path and inherits the host environment", () => | |
| 269 | Effect.gen(function* () { | |
| 270 | const fileSystem = yield* FileSystem.FileSystem; | |
| 271 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); | |
| 272 | const commandPath = `${cacheDir}/dsh wrapper`; | |
| 273 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 274 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 275 | const environment = { PATH: "/unused", INHERITED: "host" }; | |
| 276 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 277 | cacheDir, | |
| 278 | toolsDir: `${cacheDir}/tools`, | |
| 279 | registryUrl, | |
| 280 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 281 | const resolved = yield* resolver.resolve( | |
| 282 | decodeAcpRegistrySettings({ source: "local", commandPath }), | |
| 283 | "/workspace", | |
| 284 | ); | |
| 285 | ||
| 286 | expect(resolved.spawn).toEqual({ | |
| 287 | command: commandPath, | |
| 288 | args: [], | |
| 289 | cwd: "/workspace", | |
| 290 | env: environment, | |
| 291 | shell: false, | |
| 292 | }); | |
| 293 | expect(resolved.agent).toBeUndefined(); | |
| 294 | ||
| 295 | for (const extension of [".cmd", ".BAT"]) { | |
| 296 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 297 | cacheDir, | |
| 298 | toolsDir: `${cacheDir}/tools`, | |
| 299 | registryUrl, | |
| 300 | }).pipe( | |
| 301 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 302 | Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), | |
| 303 | ); | |
| 304 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); | |
| 305 | for (const failure of [ | |
| 306 | yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), | |
| 307 | yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), | |
| 308 | ]) { | |
| 309 | expect(failure).toMatchObject({ | |
| 310 | reason: "runner_unavailable", | |
| 311 | detail: expect.stringContaining("node.exe"), | |
| 312 | }); | |
| 313 | } | |
| 314 | } | |
| 315 | }).pipe( | |
| 316 | Effect.scoped, | |
| 317 | Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), | |
| 318 | ), | |
| 319 | ); | |
| 320 | ||
| 321 | it.effect("reports missing or non-executable local commands without registry fallback", () => | |
| 322 | Effect.gen(function* () { | |
| 323 | const fileSystem = yield* FileSystem.FileSystem; | |
| 324 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 325 | prefix: "t3-acp-local-missing-", | |
| 326 | }); | |
| 327 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 328 | cacheDir, | |
| 329 | toolsDir: `${cacheDir}/tools`, | |
| 330 | registryUrl, | |
| 331 | }); | |
| 332 | const environment = { PATH: cacheDir }; | |
| 333 | const unconfigured = decodeAcpRegistrySettings({ source: "local" }); | |
| 334 | expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ | |
| 335 | status: "unconfigured", | |
| 336 | }); | |
| 337 | expect( | |
| 338 | yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), | |
| 339 | ).toMatchObject({ reason: "agent_not_configured" }); | |
| 340 | ||
| 341 | const nonExecutable = `${cacheDir}/non-executable`; | |
| 342 | yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); | |
| 343 | yield* fileSystem.chmod(nonExecutable, 0o644); | |
| 344 | for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { | |
| 345 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); | |
| 346 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 347 | status: "missing_runner", | |
| 348 | version: null, | |
| 349 | distribution: "local", | |
| 350 | }); | |
| 351 | expect( | |
| 352 | yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), | |
| 353 | ).toMatchObject({ | |
| 354 | reason: "runner_unavailable", | |
| 355 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 356 | }); | |
| 357 | } | |
| 358 | }).pipe( | |
| 359 | Effect.scoped, | |
| 360 | Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), | |
| 361 | ), | |
| 362 | ); | |
| 363 | ||
| 364 | it.effect("resolves command overrides while preserving registry args and environment", () => { | |
| 365 | const agent = makeAgent({ | |
| 366 | binary: { | |
| 367 | "linux-x86_64": { | |
| 368 | archive: archiveUrl, | |
| 369 | cmd: "./bin/example-agent", | |
| 370 | args: ["acp", "--stdio"], | |
| 371 | env: { REGISTRY_VALUE: "registry", OVERRIDE_ME: "registry" }, | |
| 372 | }, | |
| 373 | }, | |
| 374 | }); | |
| 375 | const requests: Array<string> = []; | |
| 376 | return Effect.gen(function* () { | |
| 377 | const fileSystem = yield* FileSystem.FileSystem; | |
| 378 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 379 | prefix: "t3-acp-registry-override-", | |
| 380 | }); | |
| 381 | const commandPath = `${cacheDir}/example-agent`; | |
| 382 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 383 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 384 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 385 | cacheDir, | |
| 386 | toolsDir: `${cacheDir}/tools`, | |
| 387 | registryUrl, | |
| 388 | }); | |
| 389 | const resolved = yield* resolver.resolve(settings({ commandPath }), "/workspace", { | |
| 390 | HOST_VALUE: "host", | |
| 391 | OVERRIDE_ME: "host", | |
| 392 | }); | |
| 393 | ||
| 394 | expect(resolved.distribution).toBe("binary"); | |
| 395 | expect(resolved.spawn).toEqual({ | |
| 396 | command: commandPath, | |
| 397 | args: ["acp", "--stdio"], | |
| 398 | cwd: "/workspace", | |
| 399 | env: { | |
| 400 | HOST_VALUE: "host", | |
| 401 | OVERRIDE_ME: "registry", | |
| 402 | REGISTRY_VALUE: "registry", | |
| 403 | }, | |
| 404 | }); | |
| 405 | expect(requests).toEqual([registryUrl]); | |
| 406 | }).pipe( | |
| 407 | Effect.scoped, | |
| 408 | Effect.provide( | |
| 409 | layerResolver((request) => { | |
| 410 | requests.push(request.url); | |
| 411 | return Effect.succeed( | |
| 412 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 413 | ); | |
| 414 | }), | |
| 415 | ), | |
| 416 | ); | |
| 417 | }); | |
| 418 | ||
| 419 | it.effect("installs into T3 home a package and launches its exposed command", () => { | |
| 420 | const agent = makeAgent({ | |
| 421 | npx: { package: "@example/acp@V1.2.3", args: ["--stdio"] }, | |
| 422 | }); | |
| 423 | return Effect.gen(function* () { | |
| 424 | const fileSystem = yield* FileSystem.FileSystem; | |
| 425 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 426 | prefix: "t3-acp-registry-global-package-", | |
| 427 | }); | |
| 428 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 429 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 430 | cacheDir, | |
| 431 | toolsDir: `${cacheDir}/tools`, | |
| 432 | registryUrl, | |
| 433 | }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); | |
| 434 | ||
| 435 | const first = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 436 | const second = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 437 | ||
| 438 | expect(first.spawn).toMatchObject({ | |
| 439 | command: toolchain.executablePath, | |
| 440 | args: ["--stdio"], | |
| 441 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 442 | }); | |
| 443 | expect(second.spawn.command).toBe(toolchain.executablePath); | |
| 444 | const npmCommands = yield* fileSystem.readFileString(toolchain.logPath); | |
| 445 | expect(npmCommands.match(/^install --global /gmu)).toHaveLength(1); | |
| 446 | expect(npmCommands).toContain("install --global @example/acp@V1.2.3"); | |
| 447 | }).pipe( | |
| 448 | Effect.scoped, | |
| 449 | Effect.provide( | |
| 450 | layerResolver((request) => | |
| 451 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 452 | ), | |
| 453 | ), | |
| 454 | ); | |
| 455 | }); | |
| 456 | ||
| 457 | it.effect("uses its managed prefix despite a system-owned npm prefix", () => { | |
| 458 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 459 | return Effect.gen(function* () { | |
| 460 | const fileSystem = yield* FileSystem.FileSystem; | |
| 461 | const path = yield* Path.Path; | |
| 462 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 463 | prefix: "t3-acp-registry-user-global-package-", | |
| 464 | }); | |
| 465 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 466 | const systemPrefix = path.join(cacheDir, "system-global"); | |
| 467 | yield* fileSystem.makeDirectory(systemPrefix); | |
| 468 | yield* fileSystem.chmod(systemPrefix, 0o555); | |
| 469 | const userHome = path.join(cacheDir, "home"); | |
| 470 | const environment = { | |
| 471 | ...toolchain.environment, | |
| 472 | HOME: userHome, | |
| 473 | FAKE_NPM_PREFIX: systemPrefix, | |
| 474 | }; | |
| 475 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 476 | cacheDir, | |
| 477 | toolsDir: `${cacheDir}/tools`, | |
| 478 | registryUrl, | |
| 479 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 480 | ||
| 481 | const resolved = yield* resolver.resolve(settings(), "/workspace", environment); | |
| 482 | const userGlobalBin = toolchain.globalBin; | |
| 483 | expect(resolved.spawn).toMatchObject({ | |
| 484 | command: path.join(userGlobalBin, "example-agent"), | |
| 485 | env: { PATH: expect.stringMatching(new RegExp(`^${userGlobalBin}:`, "u")) }, | |
| 486 | }); | |
| 487 | }).pipe( | |
| 488 | Effect.scoped, | |
| 489 | Effect.provide( | |
| 490 | layerResolver((request) => | |
| 491 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 492 | ), | |
| 493 | ), | |
| 494 | ); | |
| 495 | }); | |
| 496 | ||
| 497 | it.effect("installs into T3 home a uv tool and launches its exposed command", () => { | |
| 498 | const agent = makeAgent({ | |
| 499 | uvx: { package: "fast-agent-acp==V0.10.1", args: ["--acp"] }, | |
| 500 | }); | |
| 501 | return Effect.gen(function* () { | |
| 502 | const fileSystem = yield* FileSystem.FileSystem; | |
| 503 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 504 | prefix: "t3-acp-registry-global-uv-tool-", | |
| 505 | }); | |
| 506 | const toolchain = yield* makeFakeUvToolchain(cacheDir); | |
| 507 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 508 | cacheDir, | |
| 509 | toolsDir: `${cacheDir}/tools`, | |
| 510 | registryUrl, | |
| 511 | }); | |
| 512 | ||
| 513 | const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 514 | ||
| 515 | expect(resolved.spawn).toMatchObject({ | |
| 516 | command: toolchain.executablePath, | |
| 517 | args: ["--acp"], | |
| 518 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 519 | }); | |
| 520 | expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( | |
| 521 | "tool install --force fast-agent-acp==V0.10.1", | |
| 522 | ); | |
| 523 | }).pipe( | |
| 524 | Effect.scoped, | |
| 525 | Effect.provide( | |
| 526 | layerResolver((request) => | |
| 527 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 528 | ), | |
| 529 | ), | |
| 530 | ); | |
| 531 | }); | |
| 532 | ||
| 533 | it.effect("honors an exact Windows PATH override when launching a global package", () => { | |
| 534 | const agent = makeAgent({ | |
| 535 | uvx: { package: "fast-agent-acp==0.10.1", args: ["--acp"] }, | |
| 536 | }); | |
| 537 | return Effect.gen(function* () { | |
| 538 | const fileSystem = yield* FileSystem.FileSystem; | |
| 539 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 540 | prefix: "t3-acp-registry-windows-package-path-", | |
| 541 | }); | |
| 542 | const toolchain = yield* makeFakeUvToolchain(cacheDir); | |
| 543 | const linuxResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 544 | cacheDir, | |
| 545 | toolsDir: `${cacheDir}/tools`, | |
| 546 | registryUrl, | |
| 547 | }); | |
| 548 | yield* linuxResolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 549 | ||
| 550 | const windowsEnvironment = { | |
| 551 | ...toolchain.environment, | |
| 552 | Path: "C:\\host\\bin", | |
| 553 | PATH: "C:\\provider\\bin", | |
| 554 | }; | |
| 555 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 556 | cacheDir, | |
| 557 | toolsDir: `${cacheDir}/tools`, | |
| 558 | registryUrl, | |
| 559 | }).pipe( | |
| 560 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 561 | Effect.provideService(SpawnExecutableResolution, (command) => { | |
| 562 | if (command === "uv") return toolchain.uvPath; | |
| 563 | if (command === "fast-agent") return toolchain.executablePath; | |
| 564 | return undefined; | |
| 565 | }), | |
| 566 | ); | |
| 567 | const resolved = yield* windowsResolver.resolve( | |
| 568 | settings(), | |
| 569 | "C:\\workspace", | |
| 570 | windowsEnvironment, | |
| 571 | ); | |
| 572 | ||
| 573 | expect(resolved.spawn).toMatchObject({ | |
| 574 | env: { PATH: `${toolchain.globalBin};C:\\provider\\bin` }, | |
| 575 | }); | |
| 576 | }).pipe( | |
| 577 | Effect.scoped, | |
| 578 | Effect.provide( | |
| 579 | layerResolver((request) => | |
| 580 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 581 | ), | |
| 582 | ), | |
| 583 | ); | |
| 584 | }); | |
| 585 | ||
| 586 | it.effect("installs and reuses a registry binary in the managed cache", () => { | |
| 587 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 588 | const agent = makeAgent({ | |
| 589 | binary: { | |
| 590 | "linux-x86_64": { | |
| 591 | archive: archiveUrl, | |
| 592 | cmd: "./bin/example-agent", | |
| 593 | args: ["acp"], | |
| 594 | // SHA-256 of binaryBytes. | |
| 595 | sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7", | |
| 596 | }, | |
| 597 | }, | |
| 598 | }); | |
| 599 | const requests: Array<string> = []; | |
| 600 | return Effect.gen(function* () { | |
| 601 | const fileSystem = yield* FileSystem.FileSystem; | |
| 602 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 603 | prefix: "t3-acp-registry-install-", | |
| 604 | }); | |
| 605 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 606 | cacheDir, | |
| 607 | toolsDir: `${cacheDir}/tools`, | |
| 608 | registryUrl, | |
| 609 | }); | |
| 610 | const first = yield* resolver.resolve(settings(), "/workspace"); | |
| 611 | const second = yield* resolver.resolve(settings(), "/workspace"); | |
| 612 | ||
| 613 | expect(first.spawn.command).toBe(second.spawn.command); | |
| 614 | expect(first.spawn.command).toContain( | |
| 615 | "/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent", | |
| 616 | ); | |
| 617 | expect(yield* fileSystem.readFileString(first.spawn.command)).toBe( | |
| 618 | "#!/bin/sh\necho example\n", | |
| 619 | ); | |
| 620 | expect(requests).toEqual([registryUrl, archiveUrl]); | |
| 621 | }).pipe( | |
| 622 | Effect.scoped, | |
| 623 | Effect.provide( | |
| 624 | layerResolver((request) => { | |
| 625 | requests.push(request.url); | |
| 626 | const response = | |
| 627 | request.url === registryUrl | |
| 628 | ? new Response(makeRegistry(agent)) | |
| 629 | : new Response( | |
| 630 | new ReadableStream<Uint8Array>({ | |
| 631 | start(controller) { | |
| 632 | controller.enqueue(binaryBytes.slice(0, 8)); | |
| 633 | controller.enqueue(binaryBytes.slice(8)); | |
| 634 | controller.close(); | |
| 635 | }, | |
| 636 | }), | |
| 637 | ); | |
| 638 | return Effect.succeed(HttpClientResponse.fromWeb(request, response)); | |
| 639 | }), | |
| 640 | ), | |
| 641 | ); | |
| 642 | }); | |
| 643 | ||
| 644 | it.effect( | |
| 645 | "prepares the registry version despite an older PATH binary and permits explicit overrides", | |
| 646 | () => { | |
| 647 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho managed\n"); | |
| 648 | const agent = makeAgent({ | |
| 649 | binary: { | |
| 650 | "linux-x86_64": { | |
| 651 | archive: archiveUrl, | |
| 652 | cmd: "./bin/example-agent", | |
| 653 | args: ["acp"], | |
| 654 | // SHA-256 of binaryBytes. | |
| 655 | sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31", | |
| 656 | }, | |
| 657 | }, | |
| 658 | }); | |
| 659 | const requests: Array<string> = []; | |
| 660 | return Effect.gen(function* () { | |
| 661 | const fileSystem = yield* FileSystem.FileSystem; | |
| 662 | const path = yield* Path.Path; | |
| 663 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 664 | prefix: "t3-acp-registry-system-", | |
| 665 | }); | |
| 666 | const systemBinDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 667 | prefix: "t3-acp-registry-system-bin-", | |
| 668 | }); | |
| 669 | const systemBinary = path.join(systemBinDir, "example-agent"); | |
| 670 | yield* fileSystem.writeFileString(systemBinary, "#!/bin/sh\necho system\n"); | |
| 671 | yield* fileSystem.chmod(systemBinary, 0o755); | |
| 672 | const environment = { PATH: systemBinDir }; | |
| 673 | ||
| 674 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 675 | cacheDir, | |
| 676 | toolsDir: `${cacheDir}/tools`, | |
| 677 | registryUrl, | |
| 678 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 679 | yield* resolver.search({ query: "example" }); | |
| 680 | expect(yield* resolver.inspect(settings(), environment)).toMatchObject({ | |
| 681 | status: "unprepared", | |
| 682 | }); | |
| 683 | ||
| 684 | const overridden = settings({ commandPath: systemBinary }); | |
| 685 | expect(yield* resolver.inspect(overridden, environment)).toMatchObject({ | |
| 686 | status: "ready", | |
| 687 | version: null, | |
| 688 | }); | |
| 689 | const custom = yield* resolver.resolve(overridden, "/workspace", environment); | |
| 690 | expect(custom.spawn.command).toBe(systemBinary); | |
| 691 | expect(custom.spawn.args).toEqual(["acp"]); | |
| 692 | expect(requests).toEqual([registryUrl]); | |
| 693 | ||
| 694 | yield* resolver.prepare({ agentId: "example-agent" }); | |
| 695 | const resolved = yield* resolver.resolve(settings(), "/workspace", environment); | |
| 696 | expect(resolved.spawn.command).not.toBe(systemBinary); | |
| 697 | expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( | |
| 698 | "#!/bin/sh\necho managed\n", | |
| 699 | ); | |
| 700 | expect(yield* fileSystem.readFileString(systemBinary)).toBe("#!/bin/sh\necho system\n"); | |
| 701 | ||
| 702 | const inspection = yield* resolver.inspect(settings(), environment); | |
| 703 | expect(inspection).toMatchObject({ | |
| 704 | status: "ready", | |
| 705 | distribution: "binary", | |
| 706 | version: "1.2.3", | |
| 707 | }); | |
| 708 | ||
| 709 | expect(requests).toEqual([registryUrl, registryUrl, archiveUrl]); | |
| 710 | }).pipe( | |
| 711 | Effect.scoped, | |
| 712 | Effect.provide( | |
| 713 | layerResolver((request) => { | |
| 714 | requests.push(request.url); | |
| 715 | return Effect.succeed( | |
| 716 | HttpClientResponse.fromWeb( | |
| 717 | request, | |
| 718 | request.url === registryUrl | |
| 719 | ? new Response(makeRegistry(agent)) | |
| 720 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 721 | ), | |
| 722 | ); | |
| 723 | }), | |
| 724 | ), | |
| 725 | ); | |
| 726 | }, | |
| 727 | ); | |
| 728 | ||
| 729 | it.effect("installs a tar archive containing a root directory entry", () => { | |
| 730 | const downloadUrl = "https://registry.test/agent.tar.gz"; | |
| 731 | const agent = makeAgent({ | |
| 732 | binary: { "linux-x86_64": { archive: downloadUrl, cmd: "./agent" } }, | |
| 733 | }); | |
| 734 | return Effect.gen(function* () { | |
| 735 | const fileSystem = yield* FileSystem.FileSystem; | |
| 736 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-root-entry-" }); | |
| 737 | const source = `${cacheDir}/source`; | |
| 738 | yield* fileSystem.makeDirectory(source); | |
| 739 | yield* fileSystem.writeFileString(`${source}/agent`, "#!/bin/sh\necho managed\n"); | |
| 740 | const archivePath = `${cacheDir}/agent.tar.gz`; | |
| 741 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 742 | const tar = yield* spawner.spawn( | |
| 743 | ChildProcess.make("tar", ["-czf", archivePath, "-C", source, "."]), | |
| 744 | ); | |
| 745 | expect(yield* tar.exitCode).toBe(0); | |
| 746 | const archive = yield* fileSystem.readFile(archivePath); | |
| 747 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 748 | cacheDir, | |
| 749 | toolsDir: `${cacheDir}/tools`, | |
| 750 | registryUrl, | |
| 751 | }).pipe( | |
| 752 | Effect.provideService( | |
| 753 | HttpClient.HttpClient, | |
| 754 | HttpClient.make((request) => | |
| 755 | Effect.succeed( | |
| 756 | HttpClientResponse.fromWeb( | |
| 757 | request, | |
| 758 | request.url === registryUrl | |
| 759 | ? new Response(makeRegistry(agent)) | |
| 760 | : new Response(archive), | |
| 761 | ), | |
| 762 | ), | |
| 763 | ), | |
| 764 | ), | |
| 765 | ); | |
| 766 | yield* resolver.prepare({ agentId: agent.id }); | |
| 767 | const resolved = yield* resolver.resolve(settings(), "/workspace", {}); | |
| 768 | expect(resolved.spawn.command).toBe( | |
| 769 | yield* fileSystem.realPath(`${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/agent`), | |
| 770 | ); | |
| 771 | expect(yield* fileSystem.readFileString(resolved.spawn.command)).toBe( | |
| 772 | "#!/bin/sh\necho managed\n", | |
| 773 | ); | |
| 774 | }).pipe( | |
| 775 | Effect.scoped, | |
| 776 | Effect.provide( | |
| 777 | Layer.mergeAll( | |
| 778 | NodeServices.layer, | |
| 779 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 780 | ), | |
| 781 | ), | |
| 782 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 783 | Effect.provideService(HostProcessArchitecture, "x64"), | |
| 784 | ); | |
| 785 | }); | |
| 786 | ||
| 787 | it.effect("searches compatible agents with deterministic ranking and bounded metadata", () => { | |
| 788 | const exact = { | |
| 789 | ...makeAgent({ npx: { package: "@example/acp@1.2.3" } }), | |
| 790 | id: "codex-acp", | |
| 791 | name: "Codex", | |
| 792 | authors: ["OpenAI", "Zed Industries"], | |
| 793 | license: "Apache-2.0", | |
| 794 | website: "https://example.test/codex", | |
| 795 | repository: "https://example.test/codex/source", | |
| 796 | icon: "https://example.test/codex.svg", | |
| 797 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 798 | const descriptionMatch = { | |
| 799 | ...makeAgent({ npx: { package: "other-agent@1.2.3" } }), | |
| 800 | id: "other-agent", | |
| 801 | name: "Other Agent", | |
| 802 | description: "An adapter for Codex workflows", | |
| 803 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 804 | const incompatible = { | |
| 805 | ...makeAgent({ | |
| 806 | binary: { | |
| 807 | "darwin-aarch64": { archive: archiveUrl, cmd: "agent" }, | |
| 808 | }, | |
| 809 | }), | |
| 810 | id: "darwin-only", | |
| 811 | name: "Codex Darwin", | |
| 812 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 813 | ||
| 814 | return Effect.gen(function* () { | |
| 815 | const fileSystem = yield* FileSystem.FileSystem; | |
| 816 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 817 | prefix: "t3-acp-registry-search-", | |
| 818 | }); | |
| 819 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 820 | cacheDir, | |
| 821 | toolsDir: `${cacheDir}/tools`, | |
| 822 | registryUrl, | |
| 823 | }); | |
| 824 | const result = yield* resolver.search({ query: "codex" }); | |
| 825 | ||
| 826 | expect(result.agents.map((agent) => agent.id)).toEqual(["codex-acp", "other-agent"]); | |
| 827 | expect(result.agents[0]).toMatchObject({ | |
| 828 | authors: ["OpenAI", "Zed Industries"], | |
| 829 | distribution: "npx", | |
| 830 | integrity: "registry", | |
| 831 | license: "Apache-2.0", | |
| 832 | }); | |
| 833 | }).pipe( | |
| 834 | Effect.scoped, | |
| 835 | Effect.provide( | |
| 836 | layerResolver((request) => | |
| 837 | Effect.succeed( | |
| 838 | HttpClientResponse.fromWeb( | |
| 839 | request, | |
| 840 | new Response( | |
| 841 | JSON.stringify({ | |
| 842 | version: "1.0.0", | |
| 843 | agents: [descriptionMatch, incompatible, exact], | |
| 844 | }), | |
| 845 | ), | |
| 846 | ), | |
| 847 | ), | |
| 848 | ), | |
| 849 | ), | |
| 850 | ); | |
| 851 | }); | |
| 852 | ||
| 853 | it.effect("refreshes explicit searches and coalesces concurrent refreshes", () => { | |
| 854 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 855 | let requests = 0; | |
| 856 | return Effect.gen(function* () { | |
| 857 | const fileSystem = yield* FileSystem.FileSystem; | |
| 858 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 859 | prefix: "t3-acp-registry-refresh-", | |
| 860 | }); | |
| 861 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 862 | cacheDir, | |
| 863 | toolsDir: `${cacheDir}/tools`, | |
| 864 | registryUrl, | |
| 865 | }); | |
| 866 | yield* Effect.all( | |
| 867 | [resolver.search({ query: "example" }), resolver.search({ query: "example" })], | |
| 868 | { concurrency: "unbounded" }, | |
| 869 | ); | |
| 870 | expect(requests).toBe(1); | |
| 871 | ||
| 872 | yield* resolver.search({ query: "example" }); | |
| 873 | expect(requests).toBe(2); | |
| 874 | }).pipe( | |
| 875 | Effect.scoped, | |
| 876 | Effect.provide( | |
| 877 | layerResolver((request) => { | |
| 878 | requests += 1; | |
| 879 | return Effect.yieldNow.pipe( | |
| 880 | Effect.as(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 881 | ); | |
| 882 | }), | |
| 883 | ), | |
| 884 | ); | |
| 885 | }); | |
| 886 | ||
| 887 | it.effect("filters runner recipes that the environment cannot prepare", () => { | |
| 888 | const runnerAgent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 889 | const binaryAgent = { | |
| 890 | ...makeAgent({ | |
| 891 | binary: { | |
| 892 | "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, | |
| 893 | }, | |
| 894 | }), | |
| 895 | id: "binary-agent", | |
| 896 | name: "Binary Agent", | |
| 897 | } satisfies AcpRegistrySupport.AcpRegistryAgent; | |
| 898 | return Effect.gen(function* () { | |
| 899 | const fileSystem = yield* FileSystem.FileSystem; | |
| 900 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 901 | prefix: "t3-acp-registry-runner-filter-", | |
| 902 | }); | |
| 903 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 904 | cacheDir, | |
| 905 | toolsDir: `${cacheDir}/tools`, | |
| 906 | registryUrl, | |
| 907 | }); | |
| 908 | const result = yield* resolver.search({ query: "" }); | |
| 909 | ||
| 910 | expect(result.agents.map((agent) => agent.id)).toEqual(["binary-agent"]); | |
| 911 | }).pipe( | |
| 912 | Effect.scoped, | |
| 913 | Effect.provide( | |
| 914 | layerResolver( | |
| 915 | (request) => | |
| 916 | Effect.succeed( | |
| 917 | HttpClientResponse.fromWeb( | |
| 918 | request, | |
| 919 | new Response( | |
| 920 | JSON.stringify({ version: "1.0.0", agents: [runnerAgent, binaryAgent] }), | |
| 921 | ), | |
| 922 | ), | |
| 923 | ), | |
| 924 | { PATH: "" }, | |
| 925 | ), | |
| 926 | ), | |
| 927 | ); | |
| 928 | }); | |
| 929 | ||
| 930 | it.effect("discards registry agents with blank names or unsafe versions", () => { | |
| 931 | const distribution = { | |
| 932 | binary: { | |
| 933 | "linux-x86_64": { archive: archiveUrl, cmd: "example-agent" }, | |
| 934 | }, | |
| 935 | } satisfies AcpRegistrySupport.AcpRegistryAgent["distribution"]; | |
| 936 | const valid = makeAgent(distribution); | |
| 937 | const blankName = { ...valid, id: "blank-name", name: " " }; | |
| 938 | const blankVersion = { ...valid, id: "blank-version", version: "\t" }; | |
| 939 | const parentVersion = { ...valid, id: "parent-version", version: ".." }; | |
| 940 | return Effect.gen(function* () { | |
| 941 | const fileSystem = yield* FileSystem.FileSystem; | |
| 942 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 943 | prefix: "t3-acp-registry-blank-fields-", | |
| 944 | }); | |
| 945 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 946 | cacheDir, | |
| 947 | toolsDir: `${cacheDir}/tools`, | |
| 948 | registryUrl, | |
| 949 | }); | |
| 950 | const result = yield* resolver.search({ query: "" }); | |
| 951 | ||
| 952 | expect(result.agents.map((agent) => agent.id)).toEqual([valid.id]); | |
| 953 | }).pipe( | |
| 954 | Effect.scoped, | |
| 955 | Effect.provide( | |
| 956 | layerResolver((request) => | |
| 957 | Effect.succeed( | |
| 958 | HttpClientResponse.fromWeb( | |
| 959 | request, | |
| 960 | new Response( | |
| 961 | JSON.stringify({ | |
| 962 | version: "1.0.0", | |
| 963 | agents: [blankName, blankVersion, parentVersion, valid], | |
| 964 | }), | |
| 965 | ), | |
| 966 | ), | |
| 967 | ), | |
| 968 | ), | |
| 969 | ), | |
| 970 | ); | |
| 971 | }); | |
| 972 | ||
| 973 | it.effect("ignores unpinned runner recipes from the registry", () => { | |
| 974 | const agent = makeAgent({ npx: { package: "@example/acp@latest" } }); | |
| 975 | return Effect.gen(function* () { | |
| 976 | const fileSystem = yield* FileSystem.FileSystem; | |
| 977 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 978 | prefix: "t3-acp-registry-unpinned-", | |
| 979 | }); | |
| 980 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 981 | cacheDir, | |
| 982 | toolsDir: `${cacheDir}/tools`, | |
| 983 | registryUrl, | |
| 984 | }); | |
| 985 | const result = yield* resolver.search({ query: "" }); | |
| 986 | ||
| 987 | expect(result.agents).toEqual([]); | |
| 988 | }).pipe( | |
| 989 | Effect.scoped, | |
| 990 | Effect.provide( | |
| 991 | layerResolver((request) => | |
| 992 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 993 | ), | |
| 994 | ), | |
| 995 | ); | |
| 996 | }); | |
| 997 | ||
| 998 | it.effect("rejects package syntax for the wrong runner", () => { | |
| 999 | const invalidAgents = [ | |
| 1000 | { ...makeAgent({ npx: { package: "example-agent==1.2.3" } }), id: "bad-npx" }, | |
| 1001 | ]; | |
| 1002 | const validAgents = [ | |
| 1003 | { ...makeAgent({ uvx: { package: "minion-code@0.1.44" } }), id: "valid-at" }, | |
| 1004 | { ...makeAgent({ uvx: { package: "fast-agent-acp==0.9.30" } }), id: "valid-equals" }, | |
| 1005 | ]; | |
| 1006 | return Effect.gen(function* () { | |
| 1007 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1008 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1009 | prefix: "t3-acp-registry-runner-syntax-", | |
| 1010 | }); | |
| 1011 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1012 | cacheDir, | |
| 1013 | toolsDir: `${cacheDir}/tools`, | |
| 1014 | registryUrl, | |
| 1015 | }); | |
| 1016 | const invalid = yield* resolver.prepare({ agentId: "bad-npx" }).pipe(Effect.flip); | |
| 1017 | const validAt = yield* resolver.prepare({ agentId: "valid-at" }).pipe(Effect.flip); | |
| 1018 | const validEquals = yield* resolver.prepare({ agentId: "valid-equals" }).pipe(Effect.flip); | |
| 1019 | ||
| 1020 | expect(invalid.reason).toBe("agent_not_found"); | |
| 1021 | expect(validAt.reason).toBe("runner_unavailable"); | |
| 1022 | expect(validEquals.reason).toBe("runner_unavailable"); | |
| 1023 | }).pipe( | |
| 1024 | Effect.scoped, | |
| 1025 | Effect.provide( | |
| 1026 | layerResolver( | |
| 1027 | (request) => | |
| 1028 | Effect.succeed( | |
| 1029 | HttpClientResponse.fromWeb( | |
| 1030 | request, | |
| 1031 | new Response( | |
| 1032 | JSON.stringify({ version: "1.0.0", agents: [...invalidAgents, ...validAgents] }), | |
| 1033 | ), | |
| 1034 | ), | |
| 1035 | ), | |
| 1036 | { PATH: "" }, | |
| 1037 | ), | |
| 1038 | ), | |
| 1039 | ); | |
| 1040 | }); | |
| 1041 | ||
| 1042 | it.effect("verifies declared SHA-256 before installing a binary", () => { | |
| 1043 | const agent = makeAgent({ | |
| 1044 | binary: { | |
| 1045 | "linux-x86_64": { | |
| 1046 | archive: archiveUrl, | |
| 1047 | cmd: "example-agent", | |
| 1048 | sha256: "0".repeat(64), | |
| 1049 | }, | |
| 1050 | }, | |
| 1051 | }); | |
| 1052 | const binaryBytes = new TextEncoder().encode("not the declared binary"); | |
| 1053 | ||
| 1054 | return Effect.gen(function* () { | |
| 1055 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1056 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1057 | prefix: "t3-acp-registry-checksum-", | |
| 1058 | }); | |
| 1059 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1060 | cacheDir, | |
| 1061 | toolsDir: `${cacheDir}/tools`, | |
| 1062 | registryUrl, | |
| 1063 | }); | |
| 1064 | const error = yield* resolver.prepare({ agentId: agent.id }).pipe(Effect.flip); | |
| 1065 | ||
| 1066 | expect(error.reason).toBe("checksum_mismatch"); | |
| 1067 | }).pipe( | |
| 1068 | Effect.scoped, | |
| 1069 | Effect.provide( | |
| 1070 | layerResolver((request) => | |
| 1071 | Effect.succeed( | |
| 1072 | HttpClientResponse.fromWeb( | |
| 1073 | request, | |
| 1074 | request.url === registryUrl | |
| 1075 | ? new Response(makeRegistry(agent)) | |
| 1076 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1077 | ), | |
| 1078 | ), | |
| 1079 | ), | |
| 1080 | ), | |
| 1081 | ); | |
| 1082 | }); | |
| 1083 | ||
| 1084 | it.effect("installs into T3 home package recipes during preparation", () => { | |
| 1085 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3", args: ["--stdio"] } }); | |
| 1086 | const requests: string[] = []; | |
| 1087 | ||
| 1088 | return Effect.gen(function* () { | |
| 1089 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1090 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1091 | prefix: "t3-acp-registry-runner-", | |
| 1092 | }); | |
| 1093 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1094 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1095 | cacheDir, | |
| 1096 | toolsDir: `${cacheDir}/tools`, | |
| 1097 | registryUrl, | |
| 1098 | }).pipe(Effect.provideService(HostProcessEnvironment, toolchain.environment)); | |
| 1099 | const prepared = yield* resolver.prepare({ agentId: agent.id }); | |
| 1100 | expect(prepared).toEqual({ | |
| 1101 | agentId: "example-agent", | |
| 1102 | version: "1.2.3", | |
| 1103 | distribution: "npx", | |
| 1104 | prepared: true, | |
| 1105 | }); | |
| 1106 | expect(yield* fileSystem.exists(toolchain.executablePath)).toBe(true); | |
| 1107 | expect(yield* fileSystem.exists(toolchain.environment.FAKE_NPM_PREFIX)).toBe(false); | |
| 1108 | expect(yield* fileSystem.readFileString(toolchain.logPath)).toContain( | |
| 1109 | "install --global @example/acp@1.2.3", | |
| 1110 | ); | |
| 1111 | expect(requests).toEqual([registryUrl]); | |
| 1112 | }).pipe( | |
| 1113 | Effect.scoped, | |
| 1114 | Effect.provide( | |
| 1115 | layerResolver((request) => { | |
| 1116 | requests.push(request.url); | |
| 1117 | return Effect.succeed( | |
| 1118 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1119 | ); | |
| 1120 | }, process.env), | |
| 1121 | ), | |
| 1122 | ); | |
| 1123 | }); | |
| 1124 | ||
| 1125 | it.effect("falls back to a valid cached registry index when refresh fails", () => { | |
| 1126 | const agent = makeAgent({ | |
| 1127 | npx: { | |
| 1128 | package: "@example/acp@1.2.3", | |
| 1129 | args: ["--stdio"], | |
| 1130 | }, | |
| 1131 | }); | |
| 1132 | return Effect.gen(function* () { | |
| 1133 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1134 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1135 | prefix: "t3-acp-registry-cache-", | |
| 1136 | }); | |
| 1137 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1138 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1139 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1140 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1141 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1142 | cacheDir, | |
| 1143 | toolsDir: `${cacheDir}/tools`, | |
| 1144 | registryUrl, | |
| 1145 | }); | |
| 1146 | const resolved = yield* resolver.resolve(settings(), "/workspace", toolchain.environment); | |
| 1147 | ||
| 1148 | expect(resolved.spawn).toMatchObject({ | |
| 1149 | command: toolchain.executablePath, | |
| 1150 | args: ["--stdio"], | |
| 1151 | }); | |
| 1152 | }).pipe( | |
| 1153 | Effect.scoped, | |
| 1154 | Effect.provide( | |
| 1155 | layerResolver((request) => | |
| 1156 | Effect.succeed( | |
| 1157 | HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })), | |
| 1158 | ), | |
| 1159 | ), | |
| 1160 | ), | |
| 1161 | ); | |
| 1162 | }); | |
| 1163 | ||
| 1164 | it.effect("rejects unsafe command paths before downloading an archive", () => { | |
| 1165 | const agent = makeAgent({ | |
| 1166 | binary: { | |
| 1167 | "linux-x86_64": { | |
| 1168 | archive: archiveUrl, | |
| 1169 | cmd: "../outside", | |
| 1170 | }, | |
| 1171 | }, | |
| 1172 | }); | |
| 1173 | const requests: Array<string> = []; | |
| 1174 | return Effect.gen(function* () { | |
| 1175 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1176 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1177 | prefix: "t3-acp-registry-invalid-", | |
| 1178 | }); | |
| 1179 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1180 | cacheDir, | |
| 1181 | toolsDir: `${cacheDir}/tools`, | |
| 1182 | registryUrl, | |
| 1183 | }); | |
| 1184 | const error = yield* resolver.resolve(settings(), "/workspace").pipe(Effect.flip); | |
| 1185 | ||
| 1186 | expect(error.reason).toBe("archive_invalid"); | |
| 1187 | expect(requests).toEqual([registryUrl]); | |
| 1188 | }).pipe( | |
| 1189 | Effect.scoped, | |
| 1190 | Effect.provide( | |
| 1191 | layerResolver((request) => { | |
| 1192 | requests.push(request.url); | |
| 1193 | return Effect.succeed( | |
| 1194 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1195 | ); | |
| 1196 | }), | |
| 1197 | ), | |
| 1198 | ); | |
| 1199 | }); | |
| 1200 | ||
| 1201 | it.effect("inspects from disk without waiting for a registry refresh", () => { | |
| 1202 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 1203 | let requests = 0; | |
| 1204 | return Effect.gen(function* () { | |
| 1205 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1206 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1207 | prefix: "t3-acp-registry-cold-inspect-", | |
| 1208 | }); | |
| 1209 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1210 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1211 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1212 | ||
| 1213 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1214 | cacheDir, | |
| 1215 | toolsDir: `${cacheDir}/tools`, | |
| 1216 | registryUrl, | |
| 1217 | }); | |
| 1218 | const inspection = yield* resolver.inspect(settings()); | |
| 1219 | ||
| 1220 | expect(inspection).toMatchObject({ status: "ready", agentId: agent.id }); | |
| 1221 | expect(requests).toBe(0); | |
| 1222 | }).pipe( | |
| 1223 | Effect.scoped, | |
| 1224 | Effect.provide( | |
| 1225 | layerResolver((request) => { | |
| 1226 | requests += 1; | |
| 1227 | return Effect.succeed( | |
| 1228 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1229 | ); | |
| 1230 | }), | |
| 1231 | ), | |
| 1232 | ); | |
| 1233 | }); | |
| 1234 | ||
| 1235 | it.effect("fails a cold inspection immediately when no local registry is available", () => { | |
| 1236 | let requests = 0; | |
| 1237 | return Effect.gen(function* () { | |
| 1238 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1239 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1240 | prefix: "t3-acp-registry-empty-inspect-", | |
| 1241 | }); | |
| 1242 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1243 | cacheDir, | |
| 1244 | toolsDir: `${cacheDir}/tools`, | |
| 1245 | registryUrl, | |
| 1246 | }); | |
| 1247 | const error = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1248 | ||
| 1249 | expect(error.reason).toBe("registry_unavailable"); | |
| 1250 | expect(requests).toBe(0); | |
| 1251 | }).pipe( | |
| 1252 | Effect.scoped, | |
| 1253 | Effect.provide( | |
| 1254 | layerResolver((request) => { | |
| 1255 | requests += 1; | |
| 1256 | return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))); | |
| 1257 | }), | |
| 1258 | ), | |
| 1259 | ); | |
| 1260 | }); | |
| 1261 | ||
| 1262 | it.effect("uses the effective provider environment for inspection and resolution", () => { | |
| 1263 | const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); | |
| 1264 | return Effect.gen(function* () { | |
| 1265 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1266 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1267 | prefix: "t3-acp-registry-effective-env-", | |
| 1268 | }); | |
| 1269 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1270 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1271 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1272 | const toolchain = yield* makeFakeNpmToolchain(cacheDir); | |
| 1273 | ||
| 1274 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1275 | cacheDir, | |
| 1276 | toolsDir: `${cacheDir}/tools`, | |
| 1277 | registryUrl, | |
| 1278 | }); | |
| 1279 | const hostInspection = yield* resolver.inspect(settings()); | |
| 1280 | const providerEnvironment = toolchain.environment; | |
| 1281 | const instanceInspection = yield* resolver.inspect(settings(), providerEnvironment); | |
| 1282 | const resolved = yield* resolver.resolve(settings(), "/workspace", providerEnvironment); | |
| 1283 | ||
| 1284 | expect(hostInspection).toMatchObject({ status: "missing_runner", runner: "npm" }); | |
| 1285 | expect(instanceInspection).toMatchObject({ status: "ready", distribution: "npx" }); | |
| 1286 | expect(resolved.spawn).toMatchObject({ | |
| 1287 | command: toolchain.executablePath, | |
| 1288 | env: { PATH: expect.stringMatching(new RegExp(`^${toolchain.globalBin}:`, "u")) }, | |
| 1289 | }); | |
| 1290 | }).pipe( | |
| 1291 | Effect.scoped, | |
| 1292 | Effect.provide( | |
| 1293 | layerResolver( | |
| 1294 | (request) => | |
| 1295 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 1296 | { PATH: "" }, | |
| 1297 | ), | |
| 1298 | ), | |
| 1299 | ); | |
| 1300 | }); | |
| 1301 | ||
| 1302 | it.effect("requires a regular executable file in the managed binary cache", () => { | |
| 1303 | const agent = makeAgent({ | |
| 1304 | binary: { | |
| 1305 | "linux-x86_64": { | |
| 1306 | archive: archiveUrl, | |
| 1307 | cmd: "bin/example-agent", | |
| 1308 | }, | |
| 1309 | }, | |
| 1310 | }); | |
| 1311 | return Effect.gen(function* () { | |
| 1312 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1313 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1314 | prefix: "t3-acp-registry-non-file-", | |
| 1315 | }); | |
| 1316 | const registryDirectory = `${cacheDir}/acp-registry`; | |
| 1317 | const fakeExecutable = `${cacheDir}/tools/example-agent/1.2.3/linux-x86_64/bin/example-agent`; | |
| 1318 | yield* fileSystem.makeDirectory(fakeExecutable, { recursive: true }); | |
| 1319 | yield* fileSystem.makeDirectory(registryDirectory, { recursive: true }); | |
| 1320 | yield* fileSystem.writeFileString(`${registryDirectory}/registry.json`, makeRegistry(agent)); | |
| 1321 | ||
| 1322 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1323 | cacheDir, | |
| 1324 | toolsDir: `${cacheDir}/tools`, | |
| 1325 | registryUrl, | |
| 1326 | }); | |
| 1327 | const directoryError = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1328 | ||
| 1329 | expect(directoryError.reason).toBe("archive_invalid"); | |
| 1330 | ||
| 1331 | yield* fileSystem.remove(fakeExecutable, { recursive: true }); | |
| 1332 | yield* fileSystem.writeFileString(fakeExecutable, "#!/bin/sh\n"); | |
| 1333 | yield* fileSystem.chmod(fakeExecutable, 0o644); | |
| 1334 | const modeError = yield* resolver.inspect(settings()).pipe(Effect.flip); | |
| 1335 | ||
| 1336 | expect(modeError.reason).toBe("archive_invalid"); | |
| 1337 | ||
| 1338 | yield* fileSystem.chmod(fakeExecutable, 0o755); | |
| 1339 | expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); | |
| 1340 | }).pipe( | |
| 1341 | Effect.scoped, | |
| 1342 | Effect.provide( | |
| 1343 | layerResolver((request) => | |
| 1344 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent)))), | |
| 1345 | ), | |
| 1346 | ), | |
| 1347 | ); | |
| 1348 | }); | |
| 1349 | ||
| 1350 | it.effect.each([ | |
| 1351 | { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, | |
| 1352 | { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, | |
| 1353 | { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, | |
| 1354 | { name: "local command", source: "local", agentId: "example-agent", referenced: false }, | |
| 1355 | ] as const)( | |
| 1356 | "checks $name references before uninstalling only managed binaries", | |
| 1357 | ({ source, agentId, referenced }) => { | |
| 1358 | return Effect.gen(function* () { | |
| 1359 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1360 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1361 | prefix: "t3-acp-registry-uninstall-", | |
| 1362 | }); | |
| 1363 | const agentRoot = `${cacheDir}/tools/example-agent`; | |
| 1364 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | |
| 1365 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); | |
| 1366 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); | |
| 1367 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { | |
| 1368 | recursive: true, | |
| 1369 | }); | |
| 1370 | yield* fileSystem.writeFileString(runnerCache, "{}"); | |
| 1371 | ||
| 1372 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1373 | cacheDir, | |
| 1374 | toolsDir: `${cacheDir}/tools`, | |
| 1375 | registryUrl, | |
| 1376 | }); | |
| 1377 | const hostSettings = yield* TestProviderHostSettings; | |
| 1378 | const instanceId = ProviderInstanceId.make("uninstall-reference"); | |
| 1379 | yield* hostSettings.set({ | |
| 1380 | ...DEFAULT_SERVER_SETTINGS, | |
| 1381 | providerInstances: { | |
| 1382 | [instanceId]: { | |
| 1383 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1384 | displayName: "Uninstall reference", | |
| 1385 | enabled: true, | |
| 1386 | config: { | |
| 1387 | agentId, | |
| 1388 | commandPath: "/local/agent", | |
| 1389 | ...(source ? { source } : {}), | |
| 1390 | }, | |
| 1391 | }, | |
| 1392 | }, | |
| 1393 | }); | |
| 1394 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1395 | expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); | |
| 1396 | expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); | |
| 1397 | yield* hostSettings.set(DEFAULT_SERVER_SETTINGS); | |
| 1398 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1399 | ||
| 1400 | expect(second).toEqual({ agentId: "example-agent", removed: referenced }); | |
| 1401 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1402 | agentId: "example-agent", | |
| 1403 | removed: false, | |
| 1404 | }); | |
| 1405 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | |
| 1406 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | |
| 1407 | }).pipe( | |
| 1408 | Effect.scoped, | |
| 1409 | Effect.provide( | |
| 1410 | layerResolver((request) => | |
| 1411 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | |
| 1412 | ), | |
| 1413 | ), | |
| 1414 | ); | |
| 1415 | }, | |
| 1416 | ); | |
| 1417 | ||
| 1418 | it.effect("keeps managed package installs when removing the same agent's binaries", () => | |
| 1419 | Effect.gen(function* () { | |
| 1420 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1421 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1422 | prefix: "t3-acp-uninstall-packages-", | |
| 1423 | }); | |
| 1424 | const versionRoot = `${cacheDir}/tools/example-agent/1.2.3`; | |
| 1425 | yield* fileSystem.makeDirectory(`${versionRoot}/linux-x86_64`, { recursive: true }); | |
| 1426 | yield* fileSystem.writeFileString(`${versionRoot}/linux-x86_64/agent`, "binary"); | |
| 1427 | yield* fileSystem.makeDirectory(`${versionRoot}/npm/bin`, { recursive: true }); | |
| 1428 | yield* fileSystem.writeFileString(`${versionRoot}/npm/bin/agent`, "package command"); | |
| 1429 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1430 | cacheDir, | |
| 1431 | toolsDir: `${cacheDir}/tools`, | |
| 1432 | registryUrl, | |
| 1433 | }); | |
| 1434 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1435 | agentId: "example-agent", | |
| 1436 | removed: true, | |
| 1437 | }); | |
| 1438 | expect(yield* fileSystem.exists(`${versionRoot}/linux-x86_64`)).toBe(false); | |
| 1439 | expect(yield* fileSystem.readFileString(`${versionRoot}/npm/bin/agent`)).toBe( | |
| 1440 | "package command", | |
| 1441 | ); | |
| 1442 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1443 | agentId: "example-agent", | |
| 1444 | removed: false, | |
| 1445 | }); | |
| 1446 | }).pipe( | |
| 1447 | Effect.scoped, | |
| 1448 | Effect.provide(layerResolver(() => Effect.die("unexpected HTTP request"))), | |
| 1449 | ), | |
| 1450 | ); | |
| 1451 | ||
| 1452 | it.effect("keeps a binary prepared by another client while an uninstall is waiting", () => { | |
| 1453 | const agent = makeAgent({ | |
| 1454 | binary: { | |
| 1455 | "linux-x86_64": { | |
| 1456 | archive: archiveUrl, | |
| 1457 | cmd: "./bin/example-agent", | |
| 1458 | }, | |
| 1459 | }, | |
| 1460 | }); | |
| 1461 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1462 | return Effect.gen(function* () { | |
| 1463 | const downloadStarted = yield* Deferred.make<void>(); | |
| 1464 | const releaseDownload = yield* Deferred.make<void>(); | |
| 1465 | return yield* Effect.gen(function* () { | |
| 1466 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1467 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1468 | prefix: "t3-acp-registry-uninstall-race-", | |
| 1469 | }); | |
| 1470 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1471 | cacheDir, | |
| 1472 | toolsDir: `${cacheDir}/tools`, | |
| 1473 | registryUrl, | |
| 1474 | }); | |
| 1475 | ||
| 1476 | const prepareFiber = yield* resolver | |
| 1477 | .prepare({ agentId: agent.id }) | |
| 1478 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1479 | yield* Deferred.await(downloadStarted); | |
| 1480 | const uninstallFiber = yield* resolver | |
| 1481 | .uninstallManagedBinary({ agentId: agent.id }) | |
| 1482 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1483 | ||
| 1484 | expect(uninstallFiber.pollUnsafe()).toBeUndefined(); | |
| 1485 | yield* Deferred.succeed(releaseDownload, undefined); | |
| 1486 | ||
| 1487 | expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); | |
| 1488 | expect(yield* Fiber.join(uninstallFiber)).toEqual({ | |
| 1489 | agentId: agent.id, | |
| 1490 | removed: false, | |
| 1491 | }); | |
| 1492 | const agentRoot = `${cacheDir}/tools/${agent.id}`; | |
| 1493 | expect(yield* fileSystem.exists(agentRoot)).toBe(true); | |
| 1494 | ||
| 1495 | const hostSettings = yield* TestProviderHostSettings; | |
| 1496 | const instanceId = ProviderInstanceId.make("registry-reference"); | |
| 1497 | yield* hostSettings.set({ | |
| 1498 | ...DEFAULT_SERVER_SETTINGS, | |
| 1499 | providerInstances: { | |
| 1500 | [instanceId]: { | |
| 1501 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1502 | displayName: "Registry reference", | |
| 1503 | enabled: true, | |
| 1504 | config: { agentId: agent.id }, | |
| 1505 | }, | |
| 1506 | }, | |
| 1507 | }); | |
| 1508 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1509 | agentId: agent.id, | |
| 1510 | removed: false, | |
| 1511 | }); | |
| 1512 | yield* hostSettings.set(DEFAULT_SERVER_SETTINGS); | |
| 1513 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1514 | agentId: agent.id, | |
| 1515 | removed: true, | |
| 1516 | }); | |
| 1517 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1518 | agentId: agent.id, | |
| 1519 | removed: false, | |
| 1520 | }); | |
| 1521 | }).pipe( | |
| 1522 | Effect.scoped, | |
| 1523 | Effect.provide( | |
| 1524 | layerResolver((request) => { | |
| 1525 | if (request.url === registryUrl) { | |
| 1526 | return Effect.succeed( | |
| 1527 | HttpClientResponse.fromWeb(request, new Response(makeRegistry(agent))), | |
| 1528 | ); | |
| 1529 | } | |
| 1530 | return Deferred.succeed(downloadStarted, undefined).pipe( | |
| 1531 | Effect.andThen(Deferred.await(releaseDownload)), | |
| 1532 | Effect.as( | |
| 1533 | HttpClientResponse.fromWeb( | |
| 1534 | request, | |
| 1535 | new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1536 | ), | |
| 1537 | ), | |
| 1538 | ); | |
| 1539 | }), | |
| 1540 | ), | |
| 1541 | ); | |
| 1542 | }); | |
| 1543 | }); | |
| 1544 | ||
| 1545 | it.effect("reserves a prepared binary until a configured instance inspects it", () => { | |
| 1546 | const agent = makeAgent({ | |
| 1547 | binary: { | |
| 1548 | "linux-x86_64": { | |
| 1549 | archive: archiveUrl, | |
| 1550 | cmd: "./bin/example-agent", | |
| 1551 | }, | |
| 1552 | }, | |
| 1553 | }); | |
| 1554 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1555 | return Effect.gen(function* () { | |
| 1556 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1557 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1558 | prefix: "t3-acp-registry-uninstall-reservation-", | |
| 1559 | }); | |
| 1560 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1561 | cacheDir, | |
| 1562 | toolsDir: `${cacheDir}/tools`, | |
| 1563 | registryUrl, | |
| 1564 | }); | |
| 1565 | ||
| 1566 | yield* resolver.prepare({ agentId: agent.id }); | |
| 1567 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1568 | agentId: agent.id, | |
| 1569 | removed: false, | |
| 1570 | }); | |
| 1571 | expect(yield* resolver.inspect(settings())).toMatchObject({ status: "ready" }); | |
| 1572 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1573 | agentId: agent.id, | |
| 1574 | removed: true, | |
| 1575 | }); | |
| 1576 | }).pipe( | |
| 1577 | Effect.scoped, | |
| 1578 | Effect.provide( | |
| 1579 | layerResolver((request) => | |
| 1580 | Effect.succeed( | |
| 1581 | HttpClientResponse.fromWeb( | |
| 1582 | request, | |
| 1583 | request.url === registryUrl | |
| 1584 | ? new Response(makeRegistry(agent)) | |
| 1585 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1586 | ), | |
| 1587 | ), | |
| 1588 | ), | |
| 1589 | ), | |
| 1590 | ); | |
| 1591 | }); | |
| 1592 | ||
| 1593 | it.effect("expires an abandoned prepared-binary reservation", () => { | |
| 1594 | const agent = makeAgent({ | |
| 1595 | binary: { | |
| 1596 | "linux-x86_64": { | |
| 1597 | archive: archiveUrl, | |
| 1598 | cmd: "./bin/example-agent", | |
| 1599 | }, | |
| 1600 | }, | |
| 1601 | }); | |
| 1602 | const binaryBytes = new TextEncoder().encode("#!/bin/sh\necho example\n"); | |
| 1603 | return Effect.gen(function* () { | |
| 1604 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1605 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1606 | prefix: "t3-acp-registry-uninstall-expiry-", | |
| 1607 | }); | |
| 1608 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1609 | cacheDir, | |
| 1610 | toolsDir: `${cacheDir}/tools`, | |
| 1611 | registryUrl, | |
| 1612 | }); | |
| 1613 | ||
| 1614 | yield* resolver.prepare({ agentId: agent.id }); | |
| 1615 | yield* TestClock.adjust("31 seconds"); | |
| 1616 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1617 | agentId: agent.id, | |
| 1618 | removed: true, | |
| 1619 | }); | |
| 1620 | }).pipe( | |
| 1621 | Effect.scoped, | |
| 1622 | Effect.provide( | |
| 1623 | layerResolver((request) => | |
| 1624 | Effect.succeed( | |
| 1625 | HttpClientResponse.fromWeb( | |
| 1626 | request, | |
| 1627 | request.url === registryUrl | |
| 1628 | ? new Response(makeRegistry(agent)) | |
| 1629 | : new Response(binaryBytes.buffer as ArrayBuffer), | |
| 1630 | ), | |
| 1631 | ), | |
| 1632 | ), | |
| 1633 | ), | |
| 1634 | ); | |
| 1635 | }); | |
| 1636 | }); | |
| 1637 | ||
| 1638 | describe("acpRegistryManagedBinaryDirectories", () => { | |
| 1639 | it.effect("lists managed package and binary command directories", () => | |
| 1640 | Effect.gen(function* () { | |
| 1641 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1642 | const path = yield* Path.Path; | |
| 1643 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1644 | prefix: "t3-acp-registry-bins-", | |
| 1645 | }); | |
| 1646 | const install = (agent: string, version: string, target: string, commandDirectory = "") => | |
| 1647 | fileSystem.makeDirectory( | |
| 1648 | path.join(cacheDir, "tools", agent, version, target, commandDirectory), | |
| 1649 | { recursive: true }, | |
| 1650 | ); | |
| 1651 | yield* install("kimi", "1.49.0", "linux-x86_64", "bin"); | |
| 1652 | yield* install("kimi", "1.50.0", "linux-x86_64", "cmd"); | |
| 1653 | yield* install("kimi", "1.50.0", "darwin-aarch64"); | |
| 1654 | yield* install("other-agent", "0.2.0", "darwin-aarch64"); | |
| 1655 | const globalBin = path.join(cacheDir, "tools", "gemini", "0.56.0", "npm", "bin"); | |
| 1656 | const geminiCommand = path.join(globalBin, "gemini"); | |
| 1657 | const receiptsDirectory = path.join(cacheDir, "acp-registry", "package-installs"); | |
| 1658 | yield* fileSystem.makeDirectory(globalBin, { recursive: true }); | |
| 1659 | yield* fileSystem.makeDirectory(receiptsDirectory, { recursive: true }); | |
| 1660 | yield* fileSystem.writeFileString(geminiCommand, "#!/bin/sh\n"); | |
| 1661 | yield* fileSystem.chmod(geminiCommand, 0o755); | |
| 1662 | yield* fileSystem.writeFileString( | |
| 1663 | path.join(receiptsDirectory, "gemini.json"), | |
| 1664 | encodeUnknownJson({ | |
| 1665 | agentId: "gemini", | |
| 1666 | agentVersion: "0.56.0", | |
| 1667 | distribution: "npx", | |
| 1668 | packageSpec: "@google/gemini-cli@0.56.0", | |
| 1669 | managerPath: "/usr/bin/npm", | |
| 1670 | binDirectory: globalBin, | |
| 1671 | executablePath: geminiCommand, | |
| 1672 | packageRoot: path.join( | |
| 1673 | cacheDir, | |
| 1674 | "tools", | |
| 1675 | "gemini", | |
| 1676 | "0.56.0", | |
| 1677 | "npm", | |
| 1678 | "lib", | |
| 1679 | "node_modules", | |
| 1680 | "@google", | |
| 1681 | "gemini-cli", | |
| 1682 | ), | |
| 1683 | packageVersion: "0.56.0", | |
| 1684 | }), | |
| 1685 | ); | |
| 1686 | yield* fileSystem.writeFileString( | |
| 1687 | path.join(cacheDir, "acp-registry", "registry.json"), | |
| 1688 | encodeUnknownJson({ | |
| 1689 | version: "1.0.0", | |
| 1690 | agents: [ | |
| 1691 | { | |
| 1692 | ...makeAgent({ | |
| 1693 | binary: { | |
| 1694 | "linux-x86_64": { archive: archiveUrl, cmd: "bin/kimi" }, | |
| 1695 | }, | |
| 1696 | }), | |
| 1697 | id: "kimi", | |
| 1698 | name: "Kimi", | |
| 1699 | version: "1.49.0", | |
| 1700 | }, | |
| 1701 | { | |
| 1702 | ...makeAgent({ | |
| 1703 | binary: { | |
| 1704 | "linux-x86_64": { archive: archiveUrl, cmd: "cmd/kimi" }, | |
| 1705 | }, | |
| 1706 | }), | |
| 1707 | id: "kimi", | |
| 1708 | name: "Kimi", | |
| 1709 | version: "1.50.0", | |
| 1710 | }, | |
| 1711 | ], | |
| 1712 | }), | |
| 1713 | ); | |
| 1714 | ||
| 1715 | const directories = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({ | |
| 1716 | fileSystem, | |
| 1717 | path, | |
| 1718 | cacheDir, | |
| 1719 | toolsDir: path.join(cacheDir, "tools"), | |
| 1720 | platform: "linux", | |
| 1721 | architecture: "x64", | |
| 1722 | }); | |
| 1723 | expect(directories).toEqual([ | |
| 1724 | globalBin, | |
| 1725 | path.join(cacheDir, "tools", "kimi", "1.50.0", "linux-x86_64", "cmd"), | |
| 1726 | path.join(cacheDir, "tools", "kimi", "1.49.0", "linux-x86_64", "bin"), | |
| 1727 | ]); | |
| 1728 | ||
| 1729 | const missing = yield* AcpRegistrySupport.acpRegistryManagedBinaryDirectories({ | |
| 1730 | fileSystem, | |
| 1731 | path, | |
| 1732 | cacheDir: path.join(cacheDir, "does-not-exist"), | |
| 1733 | toolsDir: path.join(cacheDir, "does-not-exist", "tools"), | |
| 1734 | platform: "linux", | |
| 1735 | architecture: "x64", | |
| 1736 | }); | |
| 1737 | expect(missing).toEqual([]); | |
| 1738 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 1739 | ); | |
| 1740 | }); |
packages/provider-acp-registry/src/server/AcpRegistrySupport.ts created+1963| ... | ... | @@ -0,0 +1,1963 @@ |
| 1 | import { | |
| 2 | AcpRegistryOperationError, | |
| 3 | AcpRegistryOperationErrorReason, | |
| 4 | TrimmedNonEmptyString, | |
| 5 | type AcpRegistryManagedBinaryUninstallInput, | |
| 6 | type AcpRegistryManagedBinaryUninstallResult, | |
| 7 | type AcpRegistryPrepareInput, | |
| 8 | type AcpRegistryPrepareResult, | |
| 9 | type AcpRegistrySearchInput, | |
| 10 | type AcpRegistrySearchResult, | |
| 11 | type AcpRegistryDistribution as AcpRegistryDistributionKind, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import type { AcpRegistryDistributionPreference } from "../settings.ts"; | |
| 14 | import type { AcpRegistrySettings } from "../settings.ts"; | |
| 15 | import { | |
| 16 | HostProcessArchitecture, | |
| 17 | HostProcessEnvironment, | |
| 18 | HostProcessPlatform, | |
| 19 | } from "@t3tools/shared/hostProcess"; | |
| 20 | import { | |
| 21 | mergePathEntries, | |
| 22 | resolveSpawnCommand, | |
| 23 | SpawnExecutableResolution, | |
| 24 | } from "@t3tools/shared/shell"; | |
| 25 | import * as Clock from "effect/Clock"; | |
| 26 | import * as Context from "effect/Context"; | |
| 27 | import * as Duration from "effect/Duration"; | |
| 28 | import * as Crypto from "effect/Crypto"; | |
| 29 | import * as Effect from "effect/Effect"; | |
| 30 | import * as Hex from "effect/encoding/Hex"; | |
| 31 | import * as FileSystem from "effect/FileSystem"; | |
| 32 | import * as Layer from "effect/Layer"; | |
| 33 | import * as Option from "effect/Option"; | |
| 34 | import * as Path from "effect/Path"; | |
| 35 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 36 | import * as PlatformError from "effect/PlatformError"; | |
| 37 | import * as Ref from "effect/Ref"; | |
| 38 | import * as Schema from "effect/Schema"; | |
| 39 | import * as Semaphore from "effect/Semaphore"; | |
| 40 | import * as Stream from "effect/Stream"; | |
| 41 | import { sha256 } from "@noble/hashes/sha2"; | |
| 42 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 43 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | |
| 44 | ||
| 45 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 46 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 47 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 48 | ||
| 49 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; | |
| 50 | ||
| 51 | const MAX_REGISTRY_BYTES = 1024 * 1024; | |
| 52 | const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; | |
| 53 | const MAX_PACKAGE_MANIFEST_BYTES = 1024 * 1024; | |
| 54 | const MAX_SEARCH_RESULTS = 20; | |
| 55 | const REGISTRY_REQUEST_TIMEOUT = "30 seconds"; | |
| 56 | const ARCHIVE_REQUEST_TIMEOUT = "20 minutes"; | |
| 57 | const PACKAGE_INSTALL_TIMEOUT = "20 minutes"; | |
| 58 | const PACKAGE_QUERY_TIMEOUT = "30 seconds"; | |
| 59 | ||
| 60 | const BoundedAgentId = Schema.String.check( | |
| 61 | Schema.isMaxLength(128), | |
| 62 | Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u), | |
| 63 | ); | |
| 64 | const BoundedName = TrimmedNonEmptyString.check(Schema.isMaxLength(160)); | |
| 65 | // Versions become install directory names, so "." and ".." must not decode. | |
| 66 | const BoundedVersion = TrimmedNonEmptyString.check( | |
| 67 | Schema.isMaxLength(128), | |
| 68 | Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u), | |
| 69 | ); | |
| 70 | const BoundedDescription = Schema.String.check(Schema.isMaxLength(1_024)); | |
| 71 | const BoundedMetadata = Schema.String.check(Schema.isMaxLength(256)); | |
| 72 | const BoundedArgument = Schema.String.check(Schema.isMaxLength(1_024)); | |
| 73 | const PackageCommandName = Schema.String.check( | |
| 74 | Schema.isMaxLength(128), | |
| 75 | Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/iu), | |
| 76 | ); | |
| 77 | const isPackageCommandName = Schema.is(PackageCommandName); | |
| 78 | const EXACT_RUNNER_VERSION = "v?[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?"; | |
| 79 | const EXACT_NPX_PACKAGE = new RegExp( | |
| 80 | `^(?:@[^/@\\s]+/[^@\\s]+|[a-z0-9][a-z0-9._-]*)@${EXACT_RUNNER_VERSION}$`, | |
| 81 | "iu", | |
| 82 | ); | |
| 83 | const EXACT_UVX_PACKAGE = new RegExp(`^[a-z0-9][a-z0-9._-]*(?:@|==)${EXACT_RUNNER_VERSION}$`, "iu"); | |
| 84 | const NpxPackage = Schema.String.check( | |
| 85 | Schema.isMaxLength(256), | |
| 86 | Schema.makeFilter( | |
| 87 | (value) => | |
| 88 | EXACT_NPX_PACKAGE.test(value) || "ACP Registry npx packages must include an exact version.", | |
| 89 | ), | |
| 90 | ); | |
| 91 | const UvxPackage = Schema.String.check( | |
| 92 | Schema.isMaxLength(256), | |
| 93 | Schema.makeFilter( | |
| 94 | (value) => | |
| 95 | EXACT_UVX_PACKAGE.test(value) || "ACP Registry uvx packages must include an exact version.", | |
| 96 | ), | |
| 97 | ); | |
| 98 | const HttpsUrl = Schema.String.check( | |
| 99 | Schema.isMaxLength(2_048), | |
| 100 | Schema.makeFilter((value) => { | |
| 101 | try { | |
| 102 | const url = new URL(value); | |
| 103 | return ( | |
| 104 | (url.protocol === "https:" && url.username.length === 0 && url.password.length === 0) || | |
| 105 | "ACP Registry URLs must use HTTPS without embedded credentials." | |
| 106 | ); | |
| 107 | } catch { | |
| 108 | return "ACP Registry URLs must be valid absolute URLs."; | |
| 109 | } | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | const AcpRegistryPackageDistributionFields = { | |
| 114 | args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), | |
| 115 | env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), | |
| 116 | } as const; | |
| 117 | const AcpRegistryNpxDistribution = Schema.Struct({ | |
| 118 | package: NpxPackage, | |
| 119 | ...AcpRegistryPackageDistributionFields, | |
| 120 | }); | |
| 121 | const AcpRegistryUvxDistribution = Schema.Struct({ | |
| 122 | package: UvxPackage, | |
| 123 | ...AcpRegistryPackageDistributionFields, | |
| 124 | }); | |
| 125 | ||
| 126 | const AcpRegistryBinaryTarget = Schema.Struct({ | |
| 127 | archive: HttpsUrl, | |
| 128 | cmd: BoundedArgument, | |
| 129 | sha256: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/iu))), | |
| 130 | args: Schema.optionalKey(Schema.Array(BoundedArgument).check(Schema.isMaxLength(64))), | |
| 131 | env: Schema.optionalKey(Schema.Record(BoundedMetadata, BoundedArgument)), | |
| 132 | }); | |
| 133 | ||
| 134 | const AcpRegistryAgent = Schema.Struct({ | |
| 135 | id: BoundedAgentId, | |
| 136 | name: BoundedName, | |
| 137 | version: BoundedVersion, | |
| 138 | description: BoundedDescription, | |
| 139 | authors: Schema.optionalKey(Schema.Array(BoundedMetadata).check(Schema.isMaxLength(16))), | |
| 140 | license: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), | |
| 141 | website: Schema.optionalKey(HttpsUrl), | |
| 142 | repository: Schema.optionalKey(HttpsUrl), | |
| 143 | icon: Schema.optionalKey(HttpsUrl), | |
| 144 | distribution: Schema.Struct({ | |
| 145 | binary: Schema.optionalKey(Schema.Record(Schema.String, AcpRegistryBinaryTarget)), | |
| 146 | npx: Schema.optionalKey(AcpRegistryNpxDistribution), | |
| 147 | uvx: Schema.optionalKey(AcpRegistryUvxDistribution), | |
| 148 | }), | |
| 149 | }); | |
| 150 | export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; | |
| 151 | ||
| 152 | const NpmPackageManifest = Schema.Struct({ | |
| 153 | name: Schema.String, | |
| 154 | version: Schema.String, | |
| 155 | bin: Schema.Union([BoundedArgument, Schema.Record(PackageCommandName, BoundedArgument)]), | |
| 156 | }); | |
| 157 | ||
| 158 | const AcpRegistryPackageInstallReceipt = Schema.Struct({ | |
| 159 | agentId: BoundedAgentId, | |
| 160 | agentVersion: BoundedVersion, | |
| 161 | distribution: Schema.Literals(["npx", "uvx"]), | |
| 162 | packageSpec: Schema.String, | |
| 163 | managerPath: Schema.String, | |
| 164 | binDirectory: Schema.String, | |
| 165 | executablePath: Schema.String, | |
| 166 | packageRoot: Schema.optional(Schema.String), | |
| 167 | packageVersion: Schema.optional(Schema.String), | |
| 168 | }); | |
| 169 | type AcpRegistryPackageInstallReceipt = typeof AcpRegistryPackageInstallReceipt.Type; | |
| 170 | ||
| 171 | const AcpRegistryIndexEnvelope = Schema.Struct({ | |
| 172 | version: BoundedVersion, | |
| 173 | agents: Schema.Array(Schema.Unknown).check(Schema.isMaxLength(512)), | |
| 174 | }); | |
| 175 | export interface AcpRegistryIndex { | |
| 176 | readonly version: string; | |
| 177 | readonly agents: ReadonlyArray<AcpRegistryAgent>; | |
| 178 | } | |
| 179 | ||
| 180 | const decodeRegistryIndexEnvelope = Schema.decodeUnknownEffect(AcpRegistryIndexEnvelope); | |
| 181 | const decodeRegistryAgent = Schema.decodeUnknownOption(AcpRegistryAgent); | |
| 182 | const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 183 | const decodeNpmPackageManifest = Schema.decodeUnknownEffect( | |
| 184 | Schema.fromJsonString(NpmPackageManifest), | |
| 185 | ); | |
| 186 | const decodePackageInstallReceipt = Schema.decodeUnknownOption( | |
| 187 | Schema.fromJsonString(AcpRegistryPackageInstallReceipt), | |
| 188 | ); | |
| 189 | const encodePackageInstallReceipt = Schema.encodeSync( | |
| 190 | Schema.fromJsonString(AcpRegistryPackageInstallReceipt), | |
| 191 | ); | |
| 192 | const decodeHttpsUrl = Schema.decodeUnknownEffect(HttpsUrl); | |
| 193 | const decodeBoundedAgentId = Schema.decodeUnknownEffect(BoundedAgentId); | |
| 194 | ||
| 195 | export const AcpRegistryErrorReason = AcpRegistryOperationErrorReason; | |
| 196 | export type AcpRegistryErrorReason = typeof AcpRegistryErrorReason.Type; | |
| 197 | ||
| 198 | export class AcpRegistryError extends Schema.TaggedError<AcpRegistryError>()("AcpRegistryError", { | |
| 199 | reason: AcpRegistryErrorReason, | |
| 200 | detail: Schema.String, | |
| 201 | cause: Schema.optional(Schema.Defect()), | |
| 202 | }) { | |
| 203 | override get message(): string { | |
| 204 | return this.detail; | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistryOperationError { | |
| 209 | return new AcpRegistryOperationError({ | |
| 210 | reason: error.reason, | |
| 211 | message: error.detail, | |
| 212 | cause: error, | |
| 213 | }); | |
| 214 | } | |
| 215 | ||
| 216 | const isAcpRegistryError = Schema.is(AcpRegistryError); | |
| 217 | ||
| 218 | export type AcpRegistryPlatformTarget = | |
| 219 | | "darwin-aarch64" | |
| 220 | | "darwin-x86_64" | |
| 221 | | "linux-aarch64" | |
| 222 | | "linux-x86_64" | |
| 223 | | "windows-aarch64" | |
| 224 | | "windows-x86_64"; | |
| 225 | ||
| 226 | export function resolveAcpRegistryPlatformTarget( | |
| 227 | platform: NodeJS.Platform, | |
| 228 | architecture: NodeJS.Architecture, | |
| 229 | ): AcpRegistryPlatformTarget | undefined { | |
| 230 | const os = | |
| 231 | platform === "darwin" | |
| 232 | ? "darwin" | |
| 233 | : platform === "linux" | |
| 234 | ? "linux" | |
| 235 | : platform === "win32" | |
| 236 | ? "windows" | |
| 237 | : undefined; | |
| 238 | const arch = architecture === "arm64" ? "aarch64" : architecture === "x64" ? "x86_64" : undefined; | |
| 239 | return os && arch ? (`${os}-${arch}` as AcpRegistryPlatformTarget) : undefined; | |
| 240 | } | |
| 241 | ||
| 242 | interface ExactPackageSpec { | |
| 243 | readonly name: string; | |
| 244 | readonly version: string; | |
| 245 | } | |
| 246 | ||
| 247 | function parseNpxPackageSpec(packageSpec: string): ExactPackageSpec { | |
| 248 | const separator = packageSpec.lastIndexOf("@"); | |
| 249 | return { | |
| 250 | name: packageSpec.slice(0, separator), | |
| 251 | version: packageSpec.slice(separator + 1).replace(/^v/iu, ""), | |
| 252 | }; | |
| 253 | } | |
| 254 | ||
| 255 | function parseUvxPackageSpec(packageSpec: string): ExactPackageSpec { | |
| 256 | const equalsSeparator = packageSpec.lastIndexOf("=="); | |
| 257 | const separator = equalsSeparator >= 0 ? equalsSeparator : packageSpec.lastIndexOf("@"); | |
| 258 | const separatorLength = equalsSeparator >= 0 ? 2 : 1; | |
| 259 | return { | |
| 260 | name: packageSpec.slice(0, separator), | |
| 261 | version: packageSpec.slice(separator + separatorLength).replace(/^v/iu, ""), | |
| 262 | }; | |
| 263 | } | |
| 264 | ||
| 265 | function packageManagerFor(distribution: AcpRegistryDistributionKind): "npm" | "uv" | undefined { | |
| 266 | return distribution === "npx" ? "npm" : distribution === "uvx" ? "uv" : undefined; | |
| 267 | } | |
| 268 | ||
| 269 | function packageCommandCandidates( | |
| 270 | agent: AcpRegistryAgent, | |
| 271 | packageName: string, | |
| 272 | ): ReadonlyArray<string> { | |
| 273 | const unscopedPackageName = packageName.split("/").pop() ?? packageName; | |
| 274 | return Array.from( | |
| 275 | new Set( | |
| 276 | [agent.id, unscopedPackageName, unscopedPackageName.replace(/-acp$/u, "")].filter( | |
| 277 | isPackageCommandName, | |
| 278 | ), | |
| 279 | ), | |
| 280 | ); | |
| 281 | } | |
| 282 | ||
| 283 | function readEnvironmentPath( | |
| 284 | environment: NodeJS.ProcessEnv, | |
| 285 | platform: NodeJS.Platform, | |
| 286 | ): string | undefined { | |
| 287 | if (platform !== "win32") return environment.PATH; | |
| 288 | return ( | |
| 289 | environment.PATH ?? | |
| 290 | Object.entries(environment).find(([key]) => key.toLowerCase() === "path")?.[1] | |
| 291 | ); | |
| 292 | } | |
| 293 | ||
| 294 | function withPreferredPath( | |
| 295 | environment: NodeJS.ProcessEnv, | |
| 296 | directory: string, | |
| 297 | platform: NodeJS.Platform, | |
| 298 | ): NodeJS.ProcessEnv { | |
| 299 | return { | |
| 300 | ...environment, | |
| 301 | PATH: mergePathEntries(directory, readEnvironmentPath(environment, platform), platform), | |
| 302 | }; | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * Directories exposing installed ACP Registry commands. The integrated | |
| 307 | * terminal appends them to PATH so managed package commands and | |
| 308 | * managed binary agents are both available by name. Best effort: unreadable | |
| 309 | * directories resolve to no entries. | |
| 310 | */ | |
| 311 | export const acpRegistryManagedBinaryDirectories = (input: { | |
| 312 | readonly fileSystem: FileSystem.FileSystem; | |
| 313 | readonly path: Path.Path; | |
| 314 | readonly cacheDir: string; | |
| 315 | readonly toolsDir: string; | |
| 316 | readonly platform: NodeJS.Platform; | |
| 317 | readonly architecture: NodeJS.Architecture; | |
| 318 | }): Effect.Effect<ReadonlyArray<string>> => | |
| 319 | Effect.gen(function* () { | |
| 320 | const target = resolveAcpRegistryPlatformTarget(input.platform, input.architecture); | |
| 321 | const registryDirectory = input.path.join(input.cacheDir, "acp-registry"); | |
| 322 | const installsDirectory = input.toolsDir; | |
| 323 | const packageReceiptsDirectory = input.path.join(registryDirectory, "package-installs"); | |
| 324 | const listDirectories = (directory: string) => | |
| 325 | input.fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): Array<string> => [])); | |
| 326 | const directories: Array<string> = []; | |
| 327 | ||
| 328 | for (const receiptFile of (yield* listDirectories(packageReceiptsDirectory)).toSorted()) { | |
| 329 | const receipt = yield* input.fileSystem | |
| 330 | .readFileString(input.path.join(packageReceiptsDirectory, receiptFile)) | |
| 331 | .pipe( | |
| 332 | Effect.map(decodePackageInstallReceipt), | |
| 333 | Effect.orElseSucceed(() => Option.none()), | |
| 334 | ); | |
| 335 | if ( | |
| 336 | Option.isSome(receipt) && | |
| 337 | receipt.value.binDirectory === | |
| 338 | input.path.join( | |
| 339 | installsDirectory, | |
| 340 | receipt.value.agentId, | |
| 341 | encodeURIComponent(receipt.value.agentVersion), | |
| 342 | receipt.value.distribution === "npx" ? "npm" : "python", | |
| 343 | ...(receipt.value.distribution === "npx" && input.platform === "win32" ? [] : ["bin"]), | |
| 344 | ) && | |
| 345 | input.path.dirname(receipt.value.executablePath) === receipt.value.binDirectory && | |
| 346 | (yield* input.fileSystem | |
| 347 | .exists(receipt.value.executablePath) | |
| 348 | .pipe(Effect.orElseSucceed(() => false))) | |
| 349 | ) { | |
| 350 | directories.push(receipt.value.binDirectory); | |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 | if (target === undefined) return Array.from(new Set(directories)); | |
| 355 | const cachedAgents = yield* input.fileSystem | |
| 356 | .readFileString(input.path.join(registryDirectory, "registry.json")) | |
| 357 | .pipe( | |
| 358 | Effect.flatMap(decodeJson), | |
| 359 | Effect.flatMap(decodeRegistryIndexEnvelope), | |
| 360 | Effect.map((envelope) => | |
| 361 | envelope.agents.flatMap((candidate) => { | |
| 362 | const decoded = decodeRegistryAgent(candidate); | |
| 363 | return Option.isSome(decoded) ? [decoded.value] : []; | |
| 364 | }), | |
| 365 | ), | |
| 366 | Effect.orElseSucceed((): ReadonlyArray<AcpRegistryAgent> => []), | |
| 367 | ); | |
| 368 | const cachedAgentByInstall = new Map( | |
| 369 | // Install directories are named by the URI-encoded version. | |
| 370 | cachedAgents.map( | |
| 371 | (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const, | |
| 372 | ), | |
| 373 | ); | |
| 374 | for (const agent of (yield* listDirectories(installsDirectory)).toSorted()) { | |
| 375 | const versions = (yield* listDirectories(input.path.join(installsDirectory, agent))).toSorted( | |
| 376 | (left, right) => right.localeCompare(left, undefined, { numeric: true }), | |
| 377 | ); | |
| 378 | for (const version of versions) { | |
| 379 | const installRoot = input.path.join(installsDirectory, agent, version, target); | |
| 380 | if (yield* input.fileSystem.exists(installRoot).pipe(Effect.orElseSucceed(() => false))) { | |
| 381 | const binaryTarget = cachedAgentByInstall.get(`${agent}\0${version}`)?.distribution | |
| 382 | .binary?.[target]; | |
| 383 | const commandSegments = | |
| 384 | binaryTarget === undefined ? undefined : normalizeRegistryCommandPath(binaryTarget.cmd); | |
| 385 | const directory = | |
| 386 | commandSegments === undefined | |
| 387 | ? installRoot | |
| 388 | : input.path.join(installRoot, ...commandSegments.slice(0, -1)); | |
| 389 | if (yield* input.fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false))) { | |
| 390 | directories.push(directory); | |
| 391 | } | |
| 392 | } | |
| 393 | } | |
| 394 | } | |
| 395 | return Array.from(new Set(directories)); | |
| 396 | }); | |
| 397 | ||
| 398 | export interface ResolvedAcpRegistryDistribution { | |
| 399 | readonly kind: AcpRegistryDistributionKind; | |
| 400 | readonly args: ReadonlyArray<string>; | |
| 401 | readonly env: Readonly<Record<string, string>>; | |
| 402 | readonly binaryTarget?: typeof AcpRegistryBinaryTarget.Type; | |
| 403 | readonly packageName?: string; | |
| 404 | } | |
| 405 | ||
| 406 | export function resolveAcpRegistryDistribution(input: { | |
| 407 | readonly agent: AcpRegistryAgent; | |
| 408 | readonly preference: AcpRegistryDistributionPreference; | |
| 409 | readonly platformTarget: AcpRegistryPlatformTarget | undefined; | |
| 410 | }): ResolvedAcpRegistryDistribution | undefined { | |
| 411 | const { agent, platformTarget } = input; | |
| 412 | const binary = platformTarget ? agent.distribution.binary?.[platformTarget] : undefined; | |
| 413 | const candidates: ReadonlyArray<AcpRegistryDistributionKind> = | |
| 414 | input.preference === "auto" ? ["binary", "npx", "uvx"] : [input.preference]; | |
| 415 | ||
| 416 | for (const kind of candidates) { | |
| 417 | if (kind === "binary" && binary) { | |
| 418 | return { | |
| 419 | kind, | |
| 420 | args: binary.args ?? [], | |
| 421 | env: binary.env ?? {}, | |
| 422 | binaryTarget: binary, | |
| 423 | }; | |
| 424 | } | |
| 425 | if (kind === "npx" && agent.distribution.npx) { | |
| 426 | return { | |
| 427 | kind, | |
| 428 | args: agent.distribution.npx.args ?? [], | |
| 429 | env: agent.distribution.npx.env ?? {}, | |
| 430 | packageName: agent.distribution.npx.package, | |
| 431 | }; | |
| 432 | } | |
| 433 | if (kind === "uvx" && agent.distribution.uvx) { | |
| 434 | return { | |
| 435 | kind, | |
| 436 | args: agent.distribution.uvx.args ?? [], | |
| 437 | env: agent.distribution.uvx.env ?? {}, | |
| 438 | packageName: agent.distribution.uvx.package, | |
| 439 | }; | |
| 440 | } | |
| 441 | } | |
| 442 | return undefined; | |
| 443 | } | |
| 444 | ||
| 445 | export interface ResolvedAcpRegistryAgent { | |
| 446 | readonly agent?: AcpRegistryAgent; | |
| 447 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 448 | readonly spawn: AcpSessionRuntime.AcpSpawnInput; | |
| 449 | } | |
| 450 | ||
| 451 | export type AcpRegistryInspection = | |
| 452 | | { readonly status: "unconfigured" } | |
| 453 | | { readonly status: "not_found"; readonly agentId: string } | |
| 454 | | { | |
| 455 | readonly status: "unsupported"; | |
| 456 | readonly agentId: string; | |
| 457 | readonly version: string; | |
| 458 | } | |
| 459 | | { | |
| 460 | readonly status: "missing_runner"; | |
| 461 | readonly agentId: string; | |
| 462 | readonly version: string; | |
| 463 | readonly distribution: AcpRegistryDistributionKind; | |
| 464 | readonly runner: string; | |
| 465 | } | |
| 466 | | { | |
| 467 | readonly status: "missing_runner"; | |
| 468 | readonly version: null; | |
| 469 | readonly distribution: "local"; | |
| 470 | } | |
| 471 | | { | |
| 472 | readonly status: "unprepared"; | |
| 473 | readonly agentId: string; | |
| 474 | readonly version: string; | |
| 475 | readonly distribution: "binary"; | |
| 476 | } | |
| 477 | | { | |
| 478 | readonly status: "ready"; | |
| 479 | readonly agentId: string; | |
| 480 | readonly version: string | null; | |
| 481 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 482 | readonly documentationUrl?: string; | |
| 483 | }; | |
| 484 | ||
| 485 | export class AcpRegistryCatalog extends Context.Service< | |
| 486 | AcpRegistryCatalog, | |
| 487 | { | |
| 488 | readonly search: ( | |
| 489 | input: AcpRegistrySearchInput, | |
| 490 | ) => Effect.Effect<AcpRegistrySearchResult, AcpRegistryError>; | |
| 491 | readonly prepare: ( | |
| 492 | input: AcpRegistryPrepareInput, | |
| 493 | ) => Effect.Effect<AcpRegistryPrepareResult, AcpRegistryError>; | |
| 494 | readonly inspect: ( | |
| 495 | settings: AcpRegistrySettings, | |
| 496 | environment?: NodeJS.ProcessEnv, | |
| 497 | ) => Effect.Effect<AcpRegistryInspection, AcpRegistryError>; | |
| 498 | readonly resolve: ( | |
| 499 | settings: AcpRegistrySettings, | |
| 500 | cwd: string, | |
| 501 | environment?: NodeJS.ProcessEnv, | |
| 502 | ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; | |
| 503 | readonly uninstallManagedBinary: ( | |
| 504 | input: AcpRegistryManagedBinaryUninstallInput, | |
| 505 | ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>; | |
| 506 | } | |
| 507 | >()("@t3tools/provider-acp-registry/server/AcpRegistrySupport/AcpRegistryCatalog") {} | |
| 508 | ||
| 509 | const layer = (options: AcpRegistryCatalogOptions) => | |
| 510 | Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); | |
| 511 | ||
| 512 | /** | |
| 513 | * The server-lifetime catalog, with the runtime coordinator built alongside | |
| 514 | * it so setup, snapshots, and turn launch share one of each. Its cache lives | |
| 515 | * in the host's provider status cache and installed agents under the T3 | |
| 516 | * home's `tools` directory. | |
| 517 | */ | |
| 518 | export const layerFromHost = Layer.merge( | |
| 519 | Layer.unwrap( | |
| 520 | Effect.gen(function* () { | |
| 521 | const { paths } = yield* ProviderHost.ProviderHost; | |
| 522 | const path = yield* Path.Path; | |
| 523 | return layer({ | |
| 524 | cacheDir: paths.providerStatusCacheDir, | |
| 525 | toolsDir: path.join(paths.baseDir, "tools"), | |
| 526 | }); | |
| 527 | }), | |
| 528 | ), | |
| 529 | AcpRegistryRuntimeCoordinator.layer, | |
| 530 | ); | |
| 531 | ||
| 532 | export interface AcpRegistryCatalogOptions { | |
| 533 | readonly cacheDir: string; | |
| 534 | readonly toolsDir: string; | |
| 535 | readonly registryUrl?: string; | |
| 536 | } | |
| 537 | ||
| 538 | const INSTALL_LOCK_RETRY_COUNT = 300; | |
| 539 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | |
| 540 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | |
| 541 | const PREPARED_BINARY_RESERVATION_MS = 30 * 1_000; | |
| 542 | ||
| 543 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | |
| 544 | return error.reason._tag === "AlreadyExists"; | |
| 545 | } | |
| 546 | ||
| 547 | function normalizeRegistryCommandPath(command: string): ReadonlyArray<string> | undefined { | |
| 548 | const normalized = command.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); | |
| 549 | const segments = normalized.split("/").filter((segment) => segment.length > 0); | |
| 550 | if ( | |
| 551 | segments.length === 0 || | |
| 552 | normalized.startsWith("/") || | |
| 553 | /^[a-zA-Z]:/u.test(normalized) || | |
| 554 | segments.some((segment) => segment === "." || segment === "..") | |
| 555 | ) { | |
| 556 | return undefined; | |
| 557 | } | |
| 558 | return segments; | |
| 559 | } | |
| 560 | ||
| 561 | function validateArchiveEntries(output: string): boolean { | |
| 562 | return output | |
| 563 | .split(/\r?\n/u) | |
| 564 | .map((entry) => entry.trim()) | |
| 565 | .filter((entry) => entry.length > 0) | |
| 566 | .every((entry) => entry === "./" || normalizeRegistryCommandPath(entry) !== undefined); | |
| 567 | } | |
| 568 | ||
| 569 | type ArchiveKind = "raw" | "tar_bz2" | "tar_gz" | "zip"; | |
| 570 | ||
| 571 | function archiveKind(url: string): ArchiveKind { | |
| 572 | const pathname = new URL(url).pathname.toLowerCase(); | |
| 573 | if (pathname.endsWith(".tar.gz") || pathname.endsWith(".tgz")) return "tar_gz"; | |
| 574 | if (pathname.endsWith(".tar.bz2") || pathname.endsWith(".tbz2")) return "tar_bz2"; | |
| 575 | if (pathname.endsWith(".zip")) return "zip"; | |
| 576 | return "raw"; | |
| 577 | } | |
| 578 | ||
| 579 | function archiveFileName(kind: ArchiveKind): string { | |
| 580 | switch (kind) { | |
| 581 | case "tar_gz": | |
| 582 | return "agent.tar.gz"; | |
| 583 | case "tar_bz2": | |
| 584 | return "agent.tar.bz2"; | |
| 585 | case "zip": | |
| 586 | return "agent.zip"; | |
| 587 | case "raw": | |
| 588 | return "agent.bin"; | |
| 589 | } | |
| 590 | } | |
| 591 | ||
| 592 | function compareText(left: string, right: string): number { | |
| 593 | return left < right ? -1 : left > right ? 1 : 0; | |
| 594 | } | |
| 595 | ||
| 596 | function searchRank(agent: AcpRegistryAgent, query: string): number | undefined { | |
| 597 | const normalized = query.trim().toLowerCase(); | |
| 598 | if (normalized.length === 0) return 100; | |
| 599 | ||
| 600 | const id = agent.id.toLowerCase(); | |
| 601 | const name = agent.name.toLowerCase(); | |
| 602 | const authors = (agent.authors ?? []).join(" ").toLowerCase(); | |
| 603 | const description = agent.description.toLowerCase(); | |
| 604 | const terms = normalized.split(/\s+/u); | |
| 605 | if (id === normalized || name === normalized) return 0; | |
| 606 | if (id.startsWith(normalized) || name.startsWith(normalized)) return 10; | |
| 607 | ||
| 608 | const identityTokens = `${id} ${name}`.split(/[^a-z0-9]+/u).filter(Boolean); | |
| 609 | if (terms.every((term) => identityTokens.some((token) => token.startsWith(term)))) return 20; | |
| 610 | if (terms.every((term) => id.includes(term) || name.includes(term))) return 30; | |
| 611 | if (terms.every((term) => authors.includes(term))) return 40; | |
| 612 | if (terms.every((term) => `${id} ${name} ${authors} ${description}`.includes(term))) return 50; | |
| 613 | return undefined; | |
| 614 | } | |
| 615 | ||
| 616 | export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(function* ( | |
| 617 | input: AcpRegistryCatalogOptions, | |
| 618 | ): Effect.fn.Return< | |
| 619 | AcpRegistryCatalog["Service"], | |
| 620 | never, | |
| 621 | | ChildProcessSpawner.ChildProcessSpawner | |
| 622 | | Crypto.Crypto | |
| 623 | | FileSystem.FileSystem | |
| 624 | | HttpClient.HttpClient | |
| 625 | | Path.Path | |
| 626 | | ProviderHost.ProviderHost | |
| 627 | > { | |
| 628 | const crypto = yield* Crypto.Crypto; | |
| 629 | const sha256Hex = (data: Uint8Array) => | |
| 630 | crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie); | |
| 631 | const fileSystem = yield* FileSystem.FileSystem; | |
| 632 | const path = yield* Path.Path; | |
| 633 | const httpClient = yield* HttpClient.HttpClient; | |
| 634 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 635 | const { settings: hostSettings } = yield* ProviderHost.ProviderHost; | |
| 636 | const platform = yield* HostProcessPlatform; | |
| 637 | const architecture = yield* HostProcessArchitecture; | |
| 638 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 639 | const resolveExecutable = yield* SpawnExecutableResolution; | |
| 640 | const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture); | |
| 641 | const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL; | |
| 642 | const registryDirectory = path.join(input.cacheDir, "acp-registry"); | |
| 643 | const registryCachePath = path.join(registryDirectory, "registry.json"); | |
| 644 | const installsDirectory = input.toolsDir; | |
| 645 | const agentInstallRoot = (agent: AcpRegistryAgent) => | |
| 646 | path.join(installsDirectory, agent.id, encodeURIComponent(agent.version)); | |
| 647 | const packageInstallRoot = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => | |
| 648 | path.join(agentInstallRoot(agent), distribution === "npx" ? "npm" : "python"); | |
| 649 | const packageBinDirectory = (agent: AcpRegistryAgent, distribution: "npx" | "uvx") => | |
| 650 | distribution === "npx" && platform === "win32" | |
| 651 | ? packageInstallRoot(agent, distribution) | |
| 652 | : path.join(packageInstallRoot(agent, distribution), "bin"); | |
| 653 | const packageReceiptsDirectory = path.join(registryDirectory, "package-installs"); | |
| 654 | const registryRef = yield* Ref.make<AcpRegistryIndex | undefined>(undefined); | |
| 655 | const registryRevision = yield* Ref.make(0); | |
| 656 | const registrySemaphore = yield* Semaphore.make(1); | |
| 657 | const installSemaphore = yield* Semaphore.make(1); | |
| 658 | const preparedBinaryReservations = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 659 | ||
| 660 | const managerBinDirectory = Effect.fn("AcpRegistryCatalog.managerBinDirectory")(function* ( | |
| 661 | runner: string, | |
| 662 | ) { | |
| 663 | const linkedTarget = yield* fileSystem.readLink(runner).pipe(Effect.option); | |
| 664 | if (Option.isNone(linkedTarget)) return path.dirname(runner); | |
| 665 | const target = path.isAbsolute(linkedTarget.value) | |
| 666 | ? linkedTarget.value | |
| 667 | : path.resolve(path.dirname(runner), linkedTarget.value); | |
| 668 | // Some service installs expose npm/uv through a wrapper symlink into the | |
| 669 | // actual toolchain bin directory. Do not mistake npm's internal | |
| 670 | // `npm-cli.js` directory for the global command directory. | |
| 671 | return path.basename(target) === path.basename(runner) | |
| 672 | ? path.dirname(target) | |
| 673 | : path.dirname(runner); | |
| 674 | }); | |
| 675 | ||
| 676 | const reservePreparedBinary = Effect.fn("AcpRegistryCatalog.reservePreparedBinary")(function* ( | |
| 677 | agentId: string, | |
| 678 | ) { | |
| 679 | const now = yield* Clock.currentTimeMillis; | |
| 680 | yield* Ref.update(preparedBinaryReservations, (current) => { | |
| 681 | const next = new Map(Array.from(current).filter(([, expiresAt]) => expiresAt > now)); | |
| 682 | next.set(agentId, now + PREPARED_BINARY_RESERVATION_MS); | |
| 683 | return next; | |
| 684 | }); | |
| 685 | }); | |
| 686 | ||
| 687 | const consumePreparedBinaryReservation = (agentId: string) => | |
| 688 | Ref.update(preparedBinaryReservations, (current) => { | |
| 689 | if (!current.has(agentId)) return current; | |
| 690 | const next = new Map(current); | |
| 691 | next.delete(agentId); | |
| 692 | return next; | |
| 693 | }); | |
| 694 | ||
| 695 | const hasPreparedBinaryReservation = Effect.fn("AcpRegistryCatalog.hasPreparedBinaryReservation")( | |
| 696 | function* (agentId: string) { | |
| 697 | const now = yield* Clock.currentTimeMillis; | |
| 698 | return yield* Ref.modify(preparedBinaryReservations, (current) => { | |
| 699 | const expiresAt = current.get(agentId); | |
| 700 | if (expiresAt === undefined || expiresAt <= now) { | |
| 701 | if (expiresAt === undefined) return [false, current] as const; | |
| 702 | const next = new Map(current); | |
| 703 | next.delete(agentId); | |
| 704 | return [false, next] as const; | |
| 705 | } | |
| 706 | return [true, current] as const; | |
| 707 | }); | |
| 708 | }, | |
| 709 | ); | |
| 710 | ||
| 711 | const assertHttpsUrl = Effect.fn("AcpRegistryCatalog.assertHttpsUrl")(function* ( | |
| 712 | value: string, | |
| 713 | detail: string, | |
| 714 | ) { | |
| 715 | const valid = yield* decodeHttpsUrl(value).pipe(Effect.option); | |
| 716 | if (Option.isNone(valid)) { | |
| 717 | return yield* new AcpRegistryError({ reason: "registry_unavailable", detail }); | |
| 718 | } | |
| 719 | return value; | |
| 720 | }); | |
| 721 | ||
| 722 | const decodeRegistryText = Effect.fn("AcpRegistryCatalog.decodeRegistryText")(function* ( | |
| 723 | text: string, | |
| 724 | ) { | |
| 725 | const decoded = yield* decodeJson(text).pipe( | |
| 726 | Effect.mapError( | |
| 727 | (cause) => | |
| 728 | new AcpRegistryError({ | |
| 729 | reason: "registry_unavailable", | |
| 730 | detail: "ACP Registry returned invalid JSON.", | |
| 731 | cause, | |
| 732 | }), | |
| 733 | ), | |
| 734 | ); | |
| 735 | const envelope = yield* decodeRegistryIndexEnvelope(decoded).pipe( | |
| 736 | Effect.mapError( | |
| 737 | (cause) => | |
| 738 | new AcpRegistryError({ | |
| 739 | reason: "registry_unavailable", | |
| 740 | detail: "ACP Registry returned an invalid index.", | |
| 741 | cause, | |
| 742 | }), | |
| 743 | ), | |
| 744 | ); | |
| 745 | const agents = envelope.agents.flatMap((candidate) => { | |
| 746 | const decodedAgent = decodeRegistryAgent(candidate); | |
| 747 | return Option.isSome(decodedAgent) ? [decodedAgent.value] : []; | |
| 748 | }); | |
| 749 | const discarded = envelope.agents.length - agents.length; | |
| 750 | if (discarded > 0) { | |
| 751 | yield* Effect.logWarning("ignored invalid ACP Registry entries", { discarded }); | |
| 752 | } | |
| 753 | return { version: envelope.version, agents } satisfies AcpRegistryIndex; | |
| 754 | }); | |
| 755 | ||
| 756 | const readCachedRegistry = fileSystem | |
| 757 | .readFileString(registryCachePath) | |
| 758 | .pipe(Effect.flatMap(decodeRegistryText), Effect.option); | |
| 759 | ||
| 760 | const writeRegistryCache = (text: string) => | |
| 761 | crypto.randomUUIDv4.pipe( | |
| 762 | Effect.flatMap((id) => { | |
| 763 | const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`; | |
| 764 | return fileSystem | |
| 765 | .makeDirectory(registryDirectory, { recursive: true }) | |
| 766 | .pipe( | |
| 767 | Effect.andThen(fileSystem.writeFileString(temporaryPath, text)), | |
| 768 | Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)), | |
| 769 | Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)), | |
| 770 | ); | |
| 771 | }), | |
| 772 | Effect.ignore, | |
| 773 | ); | |
| 774 | ||
| 775 | const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () { | |
| 776 | yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS."); | |
| 777 | const response = yield* httpClient.execute(HttpClientRequest.get(registryUrl)).pipe( | |
| 778 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 779 | Effect.mapError( | |
| 780 | (cause) => | |
| 781 | new AcpRegistryError({ | |
| 782 | reason: "registry_unavailable", | |
| 783 | detail: `Could not fetch ACP Registry index from ${registryUrl}.`, | |
| 784 | cause, | |
| 785 | }), | |
| 786 | ), | |
| 787 | Effect.timeoutOrElse({ | |
| 788 | duration: REGISTRY_REQUEST_TIMEOUT, | |
| 789 | orElse: () => | |
| 790 | Effect.fail( | |
| 791 | new AcpRegistryError({ | |
| 792 | reason: "registry_unavailable", | |
| 793 | detail: "Timed out fetching ACP Registry index.", | |
| 794 | }), | |
| 795 | ), | |
| 796 | }), | |
| 797 | ); | |
| 798 | const collected = yield* collectUint8StreamText({ | |
| 799 | stream: response.stream, | |
| 800 | maxBytes: MAX_REGISTRY_BYTES + 1, | |
| 801 | }).pipe( | |
| 802 | Effect.mapError( | |
| 803 | (cause) => | |
| 804 | new AcpRegistryError({ | |
| 805 | reason: "registry_unavailable", | |
| 806 | detail: "Could not read ACP Registry response body.", | |
| 807 | cause, | |
| 808 | }), | |
| 809 | ), | |
| 810 | Effect.timeoutOrElse({ | |
| 811 | duration: REGISTRY_REQUEST_TIMEOUT, | |
| 812 | orElse: () => | |
| 813 | Effect.fail( | |
| 814 | new AcpRegistryError({ | |
| 815 | reason: "registry_unavailable", | |
| 816 | detail: "Timed out reading ACP Registry response body.", | |
| 817 | }), | |
| 818 | ), | |
| 819 | }), | |
| 820 | ); | |
| 821 | if (collected.truncated || collected.bytes > MAX_REGISTRY_BYTES) { | |
| 822 | return yield* new AcpRegistryError({ | |
| 823 | reason: "registry_unavailable", | |
| 824 | detail: `ACP Registry index exceeds ${MAX_REGISTRY_BYTES} bytes.`, | |
| 825 | }); | |
| 826 | } | |
| 827 | if (collected.invalidUtf8) { | |
| 828 | return yield* new AcpRegistryError({ | |
| 829 | reason: "registry_unavailable", | |
| 830 | detail: "ACP Registry index is not valid UTF-8.", | |
| 831 | }); | |
| 832 | } | |
| 833 | const registry = yield* decodeRegistryText(collected.text); | |
| 834 | yield* writeRegistryCache(collected.text); | |
| 835 | return registry; | |
| 836 | }); | |
| 837 | ||
| 838 | const refreshRegistry = Effect.fn("AcpRegistryCatalog.refreshRegistry")(function* () { | |
| 839 | const observedRevision = yield* Ref.get(registryRevision); | |
| 840 | return yield* registrySemaphore.withPermits(1)( | |
| 841 | Effect.gen(function* () { | |
| 842 | const currentRevision = yield* Ref.get(registryRevision); | |
| 843 | const current = yield* Ref.get(registryRef); | |
| 844 | if (current !== undefined && currentRevision !== observedRevision) return current; | |
| 845 | ||
| 846 | const registry = yield* fetchRegistry().pipe( | |
| 847 | Effect.catch((networkError) => | |
| 848 | readCachedRegistry.pipe( | |
| 849 | Effect.flatMap( | |
| 850 | Option.match({ | |
| 851 | onNone: () => Effect.fail(networkError), | |
| 852 | onSome: Effect.succeed, | |
| 853 | }), | |
| 854 | ), | |
| 855 | ), | |
| 856 | ), | |
| 857 | ); | |
| 858 | yield* Ref.set(registryRef, registry); | |
| 859 | yield* Ref.update(registryRevision, (revision) => revision + 1); | |
| 860 | return registry; | |
| 861 | }), | |
| 862 | ); | |
| 863 | }); | |
| 864 | ||
| 865 | const loadCachedRegistry = Effect.fn("AcpRegistryCatalog.loadCachedRegistry")(function* () { | |
| 866 | const current = yield* Ref.get(registryRef); | |
| 867 | if (current !== undefined) return current; | |
| 868 | ||
| 869 | const cached = yield* readCachedRegistry; | |
| 870 | if (Option.isNone(cached)) { | |
| 871 | return yield* new AcpRegistryError({ | |
| 872 | reason: "registry_unavailable", | |
| 873 | detail: "No valid cached ACP Registry index is available.", | |
| 874 | }); | |
| 875 | } | |
| 876 | yield* Ref.set(registryRef, cached.value); | |
| 877 | return cached.value; | |
| 878 | }); | |
| 879 | ||
| 880 | const loadRegistry = Effect.fn("AcpRegistryCatalog.loadRegistry")(function* () { | |
| 881 | return yield* loadCachedRegistry().pipe(Effect.catch(() => refreshRegistry())); | |
| 882 | }); | |
| 883 | ||
| 884 | const runCommand = Effect.fn("AcpRegistryCatalog.runCommand")(function* ( | |
| 885 | command: string, | |
| 886 | args: ReadonlyArray<string>, | |
| 887 | options: { | |
| 888 | readonly cwd?: string; | |
| 889 | readonly env?: NodeJS.ProcessEnv; | |
| 890 | readonly timeout?: Duration.Input; | |
| 891 | readonly truncatedOutputReason?: AcpRegistryErrorReason; | |
| 892 | } = {}, | |
| 893 | ) { | |
| 894 | const collect = Effect.gen(function* () { | |
| 895 | const resolved = yield* resolveSpawnCommand( | |
| 896 | command, | |
| 897 | args, | |
| 898 | options.env === undefined ? {} : { env: options.env }, | |
| 899 | ); | |
| 900 | const child = yield* spawner.spawn( | |
| 901 | ChildProcess.make(resolved.command, resolved.args, { | |
| 902 | ...(options.cwd ? { cwd: options.cwd } : {}), | |
| 903 | ...(options.env === undefined ? {} : { env: options.env }), | |
| 904 | shell: resolved.shell, | |
| 905 | }), | |
| 906 | ); | |
| 907 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | |
| 908 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 909 | [ | |
| 910 | collectUint8StreamText({ stream: child.stdout, maxBytes: 1024 * 1024 }), | |
| 911 | collectUint8StreamText({ stream: child.stderr, maxBytes: 1024 * 1024 }), | |
| 912 | child.exitCode, | |
| 913 | ], | |
| 914 | { concurrency: "unbounded" }, | |
| 915 | ); | |
| 916 | if (Number(exitCode) !== 0) { | |
| 917 | return yield* new AcpRegistryError({ | |
| 918 | reason: "install_failed", | |
| 919 | detail: `ACP Registry install command '${command}' exited with code ${Number(exitCode)}: ${stderr.text.trim()}`, | |
| 920 | }); | |
| 921 | } | |
| 922 | // Archive listings feed validateArchiveEntries; a truncated listing would let | |
| 923 | // unvalidated entries past the traversal check, so oversized output is fatal. | |
| 924 | if (stdout.truncated) { | |
| 925 | return yield* new AcpRegistryError({ | |
| 926 | reason: options.truncatedOutputReason ?? "archive_invalid", | |
| 927 | detail: `ACP Registry install command '${command}' produced more output than expected.`, | |
| 928 | }); | |
| 929 | } | |
| 930 | return stdout.text; | |
| 931 | }); | |
| 932 | const scoped = collect.pipe( | |
| 933 | Effect.scoped, | |
| 934 | Effect.mapError((cause) => | |
| 935 | isAcpRegistryError(cause) | |
| 936 | ? cause | |
| 937 | : new AcpRegistryError({ | |
| 938 | reason: "install_failed", | |
| 939 | detail: `Could not run ACP Registry install command '${command}'.`, | |
| 940 | cause, | |
| 941 | }), | |
| 942 | ), | |
| 943 | ); | |
| 944 | return yield* options.timeout === undefined | |
| 945 | ? scoped | |
| 946 | : scoped.pipe( | |
| 947 | Effect.timeoutOrElse({ | |
| 948 | duration: options.timeout, | |
| 949 | orElse: () => | |
| 950 | Effect.fail( | |
| 951 | new AcpRegistryError({ | |
| 952 | reason: "install_failed", | |
| 953 | detail: `Timed out running ACP Registry install command '${command}'.`, | |
| 954 | }), | |
| 955 | ), | |
| 956 | }), | |
| 957 | ); | |
| 958 | }); | |
| 959 | ||
| 960 | const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) => | |
| 961 | sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe( | |
| 962 | Effect.map((digest) => | |
| 963 | path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`), | |
| 964 | ), | |
| 965 | ); | |
| 966 | ||
| 967 | const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* ( | |
| 968 | agent: AcpRegistryAgent, | |
| 969 | distribution: "npx" | "uvx", | |
| 970 | packageSpec: string, | |
| 971 | managerPath: string, | |
| 972 | ) { | |
| 973 | const receipt = yield* fileSystem | |
| 974 | .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath)) | |
| 975 | .pipe( | |
| 976 | Effect.map(decodePackageInstallReceipt), | |
| 977 | Effect.orElseSucceed(() => Option.none()), | |
| 978 | ); | |
| 979 | if ( | |
| 980 | Option.isNone(receipt) || | |
| 981 | receipt.value.agentId !== agent.id || | |
| 982 | receipt.value.agentVersion !== agent.version || | |
| 983 | receipt.value.distribution !== distribution || | |
| 984 | receipt.value.packageSpec !== packageSpec || | |
| 985 | receipt.value.managerPath !== managerPath || | |
| 986 | receipt.value.binDirectory !== packageBinDirectory(agent, distribution) || | |
| 987 | path.dirname(receipt.value.executablePath) !== receipt.value.binDirectory | |
| 988 | ) { | |
| 989 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 990 | } | |
| 991 | const executableExists = yield* fileSystem | |
| 992 | .exists(receipt.value.executablePath) | |
| 993 | .pipe(Effect.orElseSucceed(() => false)); | |
| 994 | if (!executableExists) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 995 | ||
| 996 | if (distribution === "npx") { | |
| 997 | const packageIdentity = parseNpxPackageSpec(packageSpec); | |
| 998 | if (receipt.value.packageRoot === undefined || receipt.value.packageVersion === undefined) { | |
| 999 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1000 | } | |
| 1001 | const manifest = yield* fileSystem | |
| 1002 | .readFileString(path.join(receipt.value.packageRoot, "package.json")) | |
| 1003 | .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); | |
| 1004 | if ( | |
| 1005 | Option.isNone(manifest) || | |
| 1006 | manifest.value.name !== packageIdentity.name || | |
| 1007 | manifest.value.version !== packageIdentity.version || | |
| 1008 | receipt.value.packageVersion !== packageIdentity.version | |
| 1009 | ) { | |
| 1010 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1011 | } | |
| 1012 | } | |
| 1013 | return receipt; | |
| 1014 | }); | |
| 1015 | ||
| 1016 | const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")( | |
| 1017 | function* (receipt: AcpRegistryPackageInstallReceipt) { | |
| 1018 | const receiptPath = yield* packageReceiptPath( | |
| 1019 | receipt.agentId, | |
| 1020 | receipt.distribution, | |
| 1021 | receipt.managerPath, | |
| 1022 | ); | |
| 1023 | const temporaryPath = `${receiptPath}.${process.pid}.tmp`; | |
| 1024 | yield* fileSystem.makeDirectory(packageReceiptsDirectory, { recursive: true }); | |
| 1025 | yield* fileSystem.remove(temporaryPath, { force: true }); | |
| 1026 | yield* fileSystem.writeFileString(temporaryPath, `${encodePackageInstallReceipt(receipt)}\n`); | |
| 1027 | yield* fileSystem.remove(receiptPath, { force: true }); | |
| 1028 | yield* fileSystem.rename(temporaryPath, receiptPath); | |
| 1029 | }, | |
| 1030 | Effect.mapError((cause) => | |
| 1031 | isAcpRegistryError(cause) | |
| 1032 | ? cause | |
| 1033 | : new AcpRegistryError({ | |
| 1034 | reason: "install_failed", | |
| 1035 | detail: "Could not record the globally installed ACP Registry command.", | |
| 1036 | cause, | |
| 1037 | }), | |
| 1038 | ), | |
| 1039 | ); | |
| 1040 | ||
| 1041 | const parsePackageManagerPath = Effect.fn("AcpRegistryCatalog.parsePackageManagerPath")( | |
| 1042 | function* (managerPath: string, output: string) { | |
| 1043 | const lines = output | |
| 1044 | .split(/\r?\n/u) | |
| 1045 | .map((line) => line.trim()) | |
| 1046 | .filter((line) => line.length > 0); | |
| 1047 | if (lines.length !== 1 || !path.isAbsolute(lines[0]!)) { | |
| 1048 | return yield* new AcpRegistryError({ | |
| 1049 | reason: "install_failed", | |
| 1050 | detail: `ACP Registry package manager '${managerPath}' returned an invalid global path.`, | |
| 1051 | }); | |
| 1052 | } | |
| 1053 | return lines[0]!; | |
| 1054 | }, | |
| 1055 | ); | |
| 1056 | ||
| 1057 | const npmCommandName = ( | |
| 1058 | agent: AcpRegistryAgent, | |
| 1059 | packageName: string, | |
| 1060 | manifest: typeof NpmPackageManifest.Type, | |
| 1061 | ): string | undefined => { | |
| 1062 | const packageBaseName = packageName.split("/").pop() ?? packageName; | |
| 1063 | if (typeof manifest.bin === "string") return packageBaseName; | |
| 1064 | const entries = Object.entries(manifest.bin).toSorted(([left], [right]) => | |
| 1065 | compareText(left, right), | |
| 1066 | ); | |
| 1067 | if (entries.length === 1) return entries[0]![0]; | |
| 1068 | if (manifest.bin[packageBaseName] !== undefined) return packageBaseName; | |
| 1069 | if (manifest.bin[agent.id] !== undefined) return agent.id; | |
| 1070 | return new Set(entries.map(([, commandPath]) => commandPath)).size === 1 | |
| 1071 | ? entries[0]?.[0] | |
| 1072 | : undefined; | |
| 1073 | }; | |
| 1074 | ||
| 1075 | const discoverNpmGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverNpmGlobalPackage")( | |
| 1076 | function* ( | |
| 1077 | agent: AcpRegistryAgent, | |
| 1078 | packageSpec: string, | |
| 1079 | managerPath: string, | |
| 1080 | environment: NodeJS.ProcessEnv, | |
| 1081 | ) { | |
| 1082 | const packageIdentity = parseNpxPackageSpec(packageSpec); | |
| 1083 | const commandOptions = { | |
| 1084 | env: environment, | |
| 1085 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1086 | truncatedOutputReason: "install_failed" as const, | |
| 1087 | } as const; | |
| 1088 | const globalRoot = yield* runCommand(managerPath, ["root", "--global"], commandOptions).pipe( | |
| 1089 | Effect.flatMap((output) => parsePackageManagerPath(managerPath, output)), | |
| 1090 | ); | |
| 1091 | const globalPrefix = yield* runCommand( | |
| 1092 | managerPath, | |
| 1093 | ["prefix", "--global"], | |
| 1094 | commandOptions, | |
| 1095 | ).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); | |
| 1096 | const packageRoot = path.join(globalRoot, ...packageIdentity.name.split("/")); | |
| 1097 | const manifestPath = path.join(packageRoot, "package.json"); | |
| 1098 | const manifestInfo = yield* fileSystem.stat(manifestPath).pipe(Effect.option); | |
| 1099 | if (Option.isNone(manifestInfo) || manifestInfo.value.size > MAX_PACKAGE_MANIFEST_BYTES) { | |
| 1100 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1101 | } | |
| 1102 | const manifest = yield* fileSystem | |
| 1103 | .readFileString(manifestPath) | |
| 1104 | .pipe(Effect.flatMap(decodeNpmPackageManifest), Effect.option); | |
| 1105 | if ( | |
| 1106 | Option.isNone(manifest) || | |
| 1107 | manifest.value.name !== packageIdentity.name || | |
| 1108 | manifest.value.version !== packageIdentity.version | |
| 1109 | ) { | |
| 1110 | return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1111 | } | |
| 1112 | const commandName = npmCommandName(agent, packageIdentity.name, manifest.value); | |
| 1113 | if (commandName === undefined) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1114 | const binDirectory = platform === "win32" ? globalPrefix : path.join(globalPrefix, "bin"); | |
| 1115 | const executablePath = resolveExecutable( | |
| 1116 | commandName, | |
| 1117 | platform, | |
| 1118 | withPreferredPath(environment, binDirectory, platform), | |
| 1119 | ); | |
| 1120 | return executablePath === undefined | |
| 1121 | ? Option.none<AcpRegistryPackageInstallReceipt>() | |
| 1122 | : Option.some<AcpRegistryPackageInstallReceipt>({ | |
| 1123 | agentId: agent.id, | |
| 1124 | agentVersion: agent.version, | |
| 1125 | distribution: "npx" as const, | |
| 1126 | packageSpec, | |
| 1127 | managerPath, | |
| 1128 | binDirectory, | |
| 1129 | executablePath, | |
| 1130 | packageRoot, | |
| 1131 | packageVersion: manifest.value.version, | |
| 1132 | }); | |
| 1133 | }, | |
| 1134 | ); | |
| 1135 | ||
| 1136 | const discoverUvGlobalPackage = Effect.fn("AcpRegistryCatalog.discoverUvGlobalPackage")( | |
| 1137 | function* ( | |
| 1138 | agent: AcpRegistryAgent, | |
| 1139 | packageSpec: string, | |
| 1140 | managerPath: string, | |
| 1141 | environment: NodeJS.ProcessEnv, | |
| 1142 | ) { | |
| 1143 | const packageIdentity = parseUvxPackageSpec(packageSpec); | |
| 1144 | const installedTools = yield* runCommand(managerPath, ["tool", "list"], { | |
| 1145 | env: environment, | |
| 1146 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1147 | truncatedOutputReason: "install_failed", | |
| 1148 | }); | |
| 1149 | const normalizedPackageName = packageIdentity.name.toLowerCase().replace(/[._-]+/gu, "-"); | |
| 1150 | const exactVersionInstalled = installedTools.split(/\r?\n/u).some((line) => { | |
| 1151 | const match = /^(\S+) v(\S+)(?:\s|$)/u.exec(line); | |
| 1152 | return ( | |
| 1153 | match?.[1]?.toLowerCase().replace(/[._-]+/gu, "-") === normalizedPackageName && | |
| 1154 | match[2] === packageIdentity.version | |
| 1155 | ); | |
| 1156 | }); | |
| 1157 | if (!exactVersionInstalled) return Option.none<AcpRegistryPackageInstallReceipt>(); | |
| 1158 | const binDirectory = yield* runCommand(managerPath, ["tool", "dir", "--bin"], { | |
| 1159 | env: environment, | |
| 1160 | timeout: PACKAGE_QUERY_TIMEOUT, | |
| 1161 | truncatedOutputReason: "install_failed", | |
| 1162 | }).pipe(Effect.flatMap((output) => parsePackageManagerPath(managerPath, output))); | |
| 1163 | const commandEnvironment = withPreferredPath(environment, binDirectory, platform); | |
| 1164 | const executablePath = packageCommandCandidates(agent, packageIdentity.name) | |
| 1165 | .map((candidate) => resolveExecutable(candidate, platform, commandEnvironment)) | |
| 1166 | .find((candidate): candidate is string => candidate !== undefined); | |
| 1167 | return executablePath === undefined | |
| 1168 | ? Option.none<AcpRegistryPackageInstallReceipt>() | |
| 1169 | : Option.some<AcpRegistryPackageInstallReceipt>({ | |
| 1170 | agentId: agent.id, | |
| 1171 | agentVersion: agent.version, | |
| 1172 | distribution: "uvx" as const, | |
| 1173 | packageSpec, | |
| 1174 | managerPath, | |
| 1175 | binDirectory, | |
| 1176 | executablePath, | |
| 1177 | packageVersion: packageIdentity.version, | |
| 1178 | }); | |
| 1179 | }, | |
| 1180 | ); | |
| 1181 | ||
| 1182 | const ensureGlobalPackage = Effect.fn("AcpRegistryCatalog.ensureGlobalPackage")(function* ( | |
| 1183 | agent: AcpRegistryAgent, | |
| 1184 | distribution: "npx" | "uvx", | |
| 1185 | packageSpec: string, | |
| 1186 | environment: NodeJS.ProcessEnv, | |
| 1187 | ) { | |
| 1188 | const managerName = packageManagerFor(distribution)!; | |
| 1189 | const managerPath = resolveExecutable(managerName, platform, environment); | |
| 1190 | if (managerPath === undefined) { | |
| 1191 | return yield* new AcpRegistryError({ | |
| 1192 | reason: "runner_unavailable", | |
| 1193 | detail: `ACP Registry agent ${agent.id} requires '${managerName}', but it is not available on this environment's PATH.`, | |
| 1194 | }); | |
| 1195 | } | |
| 1196 | const receipt = yield* readPackageReceipt(agent, distribution, packageSpec, managerPath); | |
| 1197 | if (Option.isSome(receipt) && distribution === "npx") return receipt.value; | |
| 1198 | ||
| 1199 | const packageRoot = packageInstallRoot(agent, distribution); | |
| 1200 | yield* fileSystem.makeDirectory(packageRoot, { recursive: true }).pipe( | |
| 1201 | Effect.mapError( | |
| 1202 | (cause) => | |
| 1203 | new AcpRegistryError({ | |
| 1204 | reason: "install_failed", | |
| 1205 | detail: `Could not create the managed install directory for ${agent.id}.`, | |
| 1206 | cause, | |
| 1207 | }), | |
| 1208 | ), | |
| 1209 | ); | |
| 1210 | const packageEnvironment = | |
| 1211 | distribution === "npx" | |
| 1212 | ? { ...environment, npm_config_prefix: packageRoot } | |
| 1213 | : { | |
| 1214 | ...environment, | |
| 1215 | UV_TOOL_DIR: packageRoot, | |
| 1216 | UV_TOOL_BIN_DIR: packageBinDirectory(agent, distribution), | |
| 1217 | }; | |
| 1218 | if (Option.isSome(receipt)) { | |
| 1219 | const installed = yield* discoverUvGlobalPackage( | |
| 1220 | agent, | |
| 1221 | packageSpec, | |
| 1222 | managerPath, | |
| 1223 | packageEnvironment, | |
| 1224 | ); | |
| 1225 | if (Option.isSome(installed)) return installed.value; | |
| 1226 | } | |
| 1227 | ||
| 1228 | const discover = | |
| 1229 | distribution === "npx" | |
| 1230 | ? () => discoverNpmGlobalPackage(agent, packageSpec, managerPath, packageEnvironment) | |
| 1231 | : () => discoverUvGlobalPackage(agent, packageSpec, managerPath, packageEnvironment); | |
| 1232 | const existing = yield* discover(); | |
| 1233 | if (Option.isSome(existing)) { | |
| 1234 | yield* writePackageReceipt(existing.value); | |
| 1235 | return existing.value; | |
| 1236 | } | |
| 1237 | ||
| 1238 | const managerDirectory = yield* managerBinDirectory(managerPath); | |
| 1239 | const installEnvironment = withPreferredPath(packageEnvironment, managerDirectory, platform); | |
| 1240 | if (distribution === "npx") { | |
| 1241 | yield* runCommand(managerPath, ["install", "--global", packageSpec], { | |
| 1242 | env: installEnvironment, | |
| 1243 | timeout: PACKAGE_INSTALL_TIMEOUT, | |
| 1244 | truncatedOutputReason: "install_failed", | |
| 1245 | }); | |
| 1246 | } else { | |
| 1247 | yield* runCommand(managerPath, ["tool", "install", "--force", packageSpec], { | |
| 1248 | env: installEnvironment, | |
| 1249 | timeout: PACKAGE_INSTALL_TIMEOUT, | |
| 1250 | truncatedOutputReason: "install_failed", | |
| 1251 | }); | |
| 1252 | } | |
| 1253 | ||
| 1254 | if (distribution === "npx") { | |
| 1255 | const installed = yield* discoverNpmGlobalPackage( | |
| 1256 | agent, | |
| 1257 | packageSpec, | |
| 1258 | managerPath, | |
| 1259 | packageEnvironment, | |
| 1260 | ); | |
| 1261 | if (Option.isNone(installed)) { | |
| 1262 | return yield* new AcpRegistryError({ | |
| 1263 | reason: "install_failed", | |
| 1264 | detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, | |
| 1265 | }); | |
| 1266 | } | |
| 1267 | yield* writePackageReceipt(installed.value); | |
| 1268 | return installed.value; | |
| 1269 | } | |
| 1270 | ||
| 1271 | const installed = yield* discoverUvGlobalPackage( | |
| 1272 | agent, | |
| 1273 | packageSpec, | |
| 1274 | managerPath, | |
| 1275 | packageEnvironment, | |
| 1276 | ); | |
| 1277 | if (Option.isNone(installed)) { | |
| 1278 | return yield* new AcpRegistryError({ | |
| 1279 | reason: "install_failed", | |
| 1280 | detail: `ACP Registry installed ${packageSpec}, but could not resolve its global command.`, | |
| 1281 | }); | |
| 1282 | } | |
| 1283 | yield* writePackageReceipt(installed.value); | |
| 1284 | return installed.value; | |
| 1285 | }); | |
| 1286 | ||
| 1287 | const acquireInstallLock = Effect.fn("AcpRegistryCatalog.acquireInstallLock")(function* ( | |
| 1288 | lockPath: string, | |
| 1289 | ) { | |
| 1290 | for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | |
| 1291 | const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | |
| 1292 | Effect.as(true), | |
| 1293 | Effect.catch((error) => | |
| 1294 | isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | |
| 1295 | ), | |
| 1296 | ); | |
| 1297 | if (acquired) return; | |
| 1298 | const now = yield* Clock.currentTimeMillis; | |
| 1299 | const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | |
| 1300 | const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | |
| 1301 | if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | |
| 1302 | yield* fileSystem.remove(lockPath, { force: true }); | |
| 1303 | continue; | |
| 1304 | } | |
| 1305 | yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | |
| 1306 | } | |
| 1307 | return yield* new AcpRegistryError({ | |
| 1308 | reason: "install_failed", | |
| 1309 | detail: `Timed out waiting for ACP Registry install lock ${lockPath}.`, | |
| 1310 | }); | |
| 1311 | }); | |
| 1312 | ||
| 1313 | const assertExecutableInRoot = Effect.fn("AcpRegistryCatalog.assertExecutableInRoot")(function* ( | |
| 1314 | root: string, | |
| 1315 | executablePath: string, | |
| 1316 | ) { | |
| 1317 | const rootRealPath = yield* fileSystem.realPath(root); | |
| 1318 | const executableRealPath = yield* fileSystem.realPath(executablePath); | |
| 1319 | const relative = path.relative(rootRealPath, executableRealPath); | |
| 1320 | if (relative.startsWith("..") || path.isAbsolute(relative)) { | |
| 1321 | return yield* new AcpRegistryError({ | |
| 1322 | reason: "archive_invalid", | |
| 1323 | detail: "ACP Registry archive command resolves outside its installation directory.", | |
| 1324 | }); | |
| 1325 | } | |
| 1326 | const info = yield* fileSystem.stat(executableRealPath); | |
| 1327 | const hasExecutableMode = platform === "win32" || (info.mode & 0o111) !== 0; | |
| 1328 | if (info.type !== "File" || !hasExecutableMode) { | |
| 1329 | return yield* new AcpRegistryError({ | |
| 1330 | reason: "archive_invalid", | |
| 1331 | detail: "ACP Registry archive command is not a regular executable file.", | |
| 1332 | }); | |
| 1333 | } | |
| 1334 | return executableRealPath; | |
| 1335 | }); | |
| 1336 | ||
| 1337 | const binaryPaths = (agent: AcpRegistryAgent, target: typeof AcpRegistryBinaryTarget.Type) => { | |
| 1338 | const commandSegments = normalizeRegistryCommandPath(target.cmd); | |
| 1339 | if (platformTarget === undefined || commandSegments === undefined) return undefined; | |
| 1340 | const installRoot = path.join(agentInstallRoot(agent), platformTarget); | |
| 1341 | return { | |
| 1342 | commandSegments, | |
| 1343 | installRoot, | |
| 1344 | executablePath: path.join(installRoot, ...commandSegments), | |
| 1345 | }; | |
| 1346 | }; | |
| 1347 | ||
| 1348 | const installBinary = Effect.fn("AcpRegistryCatalog.installBinary")(function* ( | |
| 1349 | agent: AcpRegistryAgent, | |
| 1350 | target: typeof AcpRegistryBinaryTarget.Type, | |
| 1351 | ) { | |
| 1352 | if (platformTarget === undefined) { | |
| 1353 | return yield* new AcpRegistryError({ | |
| 1354 | reason: "unsupported_platform", | |
| 1355 | detail: `ACP Registry does not support platform ${platform}-${architecture}.`, | |
| 1356 | }); | |
| 1357 | } | |
| 1358 | const paths = binaryPaths(agent, target); | |
| 1359 | if (paths === undefined) { | |
| 1360 | return yield* new AcpRegistryError({ | |
| 1361 | reason: "archive_invalid", | |
| 1362 | detail: `ACP Registry agent ${agent.id} declares an unsafe command path '${target.cmd}'.`, | |
| 1363 | }); | |
| 1364 | } | |
| 1365 | const { commandSegments, installRoot, executablePath } = paths; | |
| 1366 | if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 1367 | return yield* assertExecutableInRoot(installRoot, executablePath).pipe( | |
| 1368 | Effect.mapError((cause) => | |
| 1369 | isAcpRegistryError(cause) | |
| 1370 | ? cause | |
| 1371 | : new AcpRegistryError({ | |
| 1372 | reason: "install_failed", | |
| 1373 | detail: `Could not validate cached ACP Registry agent ${agent.id}.`, | |
| 1374 | cause, | |
| 1375 | }), | |
| 1376 | ), | |
| 1377 | ); | |
| 1378 | } | |
| 1379 | ||
| 1380 | yield* fileSystem.makeDirectory(path.dirname(installRoot), { recursive: true }).pipe( | |
| 1381 | Effect.mapError( | |
| 1382 | (cause) => | |
| 1383 | new AcpRegistryError({ | |
| 1384 | reason: "install_failed", | |
| 1385 | detail: `Could not create ACP Registry cache for ${agent.id}.`, | |
| 1386 | cause, | |
| 1387 | }), | |
| 1388 | ), | |
| 1389 | ); | |
| 1390 | const lockPath = `${installRoot}.lock`; | |
| 1391 | yield* acquireInstallLock(lockPath).pipe( | |
| 1392 | Effect.mapError((cause) => | |
| 1393 | isAcpRegistryError(cause) | |
| 1394 | ? cause | |
| 1395 | : new AcpRegistryError({ | |
| 1396 | reason: "install_failed", | |
| 1397 | detail: `Could not acquire install lock for ACP Registry agent ${agent.id}.`, | |
| 1398 | cause, | |
| 1399 | }), | |
| 1400 | ), | |
| 1401 | ); | |
| 1402 | ||
| 1403 | return yield* Effect.gen(function* () { | |
| 1404 | if (yield* fileSystem.exists(executablePath).pipe(Effect.orElseSucceed(() => false))) { | |
| 1405 | return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 1406 | } | |
| 1407 | const temporaryRoot = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1408 | directory: path.dirname(installRoot), | |
| 1409 | prefix: `.${agent.id}-install-`, | |
| 1410 | }); | |
| 1411 | const extractionRoot = path.join(temporaryRoot, "extracted"); | |
| 1412 | yield* fileSystem.makeDirectory(extractionRoot, { recursive: true }); | |
| 1413 | const kind = archiveKind(target.archive); | |
| 1414 | const archivePath = path.join(temporaryRoot, archiveFileName(kind)); | |
| 1415 | const response = yield* httpClient.execute(HttpClientRequest.get(target.archive)).pipe( | |
| 1416 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 1417 | Effect.mapError( | |
| 1418 | (cause) => | |
| 1419 | new AcpRegistryError({ | |
| 1420 | reason: "download_failed", | |
| 1421 | detail: `Could not download ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1422 | cause, | |
| 1423 | }), | |
| 1424 | ), | |
| 1425 | Effect.timeoutOrElse({ | |
| 1426 | duration: ARCHIVE_REQUEST_TIMEOUT, | |
| 1427 | orElse: () => | |
| 1428 | Effect.fail( | |
| 1429 | new AcpRegistryError({ | |
| 1430 | reason: "download_failed", | |
| 1431 | detail: `Timed out downloading ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1432 | }), | |
| 1433 | ), | |
| 1434 | }), | |
| 1435 | ); | |
| 1436 | // Hash while streaming: archives can be up to MAX_ARCHIVE_BYTES, and | |
| 1437 | // Effect's Crypto only digests a whole buffer. | |
| 1438 | const hash = sha256.create(); | |
| 1439 | let downloadedBytes = 0; | |
| 1440 | yield* response.stream.pipe( | |
| 1441 | Stream.tap((chunk) => { | |
| 1442 | if (downloadedBytes + chunk.byteLength > MAX_ARCHIVE_BYTES) { | |
| 1443 | return Effect.fail( | |
| 1444 | new AcpRegistryError({ | |
| 1445 | reason: "archive_invalid", | |
| 1446 | detail: `ACP Registry agent ${agent.id} archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`, | |
| 1447 | }), | |
| 1448 | ); | |
| 1449 | } | |
| 1450 | downloadedBytes += chunk.byteLength; | |
| 1451 | hash.update(chunk); | |
| 1452 | return Effect.void; | |
| 1453 | }), | |
| 1454 | Stream.run(fileSystem.sink(archivePath)), | |
| 1455 | Effect.mapError((cause) => | |
| 1456 | isAcpRegistryError(cause) | |
| 1457 | ? cause | |
| 1458 | : new AcpRegistryError({ | |
| 1459 | reason: "download_failed", | |
| 1460 | detail: `Could not save ACP Registry agent ${agent.id} ${agent.version}.`, | |
| 1461 | cause, | |
| 1462 | }), | |
| 1463 | ), | |
| 1464 | Effect.timeoutOrElse({ | |
| 1465 | duration: ARCHIVE_REQUEST_TIMEOUT, | |
| 1466 | orElse: () => | |
| 1467 | Effect.fail( | |
| 1468 | new AcpRegistryError({ | |
| 1469 | reason: "download_failed", | |
| 1470 | detail: `Timed out reading ACP Registry agent ${agent.id} download.`, | |
| 1471 | }), | |
| 1472 | ), | |
| 1473 | }), | |
| 1474 | ); | |
| 1475 | if (target.sha256 !== undefined) { | |
| 1476 | const actual = Hex.encode(hash.digest()); | |
| 1477 | if (actual !== target.sha256.toLowerCase()) { | |
| 1478 | return yield* new AcpRegistryError({ | |
| 1479 | reason: "checksum_mismatch", | |
| 1480 | detail: `ACP Registry agent ${agent.id} download did not match its declared SHA-256.`, | |
| 1481 | }); | |
| 1482 | } | |
| 1483 | } | |
| 1484 | ||
| 1485 | if (kind === "raw") { | |
| 1486 | const targetPath = path.join(extractionRoot, ...commandSegments); | |
| 1487 | yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); | |
| 1488 | yield* fileSystem.rename(archivePath, targetPath); | |
| 1489 | } else { | |
| 1490 | const listArgs = | |
| 1491 | kind === "tar_gz" | |
| 1492 | ? ["-tzf", archivePath] | |
| 1493 | : kind === "tar_bz2" | |
| 1494 | ? ["-tjf", archivePath] | |
| 1495 | : platform === "win32" | |
| 1496 | ? ["-tf", archivePath] | |
| 1497 | : undefined; | |
| 1498 | const entries = | |
| 1499 | listArgs === undefined | |
| 1500 | ? yield* runCommand("unzip", ["-Z1", archivePath]) | |
| 1501 | : yield* runCommand("tar", listArgs); | |
| 1502 | if (!validateArchiveEntries(entries)) { | |
| 1503 | return yield* new AcpRegistryError({ | |
| 1504 | reason: "archive_invalid", | |
| 1505 | detail: `ACP Registry agent ${agent.id} archive contains an unsafe path.`, | |
| 1506 | }); | |
| 1507 | } | |
| 1508 | if (kind === "zip" && platform !== "win32") { | |
| 1509 | yield* runCommand("unzip", ["-q", archivePath, "-d", extractionRoot]); | |
| 1510 | } else { | |
| 1511 | const extractFlag = kind === "tar_gz" ? "-xzf" : kind === "tar_bz2" ? "-xjf" : "-xf"; | |
| 1512 | yield* runCommand("tar", [extractFlag, archivePath, "-C", extractionRoot]); | |
| 1513 | } | |
| 1514 | } | |
| 1515 | ||
| 1516 | const stagedExecutable = path.join(extractionRoot, ...commandSegments); | |
| 1517 | if (!(yield* fileSystem.exists(stagedExecutable).pipe(Effect.orElseSucceed(() => false)))) { | |
| 1518 | return yield* new AcpRegistryError({ | |
| 1519 | reason: "archive_invalid", | |
| 1520 | detail: `ACP Registry archive for ${agent.id} did not contain '${target.cmd}'.`, | |
| 1521 | }); | |
| 1522 | } | |
| 1523 | if (platform !== "win32") yield* fileSystem.chmod(stagedExecutable, 0o755); | |
| 1524 | yield* assertExecutableInRoot(extractionRoot, stagedExecutable); | |
| 1525 | yield* fileSystem.remove(installRoot, { recursive: true, force: true }); | |
| 1526 | yield* fileSystem.rename(extractionRoot, installRoot); | |
| 1527 | return yield* assertExecutableInRoot(installRoot, executablePath); | |
| 1528 | }).pipe( | |
| 1529 | Effect.scoped, | |
| 1530 | Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), | |
| 1531 | Effect.mapError((cause) => | |
| 1532 | isAcpRegistryError(cause) | |
| 1533 | ? cause | |
| 1534 | : new AcpRegistryError({ | |
| 1535 | reason: "install_failed", | |
| 1536 | detail: `Could not install ACP Registry agent ${agent.id}.`, | |
| 1537 | cause, | |
| 1538 | }), | |
| 1539 | ), | |
| 1540 | ); | |
| 1541 | }); | |
| 1542 | ||
| 1543 | const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* ( | |
| 1544 | registry: AcpRegistryIndex, | |
| 1545 | agentId: string, | |
| 1546 | ) { | |
| 1547 | const agent = registry.agents.find((candidate) => candidate.id === agentId.trim()); | |
| 1548 | if (agent === undefined) { | |
| 1549 | return yield* new AcpRegistryError({ | |
| 1550 | reason: "agent_not_found", | |
| 1551 | detail: `ACP Registry does not contain agent '${agentId.trim()}'.`, | |
| 1552 | }); | |
| 1553 | } | |
| 1554 | return agent; | |
| 1555 | }); | |
| 1556 | ||
| 1557 | const compatibleDistribution = Effect.fn("AcpRegistryCatalog.compatibleDistribution")(function* ( | |
| 1558 | agent: AcpRegistryAgent, | |
| 1559 | preference: AcpRegistryDistributionPreference, | |
| 1560 | ) { | |
| 1561 | const distribution = resolveAcpRegistryDistribution({ | |
| 1562 | agent, | |
| 1563 | preference, | |
| 1564 | platformTarget, | |
| 1565 | }); | |
| 1566 | if (distribution === undefined) { | |
| 1567 | return yield* new AcpRegistryError({ | |
| 1568 | reason: platformTarget === undefined ? "unsupported_platform" : "unsupported_distribution", | |
| 1569 | detail: `ACP Registry agent ${agent.id} has no ${preference === "auto" ? "compatible" : preference} distribution for ${platform}-${architecture}.`, | |
| 1570 | }); | |
| 1571 | } | |
| 1572 | return distribution; | |
| 1573 | }); | |
| 1574 | ||
| 1575 | const search: AcpRegistryCatalog["Service"]["search"] = (input) => | |
| 1576 | Effect.gen(function* () { | |
| 1577 | const registry = yield* refreshRegistry(); | |
| 1578 | const ranked = registry.agents.flatMap((agent) => { | |
| 1579 | const distribution = resolveAcpRegistryDistribution({ | |
| 1580 | agent, | |
| 1581 | preference: "auto", | |
| 1582 | platformTarget, | |
| 1583 | }); | |
| 1584 | const rank = searchRank(agent, input.query); | |
| 1585 | const packageManager = | |
| 1586 | distribution === undefined ? undefined : packageManagerFor(distribution.kind); | |
| 1587 | const packageManagerAvailable = | |
| 1588 | packageManager === undefined || | |
| 1589 | resolveExecutable(packageManager, platform, hostEnvironment) !== undefined; | |
| 1590 | return distribution === undefined || rank === undefined || !packageManagerAvailable | |
| 1591 | ? [] | |
| 1592 | : [{ agent, distribution, rank }]; | |
| 1593 | }); | |
| 1594 | ranked.sort( | |
| 1595 | (left, right) => | |
| 1596 | left.rank - right.rank || | |
| 1597 | compareText(left.agent.name.toLowerCase(), right.agent.name.toLowerCase()) || | |
| 1598 | compareText(left.agent.id, right.agent.id), | |
| 1599 | ); | |
| 1600 | return { | |
| 1601 | agents: ranked.slice(0, MAX_SEARCH_RESULTS).map(({ agent, distribution }) => ({ | |
| 1602 | id: agent.id, | |
| 1603 | name: agent.name, | |
| 1604 | version: agent.version, | |
| 1605 | description: agent.description, | |
| 1606 | authors: agent.authors ?? [], | |
| 1607 | license: agent.license ?? null, | |
| 1608 | website: agent.website ?? null, | |
| 1609 | repository: agent.repository ?? null, | |
| 1610 | icon: agent.icon ?? null, | |
| 1611 | distribution: distribution.kind, | |
| 1612 | integrity: | |
| 1613 | distribution.kind === "binary" && distribution.binaryTarget?.sha256 !== undefined | |
| 1614 | ? "sha256" | |
| 1615 | : "registry", | |
| 1616 | })), | |
| 1617 | } satisfies AcpRegistrySearchResult; | |
| 1618 | }); | |
| 1619 | ||
| 1620 | const prepare: AcpRegistryCatalog["Service"]["prepare"] = (input) => | |
| 1621 | Effect.gen(function* () { | |
| 1622 | const registry = yield* refreshRegistry(); | |
| 1623 | const agent = yield* findAgent(registry, input.agentId); | |
| 1624 | const distribution = yield* compatibleDistribution(agent, "auto"); | |
| 1625 | if (distribution.kind === "binary") { | |
| 1626 | // A PATH executable can be a different version with different ACP | |
| 1627 | // capabilities. Only an explicit command override opts into that copy. | |
| 1628 | yield* installSemaphore.withPermits(1)( | |
| 1629 | installBinary(agent, distribution.binaryTarget!).pipe( | |
| 1630 | Effect.tap(() => reservePreparedBinary(agent.id)), | |
| 1631 | ), | |
| 1632 | ); | |
| 1633 | } else { | |
| 1634 | yield* installSemaphore.withPermits(1)( | |
| 1635 | ensureGlobalPackage(agent, distribution.kind, distribution.packageName!, hostEnvironment), | |
| 1636 | ); | |
| 1637 | } | |
| 1638 | return { | |
| 1639 | agentId: agent.id, | |
| 1640 | version: agent.version, | |
| 1641 | distribution: distribution.kind, | |
| 1642 | prepared: true, | |
| 1643 | } satisfies AcpRegistryPrepareResult; | |
| 1644 | }); | |
| 1645 | ||
| 1646 | const validateLocalExecutable = (command: string) => | |
| 1647 | platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) | |
| 1648 | ? Effect.fail( | |
| 1649 | new AcpRegistryError({ | |
| 1650 | reason: "runner_unavailable", | |
| 1651 | detail: | |
| 1652 | "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.", | |
| 1653 | }), | |
| 1654 | ) | |
| 1655 | : Effect.void; | |
| 1656 | ||
| 1657 | const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => | |
| 1658 | Effect.gen(function* () { | |
| 1659 | if (settings.source === "local") { | |
| 1660 | const command = settings.commandPath.trim(); | |
| 1661 | if (!command) return { status: "unconfigured" } as const; | |
| 1662 | const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); | |
| 1663 | if (executable !== undefined) yield* validateLocalExecutable(executable); | |
| 1664 | return executable === undefined | |
| 1665 | ? ({ | |
| 1666 | status: "missing_runner", | |
| 1667 | version: null, | |
| 1668 | distribution: "local", | |
| 1669 | } as const) | |
| 1670 | : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); | |
| 1671 | } | |
| 1672 | const agentId = settings.agentId.trim(); | |
| 1673 | if (agentId.length === 0) return { status: "unconfigured" } as const; | |
| 1674 | const registry = yield* loadCachedRegistry(); | |
| 1675 | const agent = registry.agents.find((candidate) => candidate.id === agentId); | |
| 1676 | if (agent === undefined) return { status: "not_found", agentId } as const; | |
| 1677 | const documentationUrl = agent.website ?? agent.repository; | |
| 1678 | const distribution = resolveAcpRegistryDistribution({ | |
| 1679 | agent, | |
| 1680 | preference: settings.distribution, | |
| 1681 | platformTarget, | |
| 1682 | }); | |
| 1683 | if (distribution === undefined) { | |
| 1684 | return { status: "unsupported", agentId, version: agent.version } as const; | |
| 1685 | } | |
| 1686 | ||
| 1687 | const commandOverride = settings.commandPath.trim(); | |
| 1688 | if (commandOverride.length > 0) { | |
| 1689 | const available = | |
| 1690 | resolveExecutable(commandOverride, platform, environment ?? hostEnvironment) !== | |
| 1691 | undefined; | |
| 1692 | return available | |
| 1693 | ? ({ | |
| 1694 | status: "ready", | |
| 1695 | agentId, | |
| 1696 | // The catalog version does not describe an overridden executable. | |
| 1697 | version: null, | |
| 1698 | distribution: distribution.kind, | |
| 1699 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1700 | } as const) | |
| 1701 | : ({ | |
| 1702 | status: "missing_runner", | |
| 1703 | agentId, | |
| 1704 | version: agent.version, | |
| 1705 | distribution: distribution.kind, | |
| 1706 | runner: commandOverride, | |
| 1707 | } as const); | |
| 1708 | } | |
| 1709 | ||
| 1710 | if (distribution.kind === "binary") { | |
| 1711 | return yield* installSemaphore.withPermits(1)( | |
| 1712 | Effect.gen(function* () { | |
| 1713 | const paths = binaryPaths(agent, distribution.binaryTarget!); | |
| 1714 | if (paths === undefined) { | |
| 1715 | return { status: "unsupported", agentId, version: agent.version } as const; | |
| 1716 | } | |
| 1717 | yield* consumePreparedBinaryReservation(agent.id); | |
| 1718 | const exists = yield* fileSystem | |
| 1719 | .exists(paths.executablePath) | |
| 1720 | .pipe(Effect.orElseSucceed(() => false)); | |
| 1721 | if (!exists) { | |
| 1722 | return { | |
| 1723 | status: "unprepared", | |
| 1724 | agentId, | |
| 1725 | version: agent.version, | |
| 1726 | distribution: "binary", | |
| 1727 | } as const; | |
| 1728 | } | |
| 1729 | yield* assertExecutableInRoot(paths.installRoot, paths.executablePath).pipe( | |
| 1730 | Effect.mapError((cause) => | |
| 1731 | isAcpRegistryError(cause) | |
| 1732 | ? cause | |
| 1733 | : new AcpRegistryError({ | |
| 1734 | reason: "install_failed", | |
| 1735 | detail: `Could not validate cached ACP Registry agent ${agent.id}.`, | |
| 1736 | cause, | |
| 1737 | }), | |
| 1738 | ), | |
| 1739 | ); | |
| 1740 | return { | |
| 1741 | status: "ready", | |
| 1742 | agentId, | |
| 1743 | version: agent.version, | |
| 1744 | distribution: "binary", | |
| 1745 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1746 | } as const; | |
| 1747 | }), | |
| 1748 | ); | |
| 1749 | } | |
| 1750 | ||
| 1751 | const runner = packageManagerFor(distribution.kind)!; | |
| 1752 | const available = | |
| 1753 | resolveExecutable(runner, platform, environment ?? hostEnvironment) !== undefined; | |
| 1754 | return available | |
| 1755 | ? ({ | |
| 1756 | status: "ready", | |
| 1757 | agentId, | |
| 1758 | version: agent.version, | |
| 1759 | distribution: distribution.kind, | |
| 1760 | ...(documentationUrl ? { documentationUrl } : {}), | |
| 1761 | } as const) | |
| 1762 | : ({ | |
| 1763 | status: "missing_runner", | |
| 1764 | agentId, | |
| 1765 | version: agent.version, | |
| 1766 | distribution: distribution.kind, | |
| 1767 | runner, | |
| 1768 | } as const); | |
| 1769 | }); | |
| 1770 | ||
| 1771 | const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => | |
| 1772 | Effect.gen(function* () { | |
| 1773 | if (settings.source === "local") { | |
| 1774 | const executable = settings.commandPath.trim(); | |
| 1775 | if (!executable) { | |
| 1776 | return yield* new AcpRegistryError({ | |
| 1777 | reason: "agent_not_configured", | |
| 1778 | detail: "Local ACP provider requires an executable.", | |
| 1779 | }); | |
| 1780 | } | |
| 1781 | const env = environment ?? hostEnvironment; | |
| 1782 | const command = resolveExecutable(executable, platform, env); | |
| 1783 | if (command === undefined) { | |
| 1784 | return yield* new AcpRegistryError({ | |
| 1785 | reason: "runner_unavailable", | |
| 1786 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 1787 | }); | |
| 1788 | } | |
| 1789 | yield* validateLocalExecutable(command); | |
| 1790 | return { | |
| 1791 | distribution: "local", | |
| 1792 | spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, | |
| 1793 | } satisfies ResolvedAcpRegistryAgent; | |
| 1794 | } | |
| 1795 | const agentId = settings.agentId.trim(); | |
| 1796 | if (agentId.length === 0) { | |
| 1797 | return yield* new AcpRegistryError({ | |
| 1798 | reason: "agent_not_configured", | |
| 1799 | detail: "ACP Registry provider requires a registry agent ID.", | |
| 1800 | }); | |
| 1801 | } | |
| 1802 | const registry = yield* loadRegistry(); | |
| 1803 | const agent = yield* findAgent(registry, agentId); | |
| 1804 | const distribution = yield* compatibleDistribution(agent, settings.distribution); | |
| 1805 | ||
| 1806 | let command: string; | |
| 1807 | let args: ReadonlyArray<string>; | |
| 1808 | let commandBinDirectory: string | undefined; | |
| 1809 | const effectiveEnvironment = environment ?? hostEnvironment; | |
| 1810 | const commandOverride = settings.commandPath.trim(); | |
| 1811 | if (commandOverride.length > 0) { | |
| 1812 | const resolvedOverride = resolveExecutable(commandOverride, platform, effectiveEnvironment); | |
| 1813 | if (resolvedOverride === undefined) { | |
| 1814 | return yield* new AcpRegistryError({ | |
| 1815 | reason: "runner_unavailable", | |
| 1816 | detail: `ACP Registry agent ${agent.id} requires '${commandOverride}', but it is not available on this provider instance's PATH.`, | |
| 1817 | }); | |
| 1818 | } | |
| 1819 | command = resolvedOverride; | |
| 1820 | args = distribution.args; | |
| 1821 | } else if (distribution.kind === "npx" || distribution.kind === "uvx") { | |
| 1822 | const installed = yield* installSemaphore.withPermits(1)( | |
| 1823 | ensureGlobalPackage( | |
| 1824 | agent, | |
| 1825 | distribution.kind, | |
| 1826 | distribution.packageName!, | |
| 1827 | effectiveEnvironment, | |
| 1828 | ), | |
| 1829 | ); | |
| 1830 | command = installed.executablePath; | |
| 1831 | args = distribution.args; | |
| 1832 | commandBinDirectory = installed.binDirectory; | |
| 1833 | } else { | |
| 1834 | const target = distribution.binaryTarget!; | |
| 1835 | command = yield* installSemaphore.withPermits(1)( | |
| 1836 | installBinary(agent, target).pipe( | |
| 1837 | Effect.tap(() => consumePreparedBinaryReservation(agent.id)), | |
| 1838 | ), | |
| 1839 | ); | |
| 1840 | args = distribution.args; | |
| 1841 | } | |
| 1842 | ||
| 1843 | const baseSpawnEnvironment = { | |
| 1844 | ...effectiveEnvironment, | |
| 1845 | ...distribution.env, | |
| 1846 | }; | |
| 1847 | const spawnEnvironment = | |
| 1848 | commandBinDirectory === undefined | |
| 1849 | ? baseSpawnEnvironment | |
| 1850 | : withPreferredPath(baseSpawnEnvironment, commandBinDirectory, platform); | |
| 1851 | ||
| 1852 | return { | |
| 1853 | agent, | |
| 1854 | distribution: distribution.kind, | |
| 1855 | spawn: { | |
| 1856 | command, | |
| 1857 | args, | |
| 1858 | cwd, | |
| 1859 | env: spawnEnvironment, | |
| 1860 | }, | |
| 1861 | } satisfies ResolvedAcpRegistryAgent; | |
| 1862 | }); | |
| 1863 | ||
| 1864 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => | |
| 1865 | hostSettings | |
| 1866 | .withSnapshot((settings) => | |
| 1867 | installSemaphore.withPermits(1)( | |
| 1868 | Effect.gen(function* () { | |
| 1869 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( | |
| 1870 | Effect.mapError( | |
| 1871 | (cause) => | |
| 1872 | new AcpRegistryError({ | |
| 1873 | reason: "install_failed", | |
| 1874 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", | |
| 1875 | cause, | |
| 1876 | }), | |
| 1877 | ), | |
| 1878 | ); | |
| 1879 | const isReferenced = Object.values(settings.providerInstances).some((instance) => { | |
| 1880 | if ( | |
| 1881 | instance.driver !== "acpRegistry" || | |
| 1882 | instance.config === null || | |
| 1883 | typeof instance.config !== "object" | |
| 1884 | ) | |
| 1885 | return false; | |
| 1886 | const config = instance.config as Record<string, unknown>; | |
| 1887 | return ( | |
| 1888 | config.source !== "local" && | |
| 1889 | typeof config.agentId === "string" && | |
| 1890 | config.agentId.trim() === safeAgentId | |
| 1891 | ); | |
| 1892 | }); | |
| 1893 | if (isReferenced) { | |
| 1894 | yield* consumePreparedBinaryReservation(safeAgentId); | |
| 1895 | return { agentId: safeAgentId, removed: false }; | |
| 1896 | } | |
| 1897 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { | |
| 1898 | return { agentId: safeAgentId, removed: false }; | |
| 1899 | } | |
| 1900 | const agentRoot = path.join(installsDirectory, safeAgentId); | |
| 1901 | const existed = yield* fileSystem.exists(agentRoot).pipe( | |
| 1902 | Effect.mapError( | |
| 1903 | (cause) => | |
| 1904 | new AcpRegistryError({ | |
| 1905 | reason: "install_failed", | |
| 1906 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, | |
| 1907 | cause, | |
| 1908 | }), | |
| 1909 | ), | |
| 1910 | ); | |
| 1911 | if (!existed) return { agentId: safeAgentId, removed: false }; | |
| 1912 | ||
| 1913 | let removed = false; | |
| 1914 | yield* Effect.gen(function* () { | |
| 1915 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | |
| 1916 | const versionRoot = path.join(agentRoot, version); | |
| 1917 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | |
| 1918 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | |
| 1919 | yield* fileSystem.remove(path.join(versionRoot, entry), { | |
| 1920 | recursive: true, | |
| 1921 | force: true, | |
| 1922 | }); | |
| 1923 | removed = true; | |
| 1924 | } | |
| 1925 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | |
| 1926 | yield* fileSystem.remove(versionRoot, { recursive: true }); | |
| 1927 | } | |
| 1928 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | |
| 1929 | yield* fileSystem.remove(agentRoot, { recursive: true }); | |
| 1930 | }).pipe( | |
| 1931 | Effect.mapError( | |
| 1932 | (cause) => | |
| 1933 | new AcpRegistryError({ | |
| 1934 | reason: "install_failed", | |
| 1935 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | |
| 1936 | cause, | |
| 1937 | }), | |
| 1938 | ), | |
| 1939 | ); | |
| 1940 | return { agentId: safeAgentId, removed }; | |
| 1941 | }), | |
| 1942 | ), | |
| 1943 | ) | |
| 1944 | .pipe( | |
| 1945 | Effect.catchTags({ | |
| 1946 | ServerSettingsError: (cause) => | |
| 1947 | new AcpRegistryError({ | |
| 1948 | reason: "install_failed", | |
| 1949 | detail: | |
| 1950 | "Could not read provider settings while checking managed ACP binary references.", | |
| 1951 | cause, | |
| 1952 | }), | |
| 1953 | }), | |
| 1954 | ); | |
| 1955 | ||
| 1956 | return AcpRegistryCatalog.of({ | |
| 1957 | search, | |
| 1958 | prepare, | |
| 1959 | inspect, | |
| 1960 | resolve, | |
| 1961 | uninstallManagedBinary, | |
| 1962 | }); | |
| 1963 | }); |
packages/provider-acp-registry/src/server/adapter.ts created+314| ... | ... | @@ -0,0 +1,314 @@ |
| 1 | import { | |
| 2 | normalizeDevinSessionUpdate, | |
| 3 | normalizeDevinToolCall, | |
| 4 | extractDevinSubagentUpdate, | |
| 5 | } from "./devinAcp.ts"; | |
| 6 | import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; | |
| 7 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 9 | import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; | |
| 10 | import * as Crypto from "effect/Crypto"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Schema from "effect/Schema"; | |
| 17 | import type * as Scope from "effect/Scope"; | |
| 18 | import { ChildProcessSpawner } from "effect/process"; | |
| 19 | import * as EffectAcpErrors from "effect-acp/errors"; | |
| 20 | ||
| 21 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 22 | import { | |
| 23 | normalizeAcpRegistryCommands, | |
| 24 | normalizeAcpRegistryLiveConfiguration, | |
| 25 | normalizeAcpRegistryWebUrl, | |
| 26 | } from "./probe.ts"; | |
| 27 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 28 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 29 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 30 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; | |
| 31 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 32 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 33 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 34 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 35 | import { | |
| 36 | ProviderAdapterDriverCreateError, | |
| 37 | type ProviderAdapterDriver, | |
| 38 | type ProviderAdapterDriverCreateInput, | |
| 39 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 40 | import { | |
| 41 | AcpProviderCapabilitiesV2, | |
| 42 | makeAcpAdapterV2, | |
| 43 | type AcpAdapterV2ExtensionContext, | |
| 44 | type AcpAdapterV2Flavor, | |
| 45 | type AcpAdapterV2RuntimeInput, | |
| 46 | } from "@t3tools/provider-acp/server/adapter"; | |
| 47 | ||
| 48 | export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); | |
| 49 | export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); | |
| 50 | ||
| 51 | const DEFAULT_ACP_REGISTRY_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({}); | |
| 52 | const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); | |
| 53 | ||
| 54 | export interface AcpRegistryAdapterV2Options { | |
| 55 | readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"]; | |
| 56 | readonly settings: AcpRegistrySettings; | |
| 57 | readonly environment: NodeJS.ProcessEnv; | |
| 58 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | |
| 59 | readonly selfInvocation: SelfInvocation; | |
| 60 | readonly resolver: Pick<AcpRegistrySupport.AcpRegistryCatalog["Service"], "resolve">; | |
| 61 | readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; | |
| 62 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; | |
| 63 | readonly makeRuntime?: ( | |
| 64 | input: AcpAdapterV2RuntimeInput, | |
| 65 | ) => Effect.Effect< | |
| 66 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 67 | EffectAcpErrors.AcpError, | |
| 68 | Crypto.Crypto | Scope.Scope | |
| 69 | >; | |
| 70 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 71 | } | |
| 72 | ||
| 73 | // ─── Per-agent exceptions ──────────────────────────────────────────────────── | |
| 74 | // This adapter serves every ACP registry agent through the plain ACP spec. | |
| 75 | // Agent-specific behavior does not belong here: an agent that needs it gets a | |
| 76 | // dedicated driver (as Grok and Antigravity have). The few exceptions below | |
| 77 | // predate that rule and are small presentation hooks, not permission or tool | |
| 78 | // behavior. | |
| 79 | // | |
| 80 | // Agents changing this file: do NOT add another `agentId === "..."` branch, | |
| 81 | // agent table, or agent-specific hook without explicit approval from the | |
| 82 | // maintainer in the conversation. Propose a dedicated driver instead. | |
| 83 | ||
| 84 | // Mistral Vibe: its application error code for rate limits (not an ACP code). | |
| 85 | const MISTRAL_VIBE_RATE_LIMITED = -31001; | |
| 86 | ||
| 87 | const MistralVibeSessionRetrying = Schema.Struct({ | |
| 88 | sessionId: Schema.String, | |
| 89 | category: Schema.Literals(["rate_limited", "server_error", "timed_out", "connection", "unknown"]), | |
| 90 | detail: Schema.String, | |
| 91 | }); | |
| 92 | ||
| 93 | /** Mistral Vibe (v2.25.5) reports SDK backoff through this ACP extension. */ | |
| 94 | export function registerMistralVibeAcpExtensions(context: AcpAdapterV2ExtensionContext) { | |
| 95 | return context.runtime.handleExtNotification( | |
| 96 | "_session/retrying", | |
| 97 | MistralVibeSessionRetrying, | |
| 98 | (notice) => | |
| 99 | context.reportProviderRetry({ | |
| 100 | sessionId: notice.sessionId, | |
| 101 | failure: makeProviderFailure({ | |
| 102 | message: notice.detail, | |
| 103 | class: | |
| 104 | notice.category === "rate_limited" | |
| 105 | ? "usage_limit" | |
| 106 | : notice.category === "unknown" | |
| 107 | ? "provider_error" | |
| 108 | : "transport_error", | |
| 109 | retryable: true, | |
| 110 | }), | |
| 111 | }), | |
| 112 | ); | |
| 113 | } | |
| 114 | // ─── End per-agent exceptions (Devin's gates are marked in makeAcpRegistryAdapterV2) ─── | |
| 115 | ||
| 116 | export function acpRegistryPromptFailure(agentId: string, cause: unknown) { | |
| 117 | return makeProviderFailure({ | |
| 118 | cause, | |
| 119 | ...(isAcpRequestError(cause) | |
| 120 | ? { | |
| 121 | message: cause.errorMessage, | |
| 122 | code: String(cause.code), | |
| 123 | class: | |
| 124 | // Per-agent exception: see the note above registerMistralVibeAcpExtensions. | |
| 125 | agentId === "mistral-vibe" && cause.code === MISTRAL_VIBE_RATE_LIMITED | |
| 126 | ? ("usage_limit" as const) | |
| 127 | : ("provider_error" as const), | |
| 128 | } | |
| 129 | : { class: "provider_error" as const }), | |
| 130 | }); | |
| 131 | } | |
| 132 | ||
| 133 | function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { | |
| 134 | return ( | |
| 135 | input: AcpAdapterV2RuntimeInput, | |
| 136 | ): Effect.Effect< | |
| 137 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 138 | EffectAcpErrors.AcpError, | |
| 139 | Crypto.Crypto | Scope.Scope | |
| 140 | > => | |
| 141 | Effect.gen(function* () { | |
| 142 | const { processEnvironment, ...runtimeInput } = input; | |
| 143 | const resolved = yield* options.resolver | |
| 144 | .resolve(options.settings, input.cwd, options.environment) | |
| 145 | .pipe( | |
| 146 | Effect.mapError( | |
| 147 | (cause) => | |
| 148 | new EffectAcpErrors.AcpSpawnError({ | |
| 149 | command: options.settings.agentId || ACP_REGISTRY_PROVIDER, | |
| 150 | cause, | |
| 151 | }), | |
| 152 | ), | |
| 153 | ); | |
| 154 | const context = yield* Layer.build( | |
| 155 | AcpSessionRuntime.layer({ | |
| 156 | ...runtimeInput, | |
| 157 | spawn: | |
| 158 | processEnvironment === undefined | |
| 159 | ? resolved.spawn | |
| 160 | : { | |
| 161 | ...resolved.spawn, | |
| 162 | env: { ...resolved.spawn.env, ...processEnvironment }, | |
| 163 | }, | |
| 164 | ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), | |
| 165 | }).pipe( | |
| 166 | Layer.provide( | |
| 167 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner), | |
| 168 | ), | |
| 169 | ), | |
| 170 | ); | |
| 171 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 172 | Effect.provide(context), | |
| 173 | ); | |
| 174 | }); | |
| 175 | } | |
| 176 | ||
| 177 | export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(function* ( | |
| 178 | options: AcpRegistryAdapterV2Options, | |
| 179 | ) { | |
| 180 | const runtimeCoordinator = options.runtimeCoordinator; | |
| 181 | const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; | |
| 182 | const startupKey = | |
| 183 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; | |
| 184 | const isDevin = registryAgentId === "devin"; | |
| 185 | const flavor: AcpAdapterV2Flavor = { | |
| 186 | driver: ACP_REGISTRY_PROVIDER, | |
| 187 | capabilities: AcpProviderCapabilitiesV2, | |
| 188 | promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), | |
| 189 | // Per-agent exceptions (Mistral Vibe, Devin): see the note above | |
| 190 | // registerMistralVibeAcpExtensions before adding any more. | |
| 191 | ...(registryAgentId === "mistral-vibe" | |
| 192 | ? { registerExtensions: registerMistralVibeAcpExtensions } | |
| 193 | : {}), | |
| 194 | ...(isDevin | |
| 195 | ? { | |
| 196 | clientCapabilitiesMeta: { | |
| 197 | "cognition.ai/subagentSupport": true, | |
| 198 | "cognition.ai/messageGrouping": true, | |
| 199 | }, | |
| 200 | normalizeSessionUpdate: normalizeDevinSessionUpdate, | |
| 201 | normalizeToolCall: normalizeDevinToolCall, | |
| 202 | extractSubagentUpdate: extractDevinSubagentUpdate, | |
| 203 | } | |
| 204 | : {}), | |
| 205 | makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options), | |
| 206 | ...(runtimeCoordinator === undefined | |
| 207 | ? {} | |
| 208 | : { | |
| 209 | onAvailableCommandsUpdate: (commands) => | |
| 210 | runtimeCoordinator.publishAvailableCommands( | |
| 211 | options.instanceId, | |
| 212 | normalizeAcpRegistryCommands(commands), | |
| 213 | ), | |
| 214 | onSessionConfigurationUpdate: (configOptions, modeState) => | |
| 215 | runtimeCoordinator.publishLiveConfiguration( | |
| 216 | options.instanceId, | |
| 217 | normalizeAcpRegistryLiveConfiguration(configOptions, modeState), | |
| 218 | ), | |
| 219 | onUrlElicitation: ({ elicitationId, url, message }) => { | |
| 220 | const normalizedUrl = normalizeAcpRegistryWebUrl(url); | |
| 221 | if (normalizedUrl === undefined || elicitationId.trim().length === 0) { | |
| 222 | return Effect.succeed(false); | |
| 223 | } | |
| 224 | return runtimeCoordinator.requestUrlAuthentication(options.instanceId, { | |
| 225 | elicitationId: elicitationId.trim().slice(0, 256), | |
| 226 | url: normalizedUrl, | |
| 227 | message: message.trim().slice(0, 1_024), | |
| 228 | }); | |
| 229 | }, | |
| 230 | withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 231 | runtimeCoordinator.withForegroundStartup(startupKey, effect), | |
| 232 | }), | |
| 233 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | |
| 234 | }; | |
| 235 | return yield* makeAcpAdapterV2({ | |
| 236 | instanceId: options.instanceId, | |
| 237 | flavor, | |
| 238 | selfInvocation: options.selfInvocation, | |
| 239 | // Per-agent exception (see the note above registerMistralVibeAcpExtensions): | |
| 240 | // Devin runs commands through client terminals and has no ask mode over | |
| 241 | // ACP to fall back on. Every other registry agent runs its own. | |
| 242 | ...(isDevin | |
| 243 | ? { | |
| 244 | clientTerminals: { | |
| 245 | childProcessSpawner: options.childProcessSpawner, | |
| 246 | environment: options.environment, | |
| 247 | shellCommands: true, | |
| 248 | }, | |
| 249 | } | |
| 250 | : {}), | |
| 251 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), | |
| 252 | }); | |
| 253 | }); | |
| 254 | ||
| 255 | export type AcpRegistryAdapterV2DriverEnv = | |
| 256 | | ChildProcessSpawner.ChildProcessSpawner | |
| 257 | | Crypto.Crypto | |
| 258 | | FileSystem.FileSystem | |
| 259 | | AcpRegistrySupport.AcpRegistryCatalog | |
| 260 | | IdAllocator.IdAllocatorV2 | |
| 261 | | Path.Path | |
| 262 | | ProviderEventLoggers.ProviderEventLoggers | |
| 263 | | ProviderHost.ProviderHost; | |
| 264 | ||
| 265 | export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< | |
| 266 | AcpRegistrySettings, | |
| 267 | AcpRegistryAdapterV2DriverEnv | |
| 268 | > = { | |
| 269 | driverKind: ACP_REGISTRY_PROVIDER, | |
| 270 | configSchema: AcpRegistrySettings, | |
| 271 | defaultConfig: (): AcpRegistrySettings => DEFAULT_ACP_REGISTRY_SETTINGS, | |
| 272 | create: Effect.fn("AcpRegistryAdapterV2Driver.create")( | |
| 273 | function* (input: ProviderAdapterDriverCreateInput<AcpRegistrySettings>) { | |
| 274 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 275 | const selfInvocation = yield* resolveSelfInvocation(); | |
| 276 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 277 | const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 278 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 279 | const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 280 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 281 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 282 | ); | |
| 283 | return yield* makeAcpRegistryAdapterV2({ | |
| 284 | instanceId: input.instanceId, | |
| 285 | settings: { ...input.config, enabled: input.enabled }, | |
| 286 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 287 | childProcessSpawner, | |
| 288 | resolver, | |
| 289 | ...(Option.isSome(runtimeCoordinator) | |
| 290 | ? { runtimeCoordinator: runtimeCoordinator.value } | |
| 291 | : {}), | |
| 292 | selfInvocation, | |
| 293 | nativeLogging: (threadId) => | |
| 294 | makeNativeLogger({ | |
| 295 | nativeEventLogger: providerEventLoggers.native, | |
| 296 | provider: ACP_REGISTRY_PROVIDER, | |
| 297 | threadId, | |
| 298 | }), | |
| 299 | }); | |
| 300 | }, | |
| 301 | (effect, input) => | |
| 302 | effect.pipe( | |
| 303 | Effect.mapError( | |
| 304 | (cause) => | |
| 305 | new ProviderAdapterDriverCreateError({ | |
| 306 | driver: ACP_REGISTRY_PROVIDER, | |
| 307 | instanceId: input.instanceId, | |
| 308 | detail: "Failed to create ACP Registry adapter.", | |
| 309 | cause, | |
| 310 | }), | |
| 311 | ), | |
| 312 | ), | |
| 313 | ), | |
| 314 | }; |
packages/provider-acp-registry/src/server/auth.test.ts created+364| ... | ... | @@ -0,0 +1,364 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts"; | |
| 4 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 5 | import * as Deferred from "effect/Deferred"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import type * as AcpSchema from "effect-acp/compat"; | |
| 11 | import { AcpRequestError } from "effect-acp/errors"; | |
| 12 | ||
| 13 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 14 | import { makeAcpRegistryAuth } from "./auth.ts"; | |
| 15 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 16 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 17 | ||
| 18 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 19 | const instanceId = ProviderInstanceId.make("acp-auth-test"); | |
| 20 | const browserMethod = { id: "browser", name: "Browser", type: "agent" as const }; | |
| 21 | const terminalMethod = { | |
| 22 | id: "terminal", | |
| 23 | name: "Terminal", | |
| 24 | type: "terminal" as const, | |
| 25 | args: ["login"], | |
| 26 | env: { LOGIN: "yes", OVERRIDE: "method" }, | |
| 27 | }; | |
| 28 | const resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent = { | |
| 29 | agent: { | |
| 30 | id: "test-agent", | |
| 31 | name: "Test", | |
| 32 | version: "1.0.0", | |
| 33 | description: "Test", | |
| 34 | distribution: {}, | |
| 35 | }, | |
| 36 | distribution: "binary", | |
| 37 | spawn: { | |
| 38 | command: "/managed/agent", | |
| 39 | args: ["--acp"], | |
| 40 | env: { OVERRIDE: "spawn", AGENT_HOME: "/custom/home" }, | |
| 41 | }, | |
| 42 | }; | |
| 43 | const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = { | |
| 44 | inspect: () => | |
| 45 | Effect.succeed({ | |
| 46 | status: "ready", | |
| 47 | agentId: "test-agent", | |
| 48 | version: "1.0.0", | |
| 49 | distribution: "binary", | |
| 50 | }), | |
| 51 | resolve: () => Effect.succeed(resolved), | |
| 52 | search: () => Effect.die("unused"), | |
| 53 | prepare: () => Effect.die("unused"), | |
| 54 | uninstallManagedBinary: () => Effect.die("unused"), | |
| 55 | }; | |
| 56 | ||
| 57 | const makeHarness = ( | |
| 58 | method: AcpSchema.AuthMethod, | |
| 59 | failVerification = false, | |
| 60 | settings = decodeSettings({ agentId: "test-agent" }), | |
| 61 | providerInstanceId = instanceId, | |
| 62 | ) => | |
| 63 | Effect.gen(function* () { | |
| 64 | const verify = yield* Deferred.make<void>(); | |
| 65 | const changed: boolean[] = []; | |
| 66 | const closed: number[] = []; | |
| 67 | const authenticated: string[] = []; | |
| 68 | const started: number[] = []; | |
| 69 | let runtimes = 0; | |
| 70 | let version = "1.0.0"; | |
| 71 | let terminalSpawn: PtyAdapter.PtySpawnInput | undefined; | |
| 72 | let data: ((data: string) => void) | undefined; | |
| 73 | let exit: ((event: PtyAdapter.PtyExitEvent) => void) | undefined; | |
| 74 | let killed = false; | |
| 75 | let closedBeforeTerminal = false; | |
| 76 | const written: string[] = []; | |
| 77 | const sizes: number[][] = []; | |
| 78 | const controller = yield* makeAcpRegistryAuth({ | |
| 79 | instanceId: providerInstanceId, | |
| 80 | settings, | |
| 81 | cwd: "/workspace", | |
| 82 | environment: { PATH: "/tools", OVERRIDE: "base" }, | |
| 83 | onChanged: (value) => | |
| 84 | Effect.sync(() => { | |
| 85 | changed.push(value); | |
| 86 | }), | |
| 87 | makeRuntime: () => | |
| 88 | Effect.gen(function* () { | |
| 89 | const id = ++runtimes; | |
| 90 | yield* Effect.addFinalizer(() => | |
| 91 | Effect.sync(() => { | |
| 92 | closed.push(id); | |
| 93 | }), | |
| 94 | ); | |
| 95 | const initialized: AcpSchema.InitializeResponse = { | |
| 96 | protocolVersion: 2, | |
| 97 | agentCapabilities: {}, | |
| 98 | authMethods: [{ ...method, name: `${method.name} ${version}` }], | |
| 99 | }; | |
| 100 | let elicitation: | |
| 101 | | Parameters<AcpSessionRuntime.AcpSessionRuntime["Service"]["handleElicitation"]>[0] | |
| 102 | | undefined; | |
| 103 | return { | |
| 104 | initialize: () => Effect.succeed(initialized), | |
| 105 | handleElicitation: (handler) => | |
| 106 | Effect.sync(() => { | |
| 107 | elicitation = handler; | |
| 108 | }), | |
| 109 | authenticate: (methodId) => | |
| 110 | Effect.gen(function* () { | |
| 111 | authenticated.push(methodId); | |
| 112 | if (elicitation) | |
| 113 | yield* elicitation( | |
| 114 | { | |
| 115 | mode: "url", | |
| 116 | url: "https://example.com/login", | |
| 117 | elicitationId: "consent", | |
| 118 | requestId: "login", | |
| 119 | message: "Sign in", | |
| 120 | }, | |
| 121 | { requestId: "login", method: "elicitation/create" }, | |
| 122 | ); | |
| 123 | }), | |
| 124 | start: () => | |
| 125 | Effect.gen(function* () { | |
| 126 | started.push(id); | |
| 127 | yield* Deferred.await(verify); | |
| 128 | if (failVerification) | |
| 129 | return yield* new AcpRequestError({ | |
| 130 | method: "session/new", | |
| 131 | code: -32000, | |
| 132 | errorMessage: "Authentication required", | |
| 133 | }); | |
| 134 | return { | |
| 135 | sessionId: "verified", | |
| 136 | initializeResult: initialized, | |
| 137 | sessionSetupResult: { sessionId: "verified" }, | |
| 138 | modelConfigId: undefined, | |
| 139 | }; | |
| 140 | }), | |
| 141 | logout: Effect.succeed({}), | |
| 142 | }; | |
| 143 | }), | |
| 144 | }).pipe( | |
| 145 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, { | |
| 146 | ...catalog, | |
| 147 | inspect: () => | |
| 148 | Effect.sync(() => ({ | |
| 149 | status: "ready" as const, | |
| 150 | agentId: "test-agent", | |
| 151 | version, | |
| 152 | distribution: "binary" as const, | |
| 153 | })), | |
| 154 | }), | |
| 155 | Effect.provideService(PtyAdapter.PtyAdapter, { | |
| 156 | spawn: (input) => | |
| 157 | Effect.sync(() => { | |
| 158 | terminalSpawn = input; | |
| 159 | closedBeforeTerminal = closed.includes(runtimes); | |
| 160 | return { | |
| 161 | pid: 123, | |
| 162 | write: (input) => { | |
| 163 | written.push(input); | |
| 164 | }, | |
| 165 | resize: (cols, rows) => { | |
| 166 | sizes.push([cols, rows]); | |
| 167 | }, | |
| 168 | kill: () => { | |
| 169 | killed = true; | |
| 170 | }, | |
| 171 | onData: (callback) => { | |
| 172 | data = callback; | |
| 173 | return () => { | |
| 174 | data = undefined; | |
| 175 | }; | |
| 176 | }, | |
| 177 | onExit: (callback) => { | |
| 178 | exit = callback; | |
| 179 | return () => { | |
| 180 | exit = undefined; | |
| 181 | }; | |
| 182 | }, | |
| 183 | }; | |
| 184 | }), | |
| 185 | }), | |
| 186 | ); | |
| 187 | const state = (predicate: (state: ProviderAuthState) => boolean) => | |
| 188 | controller | |
| 189 | .subscribe("owner") | |
| 190 | .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 191 | const phase = (phase: ProviderAuthState["phase"]) => state((value) => value.phase === phase); | |
| 192 | yield* state((value) => (value.methods?.length ?? 0) > 0); | |
| 193 | return { | |
| 194 | controller, | |
| 195 | verify, | |
| 196 | changed, | |
| 197 | closed, | |
| 198 | authenticated, | |
| 199 | started, | |
| 200 | phase, | |
| 201 | state, | |
| 202 | terminalSpawn: () => terminalSpawn, | |
| 203 | closedBeforeTerminal: () => closedBeforeTerminal, | |
| 204 | data: (value: string) => data?.(value), | |
| 205 | exit: (exitCode: number) => exit?.({ exitCode, signal: null }), | |
| 206 | killed: () => killed, | |
| 207 | written, | |
| 208 | sizes, | |
| 209 | runtimes: () => runtimes, | |
| 210 | setVersion: (value: string) => { | |
| 211 | version = value; | |
| 212 | }, | |
| 213 | }; | |
| 214 | }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); | |
| 215 | ||
| 216 | it.effect("keeps local credentials separate from a matching registry agent ID", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const registry = yield* makeHarness(browserMethod); | |
| 219 | const local = yield* makeHarness( | |
| 220 | browserMethod, | |
| 221 | false, | |
| 222 | decodeSettings({ source: "local", commandPath: "/different/agent" }), | |
| 223 | ProviderInstanceId.make("test-agent"), | |
| 224 | ); | |
| 225 | assert.deepEqual(registry.controller.credentialBinding, { | |
| 226 | owner: "provider", | |
| 227 | key: "acp:test-agent", | |
| 228 | }); | |
| 229 | assert.deepEqual(local.controller.credentialBinding, { | |
| 230 | owner: "provider", | |
| 231 | key: "acp:local:test-agent", | |
| 232 | }); | |
| 233 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 234 | ); | |
| 235 | ||
| 236 | it.effect( | |
| 237 | "discovers methods without signing in, then waits for browser consent and session verification", | |
| 238 | () => | |
| 239 | Effect.gen(function* () { | |
| 240 | const h = yield* makeHarness(browserMethod); | |
| 241 | assert.deepEqual(h.authenticated, []); | |
| 242 | assert.deepEqual(h.started, []); | |
| 243 | assert.deepEqual(h.closed, [1]); | |
| 244 | const flow = yield* h.controller.start("owner", Effect.void, "browser"); | |
| 245 | const waiting = yield* h.phase("waiting"); | |
| 246 | assert.deepEqual(waiting.interaction, { | |
| 247 | type: "browser", | |
| 248 | id: "consent", | |
| 249 | url: "https://example.com/login", | |
| 250 | requiresConsent: true, | |
| 251 | }); | |
| 252 | yield* h.controller.respond!("owner", { | |
| 253 | instanceId, | |
| 254 | flowId: flow.flowId!, | |
| 255 | interactionId: "consent", | |
| 256 | response: { type: "browser", action: "accept" }, | |
| 257 | }); | |
| 258 | yield* h.phase("verifying"); | |
| 259 | assert.deepEqual(h.changed, []); | |
| 260 | yield* Deferred.succeed(h.verify, undefined); | |
| 261 | yield* h.phase("succeeded"); | |
| 262 | assert.deepEqual(h.authenticated, ["browser"]); | |
| 263 | assert.deepEqual(h.changed, [true]); | |
| 264 | assert.deepEqual(h.closed, [1, 2]); | |
| 265 | yield* h.controller.logout(Effect.void); | |
| 266 | assert.deepEqual(h.changed, [true, false]); | |
| 267 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 268 | ); | |
| 269 | ||
| 270 | it.effect( | |
| 271 | "runs terminal auth directly with the resolved environment and reconnects to verify", | |
| 272 | () => | |
| 273 | Effect.gen(function* () { | |
| 274 | const h = yield* makeHarness(terminalMethod); | |
| 275 | const flow = yield* h.controller.start("owner", Effect.void, "terminal"); | |
| 276 | yield* h.phase("waiting"); | |
| 277 | assert.isTrue(h.closedBeforeTerminal()); | |
| 278 | assert.deepEqual(h.terminalSpawn(), { | |
| 279 | shell: "/managed/agent", | |
| 280 | args: ["--acp", "login"], | |
| 281 | cwd: "/workspace", | |
| 282 | cols: 80, | |
| 283 | rows: 24, | |
| 284 | env: { PATH: "/tools", OVERRIDE: "method", AGENT_HOME: "/custom/home", LOGIN: "yes" }, | |
| 285 | }); | |
| 286 | h.data("Enter code: "); | |
| 287 | const output = yield* h.state( | |
| 288 | (value) => | |
| 289 | value.interaction?.type === "terminal" && value.interaction.output === "Enter code: ", | |
| 290 | ); | |
| 291 | assert.strictEqual( | |
| 292 | output.interaction?.type === "terminal" && output.interaction.outputOffset, | |
| 293 | 12, | |
| 294 | ); | |
| 295 | yield* h.controller.respond!("owner", { | |
| 296 | instanceId, | |
| 297 | flowId: flow.flowId!, | |
| 298 | interactionId: "terminal", | |
| 299 | response: { type: "terminal", data: "123\r", size: { cols: 100, rows: 30 } }, | |
| 300 | }); | |
| 301 | assert.deepEqual(h.written, ["123\r"]); | |
| 302 | assert.deepEqual(h.sizes, [[100, 30]]); | |
| 303 | h.exit(0); | |
| 304 | yield* h.phase("verifying"); | |
| 305 | assert.isTrue(h.killed()); | |
| 306 | assert.deepEqual(h.authenticated, []); | |
| 307 | yield* Deferred.succeed(h.verify, undefined); | |
| 308 | yield* h.phase("succeeded"); | |
| 309 | assert.deepEqual(h.started, [3]); | |
| 310 | assert.deepEqual(h.changed, [true]); | |
| 311 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 312 | ); | |
| 313 | ||
| 314 | it.effect("cancels the login PTY and rejects further terminal input", () => | |
| 315 | Effect.gen(function* () { | |
| 316 | const h = yield* makeHarness(terminalMethod); | |
| 317 | const flow = yield* h.controller.start("owner"); | |
| 318 | yield* h.phase("waiting"); | |
| 319 | yield* h.controller.cancel("owner", flow.flowId!); | |
| 320 | assert.isTrue(h.killed()); | |
| 321 | assert.deepEqual(h.started, []); | |
| 322 | assert.deepEqual(h.changed, []); | |
| 323 | assert.strictEqual( | |
| 324 | (yield* h.controller.respond!("owner", { | |
| 325 | instanceId, | |
| 326 | flowId: flow.flowId!, | |
| 327 | interactionId: "terminal", | |
| 328 | response: { type: "terminal", data: "late\r" }, | |
| 329 | }).pipe(Effect.result))._tag, | |
| 330 | "Failure", | |
| 331 | ); | |
| 332 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 333 | ); | |
| 334 | ||
| 335 | it.effect("does not report authentication when post-login verification fails", () => | |
| 336 | Effect.gen(function* () { | |
| 337 | const h = yield* makeHarness(terminalMethod, true); | |
| 338 | yield* h.controller.start("owner"); | |
| 339 | yield* h.phase("waiting"); | |
| 340 | h.exit(0); | |
| 341 | yield* h.phase("verifying"); | |
| 342 | yield* Deferred.succeed(h.verify, undefined); | |
| 343 | yield* h.phase("failed"); | |
| 344 | assert.deepEqual(h.changed, []); | |
| 345 | assert.isTrue(h.killed()); | |
| 346 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 347 | ); | |
| 348 | ||
| 349 | it.effect("refreshes advertised methods after a registry version change without logging in", () => | |
| 350 | Effect.gen(function* () { | |
| 351 | const h = yield* makeHarness(browserMethod); | |
| 352 | const count = h.runtimes(); | |
| 353 | yield* h.controller.refreshMethods!; | |
| 354 | assert.strictEqual(h.runtimes(), count); | |
| 355 | h.setVersion("2.0.0"); | |
| 356 | yield* h.controller.refreshMethods!; | |
| 357 | const state = yield* h.state((value) => value.methods?.[0]?.name === "Browser 2.0.0"); | |
| 358 | assert.strictEqual(state.phase, "idle"); | |
| 359 | assert.strictEqual(h.runtimes(), count + 1); | |
| 360 | assert.deepEqual(h.authenticated, []); | |
| 361 | assert.deepEqual(h.started, []); | |
| 362 | assert.deepEqual(h.closed, [1, 2]); | |
| 363 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 364 | ); |
packages/provider-acp-registry/src/server/auth.ts created+370| ... | ... | @@ -0,0 +1,370 @@ |
| 1 | import { | |
| 2 | ProviderSetupError, | |
| 3 | type ProviderAuthMethod, | |
| 4 | type ProviderInstanceId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import type { AcpRegistrySettings } from "../settings.ts"; | |
| 7 | import * as Crypto from "effect/Crypto"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import { ChildProcessSpawner } from "effect/process"; | |
| 16 | import type * as AcpSchema from "effect-acp/compat"; | |
| 17 | ||
| 18 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 19 | import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; | |
| 20 | import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./probe.ts"; | |
| 21 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 22 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 23 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 24 | ||
| 25 | type Runtime = Pick< | |
| 26 | AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 27 | "initialize" | "authenticate" | "start" | "handleElicitation" | "logout" | |
| 28 | >; | |
| 29 | ||
| 30 | /** ACP credentials stay in the agent's own store, using the same launch environment as chat. */ | |
| 31 | export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (options: { | |
| 32 | readonly instanceId: ProviderInstanceId; | |
| 33 | readonly settings: AcpRegistrySettings; | |
| 34 | readonly cwd: string; | |
| 35 | readonly environment: NodeJS.ProcessEnv; | |
| 36 | readonly onChanged: (authenticated: boolean) => Effect.Effect<void>; | |
| 37 | readonly makeRuntime?: ( | |
| 38 | spawn: AcpSessionRuntime.AcpSpawnInput, | |
| 39 | ) => Effect.Effect<Runtime, ProviderSetupError, Scope.Scope>; | |
| 40 | }) { | |
| 41 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 42 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 43 | const crypto = yield* Crypto.Crypto; | |
| 44 | const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); | |
| 45 | const coordinator = yield* Effect.serviceOption( | |
| 46 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 47 | ); | |
| 48 | const agentKey = | |
| 49 | options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; | |
| 50 | const failure = (operation: string, detail: string, cause?: unknown) => | |
| 51 | new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); | |
| 52 | const resolve = catalog | |
| 53 | .resolve(options.settings, options.cwd, options.environment) | |
| 54 | .pipe( | |
| 55 | Effect.mapError((cause) => | |
| 56 | failure("start", "Could not prepare the selected ACP agent.", cause), | |
| 57 | ), | |
| 58 | ); | |
| 59 | const makeRuntime = | |
| 60 | options.makeRuntime ?? | |
| 61 | ((spawn) => | |
| 62 | Effect.gen(function* () { | |
| 63 | const context = yield* Layer.build( | |
| 64 | AcpSessionRuntime.layer({ | |
| 65 | spawn, | |
| 66 | cwd: options.cwd, | |
| 67 | authenticateOnAuthRequired: false, | |
| 68 | clientCapabilities: { | |
| 69 | auth: { terminal: Option.isSome(pty) }, | |
| 70 | elicitation: { url: {} }, | |
| 71 | fs: { readTextFile: false, writeTextFile: false }, | |
| 72 | terminal: false, | |
| 73 | }, | |
| 74 | clientInfo: { name: "t3-code-provider-auth", version: "0.0.0" }, | |
| 75 | }).pipe( | |
| 76 | Layer.provide( | |
| 77 | Layer.mergeAll( | |
| 78 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 79 | Layer.succeed(Crypto.Crypto, crypto), | |
| 80 | ), | |
| 81 | ), | |
| 82 | ), | |
| 83 | ); | |
| 84 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 85 | Effect.provide(context), | |
| 86 | ); | |
| 87 | }).pipe( | |
| 88 | Effect.mapError((cause) => | |
| 89 | failure("start", "Could not start the selected ACP agent.", cause), | |
| 90 | ), | |
| 91 | )); | |
| 92 | ||
| 93 | let knownMethods: | |
| 94 | | { readonly version: string | null; readonly methods: ReadonlyArray<ProviderAuthMethod> } | |
| 95 | | undefined; | |
| 96 | const discoverMethods = Effect.scoped( | |
| 97 | Effect.gen(function* () { | |
| 98 | const inspected = yield* catalog | |
| 99 | .inspect(options.settings, options.environment) | |
| 100 | .pipe( | |
| 101 | Effect.mapError((cause) => | |
| 102 | failure("methods", "Could not inspect the selected ACP agent.", cause), | |
| 103 | ), | |
| 104 | ); | |
| 105 | if (inspected.status !== "ready") | |
| 106 | return yield* failure("methods", "Prepare this ACP agent before signing in."); | |
| 107 | // An unversioned command (a path override) can change in place, so only | |
| 108 | // registry versions reuse earlier discovery. | |
| 109 | if (inspected.version !== null && knownMethods?.version === inspected.version) | |
| 110 | return knownMethods.methods; | |
| 111 | const resolved = yield* resolve; | |
| 112 | const runtime = yield* makeRuntime(resolved.spawn); | |
| 113 | const initialized = yield* runtime | |
| 114 | .initialize() | |
| 115 | .pipe( | |
| 116 | Effect.mapError((cause) => | |
| 117 | failure("methods", "Could not discover this agent's sign-in methods.", cause), | |
| 118 | ), | |
| 119 | ); | |
| 120 | const advertised = normalizeAcpRegistryAuthMethods(initialized.authMethods) | |
| 121 | .filter( | |
| 122 | (method) => method.id.length <= 128 && (method.type !== "terminal" || Option.isSome(pty)), | |
| 123 | ) | |
| 124 | .slice(0, 32) | |
| 125 | .map((method) => ({ | |
| 126 | id: method.id, | |
| 127 | name: method.name, | |
| 128 | description: method.description, | |
| 129 | type: method.type === "env_var" ? ("credentials" as const) : method.type, | |
| 130 | })); | |
| 131 | knownMethods = { version: inspected.version, methods: advertised }; | |
| 132 | return advertised; | |
| 133 | }), | |
| 134 | ).pipe( | |
| 135 | Effect.timeoutOrElse({ | |
| 136 | duration: "30 seconds", | |
| 137 | orElse: () => | |
| 138 | Effect.fail(failure("methods", "The ACP agent did not advertise sign-in methods in time.")), | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | const methods = Option.isSome(coordinator) | |
| 143 | ? coordinator.value | |
| 144 | .runBackgroundProbe(agentKey, discoverMethods) | |
| 145 | .pipe( | |
| 146 | Effect.flatMap((result) => | |
| 147 | Option.isSome(result) | |
| 148 | ? Effect.succeed(result.value) | |
| 149 | : Effect.fail( | |
| 150 | failure( | |
| 151 | "methods", | |
| 152 | "Sign-in discovery was interrupted by an active provider session. Try again.", | |
| 153 | ), | |
| 154 | ), | |
| 155 | ), | |
| 156 | ) | |
| 157 | : discoverMethods; | |
| 158 | ||
| 159 | const runTerminal = Effect.fnUntraced(function* ( | |
| 160 | resolved: AcpRegistrySupport.ResolvedAcpRegistryAgent, | |
| 161 | method: Extract<AcpSchema.AuthMethod, { readonly type: "terminal" }>, | |
| 162 | context: ProviderAuthFlow.ProviderAuthFlowContext, | |
| 163 | ) { | |
| 164 | if (Option.isNone(pty)) | |
| 165 | return yield* failure( | |
| 166 | "start", | |
| 167 | "Interactive provider login is unavailable on this environment.", | |
| 168 | ); | |
| 169 | const exited = yield* Deferred.make<number>(); | |
| 170 | const output = yield* Queue.sliding<string>(64); | |
| 171 | const process = yield* pty.value | |
| 172 | .spawn({ | |
| 173 | shell: resolved.spawn.command, | |
| 174 | args: [...resolved.spawn.args, ...(method.args ?? [])], | |
| 175 | cwd: options.cwd, | |
| 176 | cols: 80, | |
| 177 | rows: 24, | |
| 178 | env: { ...options.environment, ...resolved.spawn.env, ...method.env }, | |
| 179 | }) | |
| 180 | .pipe( | |
| 181 | Effect.mapError((cause) => | |
| 182 | failure("start", "Could not open the provider sign-in terminal.", cause), | |
| 183 | ), | |
| 184 | ); | |
| 185 | const detachData = process.onData((data) => { | |
| 186 | Queue.offerUnsafe(output, data.slice(-16_384)); | |
| 187 | }); | |
| 188 | const detachExit = process.onExit(({ exitCode }) => { | |
| 189 | Deferred.doneUnsafe(exited, Effect.succeed(exitCode)); | |
| 190 | }); | |
| 191 | yield* Effect.addFinalizer(() => | |
| 192 | Effect.sync(() => { | |
| 193 | detachData(); | |
| 194 | detachExit(); | |
| 195 | try { | |
| 196 | process.kill(); | |
| 197 | } catch { | |
| 198 | /* The login process may already have exited. */ | |
| 199 | } | |
| 200 | }), | |
| 201 | ); | |
| 202 | let transcript = ""; | |
| 203 | let outputOffset = 0; | |
| 204 | const update = () => | |
| 205 | context.setInteraction( | |
| 206 | { type: "terminal", id: "terminal", output: transcript, outputOffset }, | |
| 207 | (response) => | |
| 208 | response.type === "terminal" | |
| 209 | ? Effect.try({ | |
| 210 | try: () => { | |
| 211 | if (response.size) process.resize(response.size.cols, response.size.rows); | |
| 212 | if (response.data) process.write(response.data); | |
| 213 | }, | |
| 214 | catch: () => | |
| 215 | failure("respond", "The provider sign-in terminal is no longer available."), | |
| 216 | }) | |
| 217 | : Effect.void, | |
| 218 | ); | |
| 219 | yield* update(); | |
| 220 | yield* Queue.take(output).pipe( | |
| 221 | Effect.flatMap((data) => { | |
| 222 | outputOffset += data.length; | |
| 223 | transcript = (transcript + data).slice(-16_384); | |
| 224 | return update(); | |
| 225 | }), | |
| 226 | Effect.forever, | |
| 227 | Effect.forkScoped, | |
| 228 | ); | |
| 229 | if ((yield* Deferred.await(exited)) !== 0) | |
| 230 | return yield* failure("start", "The provider login command did not finish successfully."); | |
| 231 | }); | |
| 232 | ||
| 233 | const authenticate = (methodId: string, context: ProviderAuthFlow.ProviderAuthFlowContext) => | |
| 234 | Effect.gen(function* () { | |
| 235 | if (!options.settings.enabled) | |
| 236 | return yield* failure("start", "Enable this provider before signing in."); | |
| 237 | const login = Effect.scoped( | |
| 238 | Effect.gen(function* () { | |
| 239 | const resolved = yield* resolve; | |
| 240 | const runtimeScope = yield* Scope.make(); | |
| 241 | yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); | |
| 242 | const runtime = yield* makeRuntime(resolved.spawn).pipe( | |
| 243 | Effect.provideService(Scope.Scope, runtimeScope), | |
| 244 | ); | |
| 245 | yield* runtime.handleElicitation((request) => | |
| 246 | Effect.gen(function* () { | |
| 247 | if ( | |
| 248 | request.mode !== "url" || | |
| 249 | !("url" in request) || | |
| 250 | !("elicitationId" in request) || | |
| 251 | typeof request.url !== "string" || | |
| 252 | typeof request.elicitationId !== "string" | |
| 253 | ) | |
| 254 | return { action: "decline" } as const; | |
| 255 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 256 | const id = request.elicitationId.trim(); | |
| 257 | if (!url || !id || id.length > 128) return { action: "decline" } as const; | |
| 258 | const consent = yield* Deferred.make<boolean>(); | |
| 259 | yield* context.setInteraction( | |
| 260 | { type: "browser", id, url, requiresConsent: true }, | |
| 261 | (response) => | |
| 262 | response.type === "browser" | |
| 263 | ? Deferred.succeed(consent, response.action === "accept").pipe(Effect.asVoid) | |
| 264 | : Effect.void, | |
| 265 | ); | |
| 266 | return (yield* Deferred.await(consent)) | |
| 267 | ? ({ action: "accept" } as const) | |
| 268 | : ({ action: "decline" } as const); | |
| 269 | }), | |
| 270 | ); | |
| 271 | const initialized = yield* runtime | |
| 272 | .initialize() | |
| 273 | .pipe( | |
| 274 | Effect.mapError((cause) => | |
| 275 | failure("start", "Could not initialize the selected ACP agent.", cause), | |
| 276 | ), | |
| 277 | ); | |
| 278 | const method = initialized.authMethods?.find((method) => method.id === methodId); | |
| 279 | if (!method) | |
| 280 | return yield* failure("start", "The agent no longer advertises this sign-in method."); | |
| 281 | if (method.type === "terminal") { | |
| 282 | yield* Scope.close(runtimeScope, Exit.void); | |
| 283 | yield* runTerminal(resolved, method, context).pipe(Effect.scoped); | |
| 284 | // Terminal login is a separate invocation. Reconnect after it exits so | |
| 285 | // the ACP process reads the credentials its login command persisted. | |
| 286 | const verifiedRuntime = yield* makeRuntime(resolved.spawn); | |
| 287 | yield* context.verifying; | |
| 288 | yield* verifiedRuntime | |
| 289 | .start() | |
| 290 | .pipe( | |
| 291 | Effect.mapError((cause) => | |
| 292 | failure( | |
| 293 | "verify", | |
| 294 | "The provider could not create a session after terminal sign-in.", | |
| 295 | cause, | |
| 296 | ), | |
| 297 | ), | |
| 298 | ); | |
| 299 | } else { | |
| 300 | if (method.type === undefined || method.type === "agent") { | |
| 301 | if (!runtime.authenticate) | |
| 302 | return yield* failure( | |
| 303 | "start", | |
| 304 | "This runtime does not support explicit ACP sign-in.", | |
| 305 | ); | |
| 306 | yield* runtime | |
| 307 | .authenticate(methodId) | |
| 308 | .pipe( | |
| 309 | Effect.mapError((cause) => | |
| 310 | failure("start", "The ACP agent could not complete sign-in.", cause), | |
| 311 | ), | |
| 312 | ); | |
| 313 | } | |
| 314 | // Legacy env_var methods use the secret-backed environment fields in | |
| 315 | // provider settings. Never send their ID to authenticate. | |
| 316 | yield* context.verifying; | |
| 317 | yield* runtime | |
| 318 | .start() | |
| 319 | .pipe( | |
| 320 | Effect.mapError((cause) => | |
| 321 | failure( | |
| 322 | "verify", | |
| 323 | "The provider could not create a session after sign-in.", | |
| 324 | cause, | |
| 325 | ), | |
| 326 | ), | |
| 327 | ); | |
| 328 | } | |
| 329 | }), | |
| 330 | ); | |
| 331 | yield* Option.isSome(coordinator) | |
| 332 | ? coordinator.value.withForegroundStartup(agentKey, login) | |
| 333 | : login; | |
| 334 | yield* options.onChanged(true); | |
| 335 | }); | |
| 336 | const logout = Effect.scoped( | |
| 337 | Effect.gen(function* () { | |
| 338 | const resolved = yield* resolve; | |
| 339 | const runtime = yield* makeRuntime(resolved.spawn); | |
| 340 | yield* runtime.logout.pipe( | |
| 341 | Effect.mapError((cause) => | |
| 342 | failure( | |
| 343 | "logout", | |
| 344 | "This ACP agent could not sign out. It may not advertise logout support.", | |
| 345 | cause, | |
| 346 | ), | |
| 347 | ), | |
| 348 | ); | |
| 349 | }), | |
| 350 | ).pipe( | |
| 351 | Effect.timeoutOrElse({ | |
| 352 | duration: "60 seconds", | |
| 353 | orElse: () => | |
| 354 | Effect.fail(failure("logout", "The ACP agent did not finish signing out in time.")), | |
| 355 | }), | |
| 356 | ); | |
| 357 | const signOut = ( | |
| 358 | Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout | |
| 359 | ).pipe(Effect.andThen(options.onChanged(false))); | |
| 360 | return yield* ProviderAuthFlow.make({ | |
| 361 | instanceId: options.instanceId, | |
| 362 | // ACP doesn't advertise its credential scope. Conservatively treat all | |
| 363 | // instances of the same agent on this environment as sharing credentials. | |
| 364 | credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, | |
| 365 | methods, | |
| 366 | ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), | |
| 367 | authenticate, | |
| 368 | logout: signOut, | |
| 369 | }); | |
| 370 | }); |
packages/provider-acp-registry/src/server/authenticationState.test.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | ||
| 9 | import { makeAcpRegistryAuthenticationState } from "./authenticationState.ts"; | |
| 10 | ||
| 11 | const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" }); | |
| 12 | ||
| 13 | it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => { | |
| 14 | it.effect("restores explicit sign-in across driver recreation and server restart", () => | |
| 15 | Effect.gen(function* () { | |
| 16 | const fs = yield* FileSystem.FileSystem; | |
| 17 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 18 | const input = { | |
| 19 | cacheDir, | |
| 20 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 21 | settings, | |
| 22 | environment: [], | |
| 23 | processEnvironment: { HOME: "/home/test" }, | |
| 24 | }; | |
| 25 | const first = yield* makeAcpRegistryAuthenticationState(input); | |
| 26 | assert.isFalse(yield* first.get); | |
| 27 | yield* first.set(true); | |
| 28 | const recreated = yield* makeAcpRegistryAuthenticationState({ | |
| 29 | ...input, | |
| 30 | settings: { ...settings, customModels: ["new-model"] }, | |
| 31 | }); | |
| 32 | assert.isTrue(yield* recreated.get); | |
| 33 | // Both logout and an explicit authentication failure revoke confirmation. | |
| 34 | yield* recreated.set(false); | |
| 35 | const restarted = yield* makeAcpRegistryAuthenticationState(input); | |
| 36 | assert.isFalse(yield* restarted.get); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect( | |
| 41 | "does not carry confirmation to a different agent, method, executable, or credential environment", | |
| 42 | () => | |
| 43 | Effect.gen(function* () { | |
| 44 | const fs = yield* FileSystem.FileSystem; | |
| 45 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 46 | const input = { | |
| 47 | cacheDir, | |
| 48 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 49 | settings, | |
| 50 | environment: [{ name: "DEVIN_TOKEN", value: "test-only-token", sensitive: true }], | |
| 51 | processEnvironment: { HOME: "/home/test" }, | |
| 52 | }; | |
| 53 | for (const change of [ | |
| 54 | { settings: { ...settings, agentId: "other-agent" } }, | |
| 55 | { settings: { ...settings, authMethodId: "enterprise" } }, | |
| 56 | { settings: { ...settings, commandPath: "/other/devin" } }, | |
| 57 | { environment: [{ name: "DEVIN_TOKEN", value: "different-token", sensitive: true }] }, | |
| 58 | { processEnvironment: { HOME: "/home/other" } }, | |
| 59 | ]) { | |
| 60 | const signedIn = yield* makeAcpRegistryAuthenticationState(input); | |
| 61 | yield* signedIn.set(true); | |
| 62 | const changed = yield* makeAcpRegistryAuthenticationState({ ...input, ...change }); | |
| 63 | assert.isFalse(yield* changed.get); | |
| 64 | const restoredConfig = yield* makeAcpRegistryAuthenticationState(input); | |
| 65 | assert.isFalse(yield* restoredConfig.get); | |
| 66 | } | |
| 67 | for (const name of yield* fs.readDirectory(cacheDir)) { | |
| 68 | const contents = yield* fs.readFileString(`${cacheDir}/${name}`); | |
| 69 | assert.isFalse(contents.includes("test-only-token")); | |
| 70 | assert.isFalse(contents.includes("different-token")); | |
| 71 | } | |
| 72 | }), | |
| 73 | ); | |
| 74 | ||
| 75 | it.effect("ignores damaged confirmation and keeps another instance independent", () => | |
| 76 | Effect.gen(function* () { | |
| 77 | const fs = yield* FileSystem.FileSystem; | |
| 78 | const cacheDir = yield* fs.makeTempDirectoryScoped(); | |
| 79 | const input = { | |
| 80 | cacheDir, | |
| 81 | instanceId: ProviderInstanceId.make("acp_devin"), | |
| 82 | settings, | |
| 83 | environment: [], | |
| 84 | processEnvironment: {}, | |
| 85 | }; | |
| 86 | const first = yield* makeAcpRegistryAuthenticationState(input); | |
| 87 | yield* first.set(true); | |
| 88 | const other = yield* makeAcpRegistryAuthenticationState({ | |
| 89 | ...input, | |
| 90 | instanceId: ProviderInstanceId.make("acp_other"), | |
| 91 | }); | |
| 92 | assert.isFalse(yield* other.get); | |
| 93 | for (const name of yield* fs.readDirectory(cacheDir)) { | |
| 94 | yield* fs.writeFileString(`${cacheDir}/${name}`, "invalid"); | |
| 95 | } | |
| 96 | const restored = yield* makeAcpRegistryAuthenticationState(input); | |
| 97 | assert.isFalse(yield* restored.get); | |
| 98 | }), | |
| 99 | ); | |
| 100 | }); |
packages/provider-acp-registry/src/server/authenticationState.ts created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | import type { ProviderInstanceEnvironment, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | import type { AcpRegistrySettings } from "../settings.ts"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Hex from "effect/encoding/Hex"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Semaphore from "effect/Semaphore"; | |
| 11 | ||
| 12 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 13 | ||
| 14 | const decodeState = Schema.decodeUnknownEffect( | |
| 15 | Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), | |
| 16 | ); | |
| 17 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 18 | const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) { | |
| 19 | const crypto = yield* Crypto.Crypto; | |
| 20 | const json = yield* encodeJson(value).pipe(Effect.orDie); | |
| 21 | const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie); | |
| 22 | return Hex.encode(digest); | |
| 23 | }); | |
| 24 | ||
| 25 | /** Remember explicit sign-in success, never discovery success or the agent's credentials. */ | |
| 26 | export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")( | |
| 27 | function* (input: { | |
| 28 | readonly cacheDir: string; | |
| 29 | readonly instanceId: ProviderInstanceId; | |
| 30 | readonly settings: AcpRegistrySettings; | |
| 31 | readonly environment: ProviderInstanceEnvironment; | |
| 32 | readonly processEnvironment: NodeJS.ProcessEnv; | |
| 33 | }) { | |
| 34 | const fs = yield* FileSystem.FileSystem; | |
| 35 | const path = yield* Path.Path; | |
| 36 | const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`); | |
| 37 | // Cosmetic settings and model discovery can rebuild the driver without | |
| 38 | // changing the account. Credential overrides and profile paths cannot. | |
| 39 | const binding = yield* hash({ | |
| 40 | ...(input.settings.source === "local" | |
| 41 | ? { source: "local", commandArgs: input.settings.commandArgs } | |
| 42 | : {}), | |
| 43 | agentId: input.settings.agentId, | |
| 44 | commandPath: input.settings.commandPath, | |
| 45 | distribution: input.settings.distribution, | |
| 46 | authMethodId: input.settings.authMethodId, | |
| 47 | environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)), | |
| 48 | profiles: [ | |
| 49 | "HOME", | |
| 50 | "XDG_CONFIG_HOME", | |
| 51 | "XDG_DATA_HOME", | |
| 52 | "XDG_STATE_HOME", | |
| 53 | "APPDATA", | |
| 54 | "LOCALAPPDATA", | |
| 55 | ].map((name) => input.processEnvironment[name] ?? null), | |
| 56 | }); | |
| 57 | const saved = yield* fs.readFileString(filePath).pipe( | |
| 58 | Effect.flatMap(decodeState), | |
| 59 | Effect.orElseSucceed(() => undefined), | |
| 60 | ); | |
| 61 | const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated); | |
| 62 | const lock = yield* Semaphore.make(1); | |
| 63 | const persist = (authenticated: boolean) => | |
| 64 | writeFileStringAtomically({ | |
| 65 | filePath, | |
| 66 | contents: JSON.stringify({ binding, authenticated }), | |
| 67 | }).pipe( | |
| 68 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 69 | Effect.provideService(Path.Path, path), | |
| 70 | Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")), | |
| 71 | Effect.ignore, | |
| 72 | ); | |
| 73 | if (saved && saved.binding !== binding) yield* persist(false); | |
| 74 | return { | |
| 75 | get: Ref.get(confirmed), | |
| 76 | set: (authenticated: boolean) => | |
| 77 | lock.withPermit( | |
| 78 | Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))), | |
| 79 | ), | |
| 80 | }; | |
| 81 | }, | |
| 82 | ); |
packages/provider-acp-registry/src/server/devinAcp.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import type * as AcpSchema from "effect-acp/compat"; | |
| 2 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; | |
| 3 | import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter"; | |
| 4 | ||
| 5 | function record(value: unknown): Record<string, unknown> | undefined { | |
| 6 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 7 | ? (value as Record<string, unknown>) | |
| 8 | : undefined; | |
| 9 | } | |
| 10 | ||
| 11 | /** Devin's typed extension metadata, captured from its native ACP stream. */ | |
| 12 | export function normalizeDevinSessionUpdate( | |
| 13 | notification: AcpSchema.SessionNotification, | |
| 14 | ): AcpSchema.SessionNotification { | |
| 15 | const update = notification.update; | |
| 16 | const meta = record("_meta" in update ? update._meta : undefined); | |
| 17 | const parentAgentId = record(meta?.["cognition.ai/subagent_context"])?.parentAgentId; | |
| 18 | const routed = | |
| 19 | typeof parentAgentId === "string" && parentAgentId !== "root" | |
| 20 | ? { ...notification, sessionId: parentAgentId } | |
| 21 | : notification; | |
| 22 | const messageId = meta?.["cognition.ai/streamingMessageId"]; | |
| 23 | if ( | |
| 24 | (update.sessionUpdate === "agent_message_chunk" || | |
| 25 | update.sessionUpdate === "agent_thought_chunk") && | |
| 26 | typeof messageId === "string" | |
| 27 | ) { | |
| 28 | return { ...routed, update: { ...update, messageId } }; | |
| 29 | } | |
| 30 | return routed; | |
| 31 | } | |
| 32 | ||
| 33 | export function normalizeDevinToolCall(tool: AcpToolCallState): AcpToolCallState { | |
| 34 | const name = record(tool.data.meta)?.["cognition.ai/inferenceToolName"]; | |
| 35 | return typeof name === "string" && (!tool.title || tool.title === "Tool") | |
| 36 | ? { ...tool, title: name } | |
| 37 | : tool; | |
| 38 | } | |
| 39 | ||
| 40 | export function extractDevinSubagentUpdate( | |
| 41 | tool: AcpToolCallState, | |
| 42 | ): AcpAdapterV2SubagentUpdate | undefined { | |
| 43 | const meta = record(tool.data.meta); | |
| 44 | const completed = record(meta?.["cognition.ai/subagent_completed"]); | |
| 45 | const started = record(meta?.["cognition.ai/subagent_started"]); | |
| 46 | const data = completed ?? started; | |
| 47 | if (typeof data?.agentId !== "string") return undefined; | |
| 48 | return { | |
| 49 | nativeTaskId: data.agentId, | |
| 50 | childSessionId: data.agentId, | |
| 51 | prompt: typeof started?.task === "string" ? started.task : "", | |
| 52 | title: typeof started?.title === "string" ? started.title : null, | |
| 53 | model: typeof started?.model === "string" ? started.model : null, | |
| 54 | status: completed ? (completed.success === false ? "failed" : "completed") : "running", | |
| 55 | result: typeof completed?.summary === "string" ? completed.summary : null, | |
| 56 | }; | |
| 57 | } |
packages/provider-acp-registry/src/server/driver.test.ts created+456| ... | ... | @@ -0,0 +1,456 @@ |
| 1 | import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 3 | import { describe, expect, it } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | ||
| 8 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 9 | import { | |
| 10 | acpRegistrySnapshotReadiness, | |
| 11 | applyAcpRegistryAvailableCommands, | |
| 12 | applyAcpRegistryLiveConfiguration, | |
| 13 | buildCheckedAcpRegistrySnapshot, | |
| 14 | checkAcpRegistryProviderReadiness, | |
| 15 | checkAcpRegistryProviderStatus, | |
| 16 | } from "./driver.ts"; | |
| 17 | ||
| 18 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 19 | const identity = { | |
| 20 | instanceId: ProviderInstanceId.make("acpRegistry_test"), | |
| 21 | displayName: "Test ACP", | |
| 22 | accentColor: undefined, | |
| 23 | continuationKey: "acpRegistry:instance:acpRegistry_test", | |
| 24 | }; | |
| 25 | const noSessionManagement = { | |
| 26 | canList: false, | |
| 27 | canLoad: false, | |
| 28 | canResume: false, | |
| 29 | canLogout: false, | |
| 30 | canDelete: false, | |
| 31 | canConfigureProviders: false, | |
| 32 | } as const; | |
| 33 | ||
| 34 | function catalogWithInspection( | |
| 35 | inspection: AcpRegistrySupport.AcpRegistryInspection, | |
| 36 | ): AcpRegistrySupport.AcpRegistryCatalog["Service"] { | |
| 37 | return { | |
| 38 | search: () => Effect.die("unused search"), | |
| 39 | prepare: () => Effect.die("unused prepare"), | |
| 40 | inspect: () => Effect.succeed(inspection), | |
| 41 | resolve: () => Effect.die("unused resolve"), | |
| 42 | uninstallManagedBinary: () => Effect.die("unused uninstall"), | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | describe("acpRegistrySnapshotReadiness", () => { | |
| 47 | it("treats a live empty command advertisement as an authoritative replacement", () => { | |
| 48 | const provider = buildCheckedAcpRegistrySnapshot({ | |
| 49 | ...identity, | |
| 50 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 51 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 52 | inspection: { | |
| 53 | status: "ready", | |
| 54 | agentId: "test-agent", | |
| 55 | version: "1.0.0", | |
| 56 | distribution: "npx", | |
| 57 | documentationUrl: "https://example.test/agent/setup", | |
| 58 | }, | |
| 59 | probe: { | |
| 60 | probe: { | |
| 61 | instanceId: identity.instanceId, | |
| 62 | ready: true, | |
| 63 | icon: null, | |
| 64 | authMethods: [], | |
| 65 | models: [], | |
| 66 | currentModelId: null, | |
| 67 | configOptions: [], | |
| 68 | sessionManagement: noSessionManagement, | |
| 69 | }, | |
| 70 | slashCommands: [{ name: "stale" }], | |
| 71 | skills: [{ name: "stale-skill", path: "stale", enabled: true }], | |
| 72 | }, | |
| 73 | }); | |
| 74 | ||
| 75 | const replaced = applyAcpRegistryAvailableCommands( | |
| 76 | provider, | |
| 77 | Option.some({ slashCommands: [], skills: [] }), | |
| 78 | ); | |
| 79 | expect(replaced.slashCommands).toEqual([]); | |
| 80 | expect(replaced.skills).toEqual([]); | |
| 81 | expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([ | |
| 82 | { name: "stale" }, | |
| 83 | ]); | |
| 84 | expect(provider.iconUrl).toBe( | |
| 85 | "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg", | |
| 86 | ); | |
| 87 | expect(provider.setup).toMatchObject({ | |
| 88 | canAuthenticate: false, | |
| 89 | documentationUrl: "https://example.test/agent/setup", | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => { | |
| 94 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 95 | ...identity, | |
| 96 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 97 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 98 | inspection: { | |
| 99 | status: "ready", | |
| 100 | agentId: "test-agent", | |
| 101 | version: "1.0.0", | |
| 102 | distribution: "binary", | |
| 103 | }, | |
| 104 | probe: { | |
| 105 | probe: { | |
| 106 | instanceId: identity.instanceId, | |
| 107 | ready: true, | |
| 108 | icon: null, | |
| 109 | authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }], | |
| 110 | models: [], | |
| 111 | currentModelId: null, | |
| 112 | configOptions: [], | |
| 113 | sessionManagement: noSessionManagement, | |
| 114 | }, | |
| 115 | slashCommands: [], | |
| 116 | skills: [], | |
| 117 | }, | |
| 118 | }); | |
| 119 | expect(snapshot.auth.status).toBe("unknown"); | |
| 120 | expect(snapshot.setup?.canAuthenticate).toBe(true); | |
| 121 | expect( | |
| 122 | applyAcpRegistryLiveConfiguration( | |
| 123 | snapshot, | |
| 124 | { models: [], currentModelId: null, configOptions: [] }, | |
| 125 | [], | |
| 126 | ).auth.status, | |
| 127 | ).toBe("unknown"); | |
| 128 | }); | |
| 129 | ||
| 130 | it("overlays live configuration without dropping probe-owned session capabilities", () => { | |
| 131 | const provider = buildCheckedAcpRegistrySnapshot({ | |
| 132 | ...identity, | |
| 133 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 134 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 135 | inspection: { | |
| 136 | status: "ready", | |
| 137 | agentId: "test-agent", | |
| 138 | version: "1.0.0", | |
| 139 | distribution: "npx", | |
| 140 | }, | |
| 141 | probe: { | |
| 142 | probe: { | |
| 143 | instanceId: identity.instanceId, | |
| 144 | ready: true, | |
| 145 | icon: null, | |
| 146 | authMethods: [], | |
| 147 | models: [{ id: "probe-model", name: "Probe model", description: null }], | |
| 148 | currentModelId: "probe-model", | |
| 149 | configOptions: [], | |
| 150 | sessionManagement: { | |
| 151 | canList: true, | |
| 152 | canLoad: true, | |
| 153 | canResume: true, | |
| 154 | canLogout: true, | |
| 155 | canDelete: true, | |
| 156 | canConfigureProviders: true, | |
| 157 | }, | |
| 158 | }, | |
| 159 | slashCommands: [], | |
| 160 | skills: [], | |
| 161 | }, | |
| 162 | }); | |
| 163 | ||
| 164 | expect( | |
| 165 | applyAcpRegistryLiveConfiguration( | |
| 166 | provider, | |
| 167 | { | |
| 168 | models: [{ id: "live-model", name: "Live model", description: null }], | |
| 169 | currentModelId: "live-model", | |
| 170 | configOptions: [], | |
| 171 | }, | |
| 172 | [], | |
| 173 | ), | |
| 174 | ).toMatchObject({ | |
| 175 | auth: { status: "unknown", canLogout: true }, | |
| 176 | nativeSessions: { canList: true, canLoad: true, canResume: true }, | |
| 177 | models: [{ slug: "live-model", isDefault: true }], | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | it("maps registry inspection status to provider readiness", () => { | |
| 182 | expect( | |
| 183 | acpRegistrySnapshotReadiness({ | |
| 184 | status: "ready", | |
| 185 | agentId: "gemini-cli", | |
| 186 | version: "1.2.3", | |
| 187 | distribution: "npx", | |
| 188 | }), | |
| 189 | ).toEqual({ installed: true, version: "1.2.3", status: "ready" }); | |
| 190 | ||
| 191 | expect( | |
| 192 | acpRegistrySnapshotReadiness({ | |
| 193 | status: "missing_runner", | |
| 194 | agentId: "gemini-cli", | |
| 195 | version: "1.2.3", | |
| 196 | distribution: "npx", | |
| 197 | runner: "npx", | |
| 198 | }), | |
| 199 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); | |
| 200 | ||
| 201 | expect( | |
| 202 | acpRegistrySnapshotReadiness({ | |
| 203 | status: "missing_runner", | |
| 204 | version: null, | |
| 205 | distribution: "local", | |
| 206 | }), | |
| 207 | ).toEqual({ | |
| 208 | installed: false, | |
| 209 | version: null, | |
| 210 | status: "error", | |
| 211 | message: "Local ACP executable is not available on this environment's PATH.", | |
| 212 | }); | |
| 213 | ||
| 214 | expect( | |
| 215 | acpRegistrySnapshotReadiness({ | |
| 216 | status: "unprepared", | |
| 217 | agentId: "zed-agent", | |
| 218 | version: "2.0.0", | |
| 219 | distribution: "binary", | |
| 220 | }), | |
| 221 | ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" }); | |
| 222 | ||
| 223 | expect( | |
| 224 | acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }), | |
| 225 | ).toEqual({ | |
| 226 | installed: false, | |
| 227 | version: null, | |
| 228 | status: "error", | |
| 229 | message: "Registry unavailable.", | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 233 | it("projects discovery without claiming authentication when no login methods are advertised", () => { | |
| 234 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 235 | ...identity, | |
| 236 | settings: decodeSettings({ | |
| 237 | agentId: "test-agent", | |
| 238 | customModels: [" custom-model ", "gpt-discovered"], | |
| 239 | }), | |
| 240 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 241 | inspection: { | |
| 242 | status: "ready", | |
| 243 | agentId: "test-agent", | |
| 244 | version: "1.0.0", | |
| 245 | distribution: "npx", | |
| 246 | }, | |
| 247 | probe: { | |
| 248 | probe: { | |
| 249 | instanceId: identity.instanceId, | |
| 250 | ready: true, | |
| 251 | icon: null, | |
| 252 | authMethods: [], | |
| 253 | models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }], | |
| 254 | currentModelId: "gpt-discovered", | |
| 255 | configOptions: [], | |
| 256 | sessionManagement: noSessionManagement, | |
| 257 | }, | |
| 258 | slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }], | |
| 259 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], | |
| 260 | }, | |
| 261 | }); | |
| 262 | ||
| 263 | expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false }); | |
| 264 | expect(snapshot.supportsTextGeneration).toBe(false); | |
| 265 | expect( | |
| 266 | snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({ | |
| 267 | slug, | |
| 268 | name, | |
| 269 | isCustom, | |
| 270 | isDefault, | |
| 271 | })), | |
| 272 | ).toEqual([ | |
| 273 | { | |
| 274 | slug: "gpt-discovered", | |
| 275 | name: "GPT Discovered", | |
| 276 | isCustom: false, | |
| 277 | isDefault: true, | |
| 278 | }, | |
| 279 | { | |
| 280 | slug: "custom-model", | |
| 281 | name: "custom-model", | |
| 282 | isCustom: true, | |
| 283 | isDefault: undefined, | |
| 284 | }, | |
| 285 | ]); | |
| 286 | expect(snapshot.slashCommands).toEqual([ | |
| 287 | { name: "plan", description: "Create a plan", input: { hint: "topic" } }, | |
| 288 | ]); | |
| 289 | expect(snapshot.skills).toEqual([ | |
| 290 | { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }, | |
| 291 | ]); | |
| 292 | }); | |
| 293 | ||
| 294 | it("keeps a default model only when the agent advertises none", () => { | |
| 295 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 296 | ...identity, | |
| 297 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 298 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 299 | inspection: { | |
| 300 | status: "ready", | |
| 301 | agentId: "test-agent", | |
| 302 | version: "1.0.0", | |
| 303 | distribution: "uvx", | |
| 304 | }, | |
| 305 | probe: { | |
| 306 | probe: { | |
| 307 | instanceId: identity.instanceId, | |
| 308 | ready: true, | |
| 309 | icon: null, | |
| 310 | authMethods: [], | |
| 311 | models: [], | |
| 312 | currentModelId: null, | |
| 313 | configOptions: [], | |
| 314 | sessionManagement: noSessionManagement, | |
| 315 | }, | |
| 316 | slashCommands: [], | |
| 317 | skills: [], | |
| 318 | }, | |
| 319 | }); | |
| 320 | ||
| 321 | expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); | |
| 322 | expect(snapshot.models[0]?.isDefault).toBe(true); | |
| 323 | }); | |
| 324 | ||
| 325 | it.each(["registry", "local"] as const)( | |
| 326 | "reports failed authentication after successful %s inspection", | |
| 327 | (source) => { | |
| 328 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 329 | ...identity, | |
| 330 | settings: decodeSettings({ | |
| 331 | source, | |
| 332 | ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), | |
| 333 | authMethodId: "grok-login", | |
| 334 | }), | |
| 335 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 336 | inspection: { | |
| 337 | status: "ready", | |
| 338 | agentId: "test-agent", | |
| 339 | version: source === "local" ? null : "1.0.0", | |
| 340 | distribution: source === "local" ? "local" : "binary", | |
| 341 | }, | |
| 342 | probeError: new AcpRegistryOperationError({ | |
| 343 | reason: "authentication_failed", | |
| 344 | message: "Login required.", | |
| 345 | authMethods: [ | |
| 346 | { | |
| 347 | id: "api-key", | |
| 348 | name: "API key", | |
| 349 | description: null, | |
| 350 | type: "env_var", | |
| 351 | }, | |
| 352 | { | |
| 353 | id: "grok-login", | |
| 354 | name: "Log in with Grok", | |
| 355 | description: null, | |
| 356 | type: "agent", | |
| 357 | }, | |
| 358 | ], | |
| 359 | }), | |
| 360 | }); | |
| 361 | ||
| 362 | expect(snapshot).toMatchObject({ | |
| 363 | installed: true, | |
| 364 | version: source === "local" ? null : "1.0.0", | |
| 365 | status: "warning", | |
| 366 | setup: { canAuthenticate: true }, | |
| 367 | auth: { | |
| 368 | status: "unauthenticated", | |
| 369 | type: "agent", | |
| 370 | label: "Log in with Grok", | |
| 371 | }, | |
| 372 | message: 'Sign in in provider settings using "Log in with Grok".', | |
| 373 | }); | |
| 374 | }, | |
| 375 | ); | |
| 376 | ||
| 377 | it.effect("runs the disposable probe only after local inspection is ready", () => | |
| 378 | Effect.gen(function* () { | |
| 379 | const settings = decodeSettings({ agentId: "test-agent" }); | |
| 380 | const environment = { PATH: "/provider/bin" }; | |
| 381 | let receivedEnvironment: NodeJS.ProcessEnv | undefined; | |
| 382 | const snapshot = yield* checkAcpRegistryProviderStatus( | |
| 383 | { | |
| 384 | ...identity, | |
| 385 | settings, | |
| 386 | cwd: "/workspace", | |
| 387 | environment, | |
| 388 | }, | |
| 389 | (input) => | |
| 390 | Effect.sync(() => { | |
| 391 | receivedEnvironment = input.environment; | |
| 392 | return { | |
| 393 | probe: { | |
| 394 | instanceId: identity.instanceId, | |
| 395 | ready: true as const, | |
| 396 | icon: null, | |
| 397 | authMethods: [], | |
| 398 | models: [{ id: "agent-model", name: "Agent Model", description: null }], | |
| 399 | currentModelId: "agent-model", | |
| 400 | configOptions: [], | |
| 401 | sessionManagement: noSessionManagement, | |
| 402 | }, | |
| 403 | slashCommands: [{ name: "review" }], | |
| 404 | skills: [], | |
| 405 | }; | |
| 406 | }), | |
| 407 | ).pipe( | |
| 408 | Effect.provideService( | |
| 409 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 410 | catalogWithInspection({ | |
| 411 | status: "ready", | |
| 412 | agentId: "test-agent", | |
| 413 | version: "1.0.0", | |
| 414 | distribution: "npx", | |
| 415 | }), | |
| 416 | ), | |
| 417 | ); | |
| 418 | ||
| 419 | expect(receivedEnvironment).toBe(environment); | |
| 420 | expect(snapshot).toMatchObject({ | |
| 421 | auth: { status: "unknown" }, | |
| 422 | models: [{ slug: "agent-model" }], | |
| 423 | slashCommands: [{ name: "review" }], | |
| 424 | skills: [], | |
| 425 | }); | |
| 426 | }), | |
| 427 | ); | |
| 428 | ||
| 429 | it.effect("publishes concrete local readiness before background ACP discovery", () => | |
| 430 | Effect.gen(function* () { | |
| 431 | const snapshot = yield* checkAcpRegistryProviderReadiness({ | |
| 432 | ...identity, | |
| 433 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 434 | environment: { PATH: "/provider/bin" }, | |
| 435 | }).pipe( | |
| 436 | Effect.provideService( | |
| 437 | AcpRegistrySupport.AcpRegistryCatalog, | |
| 438 | catalogWithInspection({ | |
| 439 | status: "ready", | |
| 440 | agentId: "test-agent", | |
| 441 | version: "1.0.0", | |
| 442 | distribution: "npx", | |
| 443 | }), | |
| 444 | ), | |
| 445 | ); | |
| 446 | ||
| 447 | expect(snapshot).toMatchObject({ | |
| 448 | installed: true, | |
| 449 | status: "ready", | |
| 450 | version: "1.0.0", | |
| 451 | auth: { status: "unknown" }, | |
| 452 | message: "Checking ACP authentication, models, and commands in the background...", | |
| 453 | }); | |
| 454 | }), | |
| 455 | ); | |
| 456 | }); |
packages/provider-acp-registry/src/server/driver.ts created+903| ... | ... | @@ -0,0 +1,903 @@ |
| 1 | import { | |
| 2 | officialAcpRegistryIconUrlForAgentId, | |
| 3 | ProviderDriverKind, | |
| 4 | resolveOfficialAcpRegistryIconUrl, | |
| 5 | TextGenerationError, | |
| 6 | type AcpRegistryOperationError, | |
| 7 | type ServerProvider, | |
| 8 | type ServerProviderModel, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 11 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 12 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 13 | import * as Clock from "effect/Clock"; | |
| 14 | import * as Crypto from "effect/Crypto"; | |
| 15 | import * as DateTime from "effect/DateTime"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Ref from "effect/Ref"; | |
| 19 | import * as Result from "effect/Result"; | |
| 20 | import * as Schema from "effect/Schema"; | |
| 21 | import * as Semaphore from "effect/Semaphore"; | |
| 22 | import { ChildProcessSpawner } from "effect/process"; | |
| 23 | ||
| 24 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 25 | import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; | |
| 26 | import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; | |
| 27 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | |
| 28 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 29 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 30 | import { | |
| 31 | defaultProviderContinuationIdentity, | |
| 32 | type ProviderDriver, | |
| 33 | type ProviderInstance, | |
| 34 | } from "@t3tools/provider-core/server/driver"; | |
| 35 | import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 36 | import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 37 | import { | |
| 38 | haveProviderSnapshotSettingsChanged, | |
| 39 | makeProviderSnapshotSettingsSource, | |
| 40 | type ProviderSnapshotSettings, | |
| 41 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 42 | import { | |
| 43 | type AcpRegistryAvailableCommands, | |
| 44 | type AcpRegistryConfigurationProbe, | |
| 45 | type AcpRegistryConfigurationProbeResult, | |
| 46 | type AcpRegistryLiveConfiguration, | |
| 47 | deleteAcpRegistrySession, | |
| 48 | disableAcpRegistryProvider, | |
| 49 | listAcpRegistrySessions, | |
| 50 | listAcpRegistryProviders, | |
| 51 | logoutAcpRegistry, | |
| 52 | probeAcpRegistryConfiguration, | |
| 53 | setAcpRegistryProvider, | |
| 54 | } from "./probe.ts"; | |
| 55 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 56 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 57 | import * as AcpRegistryAuth from "./auth.ts"; | |
| 58 | import * as AcpRegistryAuthenticationState from "./authenticationState.ts"; | |
| 59 | ||
| 60 | const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); | |
| 61 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); | |
| 62 | // How long a successful discovery probe stays valid. Periodic health | |
| 63 | // refreshes reuse it instead of spawning a fresh disposable ACP session; | |
| 64 | // failed or unauthenticated probes are never cached so a completed sign-in | |
| 65 | // is detected on the next refresh. | |
| 66 | const PROBE_SUCCESS_TTL_MS = 15 * 60 * 1_000; | |
| 67 | const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | |
| 68 | const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 69 | provider: DRIVER_KIND, | |
| 70 | packageName: null, | |
| 71 | }); | |
| 72 | ||
| 73 | const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { | |
| 74 | const unsupported = (operation: string) => | |
| 75 | Effect.fail( | |
| 76 | new TextGenerationError({ | |
| 77 | operation, | |
| 78 | detail: "ACP Registry instances do not provide application text generation.", | |
| 79 | }), | |
| 80 | ); | |
| 81 | return { | |
| 82 | generateCommitMessage: () => unsupported("generateCommitMessage"), | |
| 83 | generatePrContent: () => unsupported("generatePrContent"), | |
| 84 | generateBranchName: () => unsupported("generateBranchName"), | |
| 85 | generateThreadTitle: () => unsupported("generateThreadTitle"), | |
| 86 | }; | |
| 87 | }; | |
| 88 | ||
| 89 | function modelsFromDiscovery( | |
| 90 | discovery: | |
| 91 | | Pick<AcpRegistryLiveConfiguration, "models" | "currentModelId" | "configOptions"> | |
| 92 | | undefined, | |
| 93 | customModels: ReadonlyArray<string>, | |
| 94 | ): ReadonlyArray<ServerProviderModel> { | |
| 95 | const discovered = discovery?.models ?? []; | |
| 96 | // Discovered session config options and modes ride on every model so the | |
| 97 | // composer's generic option controls can drive them per thread. | |
| 98 | const capabilities = | |
| 99 | discovery === undefined || discovery.configOptions.length === 0 | |
| 100 | ? EMPTY_CAPABILITIES | |
| 101 | : createModelCapabilities({ optionDescriptors: discovery.configOptions }); | |
| 102 | const builtInModels: ReadonlyArray<ServerProviderModel> = | |
| 103 | discovered.length === 0 | |
| 104 | ? [ | |
| 105 | { | |
| 106 | slug: "default", | |
| 107 | name: "Default", | |
| 108 | isCustom: false, | |
| 109 | isDefault: true, | |
| 110 | capabilities, | |
| 111 | }, | |
| 112 | ] | |
| 113 | : discovered.map((model) => ({ | |
| 114 | slug: model.id, | |
| 115 | name: model.name, | |
| 116 | isCustom: false, | |
| 117 | ...(model.id === discovery?.currentModelId ? { isDefault: true } : {}), | |
| 118 | capabilities, | |
| 119 | })); | |
| 120 | return providerModelsFromSettings(builtInModels, customModels, capabilities); | |
| 121 | } | |
| 122 | ||
| 123 | export function acpRegistrySnapshotReadiness( | |
| 124 | inspection: | |
| 125 | | AcpRegistrySupport.AcpRegistryInspection | |
| 126 | | { readonly status: "failed"; readonly message: string }, | |
| 127 | ): Pick<ServerProvider, "installed" | "version" | "status" | "message"> { | |
| 128 | switch (inspection.status) { | |
| 129 | case "ready": | |
| 130 | return { installed: true, version: inspection.version, status: "ready" }; | |
| 131 | case "unconfigured": | |
| 132 | return { | |
| 133 | installed: false, | |
| 134 | version: null, | |
| 135 | status: "warning", | |
| 136 | message: | |
| 137 | "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", | |
| 138 | }; | |
| 139 | case "not_found": | |
| 140 | return { | |
| 141 | installed: false, | |
| 142 | version: null, | |
| 143 | status: "error", | |
| 144 | message: `ACP Registry does not contain agent '${inspection.agentId}'.`, | |
| 145 | }; | |
| 146 | case "unsupported": | |
| 147 | return { | |
| 148 | installed: false, | |
| 149 | version: inspection.version, | |
| 150 | status: "error", | |
| 151 | message: `ACP Registry agent '${inspection.agentId}' has no compatible distribution for this environment.`, | |
| 152 | }; | |
| 153 | case "missing_runner": | |
| 154 | return { | |
| 155 | installed: false, | |
| 156 | version: inspection.version, | |
| 157 | status: "error", | |
| 158 | message: | |
| 159 | inspection.distribution === "local" | |
| 160 | ? "Local ACP executable is not available on this environment's PATH." | |
| 161 | : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, | |
| 162 | }; | |
| 163 | case "unprepared": | |
| 164 | return { | |
| 165 | installed: false, | |
| 166 | version: inspection.version, | |
| 167 | status: "warning", | |
| 168 | message: `ACP Registry agent '${inspection.agentId}' has not been prepared on this environment.`, | |
| 169 | }; | |
| 170 | case "failed": | |
| 171 | return { | |
| 172 | installed: false, | |
| 173 | version: null, | |
| 174 | status: "error", | |
| 175 | message: inspection.message, | |
| 176 | }; | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | interface SnapshotIdentity { | |
| 181 | readonly instanceId: ProviderInstance["instanceId"]; | |
| 182 | readonly displayName: string | undefined; | |
| 183 | readonly accentColor: string | undefined; | |
| 184 | readonly continuationKey: string; | |
| 185 | } | |
| 186 | ||
| 187 | function baseSnapshot( | |
| 188 | input: SnapshotIdentity & { | |
| 189 | readonly settings: AcpRegistrySettings; | |
| 190 | readonly checkedAt: string; | |
| 191 | readonly installed: boolean; | |
| 192 | readonly version: string | null; | |
| 193 | readonly status: ServerProvider["status"]; | |
| 194 | readonly auth: ServerProvider["auth"]; | |
| 195 | readonly documentationUrl?: string; | |
| 196 | readonly message?: string; | |
| 197 | readonly probe?: AcpRegistryConfigurationProbeResult; | |
| 198 | readonly probeError?: AcpRegistryOperationError; | |
| 199 | }, | |
| 200 | ): ServerProvider { | |
| 201 | const iconUrl = | |
| 202 | input.settings.source === "local" | |
| 203 | ? null | |
| 204 | : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | |
| 205 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); | |
| 206 | return { | |
| 207 | instanceId: input.instanceId, | |
| 208 | driver: DRIVER_KIND, | |
| 209 | ...(input.displayName ? { displayName: input.displayName } : {}), | |
| 210 | ...(input.accentColor ? { accentColor: input.accentColor } : {}), | |
| 211 | ...(iconUrl ? { iconUrl } : {}), | |
| 212 | continuation: { groupKey: input.continuationKey }, | |
| 213 | // The registry driver rejects every application text-generation operation, | |
| 214 | // so selectors must not offer these instances for commit, PR, branch, or | |
| 215 | // title generation. | |
| 216 | supportsTextGeneration: false, | |
| 217 | enabled: input.settings.enabled, | |
| 218 | installed: input.installed, | |
| 219 | version: input.version, | |
| 220 | status: input.settings.enabled ? input.status : "disabled", | |
| 221 | auth: { ...input.auth, canLogout: input.auth.canLogout ?? false }, | |
| 222 | checkedAt: input.checkedAt, | |
| 223 | setup: { | |
| 224 | canInstall: false, | |
| 225 | ...(input.documentationUrl ? { documentationUrl: input.documentationUrl } : {}), | |
| 226 | canAuthenticate: | |
| 227 | input.installed && | |
| 228 | (input.probe | |
| 229 | ? input.probe.probe.authMethods.length > 0 | |
| 230 | : input.settings.source === "local" | |
| 231 | ? (input.probeError?.authMethods?.length ?? 0) > 0 | |
| 232 | : input.settings.agentId.length > 0), | |
| 233 | }, | |
| 234 | ...(input.message ? { message: input.message } : {}), | |
| 235 | models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), | |
| 236 | ...(input.probe === undefined | |
| 237 | ? {} | |
| 238 | : { | |
| 239 | nativeSessions: { | |
| 240 | canList: input.probe.probe.sessionManagement.canList, | |
| 241 | canLoad: input.probe.probe.sessionManagement.canLoad, | |
| 242 | canResume: input.probe.probe.sessionManagement.canResume, | |
| 243 | canDelete: input.probe.probe.sessionManagement.canDelete, | |
| 244 | }, | |
| 245 | configurableProviders: input.probe.probe.sessionManagement.canConfigureProviders, | |
| 246 | }), | |
| 247 | slashCommands: input.probe?.slashCommands ?? [], | |
| 248 | skills: input.probe?.skills ?? [], | |
| 249 | }; | |
| 250 | } | |
| 251 | ||
| 252 | export function applyAcpRegistryAvailableCommands( | |
| 253 | provider: ServerProvider, | |
| 254 | commands: Option.Option<AcpRegistryAvailableCommands>, | |
| 255 | ): ServerProvider { | |
| 256 | return Option.match(commands, { | |
| 257 | onNone: () => provider, | |
| 258 | onSome: ({ slashCommands, skills }) => ({ ...provider, slashCommands, skills }), | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | export function applyAcpRegistryLiveConfiguration( | |
| 263 | provider: ServerProvider, | |
| 264 | configuration: AcpRegistryLiveConfiguration, | |
| 265 | customModels: ReadonlyArray<string>, | |
| 266 | ): ServerProvider { | |
| 267 | const { message: _staleProbeMessage, ...snapshot } = provider; | |
| 268 | return { | |
| 269 | ...snapshot, | |
| 270 | status: provider.enabled ? "ready" : provider.status, | |
| 271 | models: modelsFromDiscovery(configuration, customModels), | |
| 272 | }; | |
| 273 | } | |
| 274 | ||
| 275 | function applyAcpRegistryUrlAuthAction( | |
| 276 | provider: ServerProvider, | |
| 277 | action: Option.Option<NonNullable<ServerProvider["auth"]["action"]>>, | |
| 278 | ): ServerProvider { | |
| 279 | const { action: _previousAction, ...auth } = provider.auth; | |
| 280 | return { | |
| 281 | ...provider, | |
| 282 | auth: Option.match(action, { | |
| 283 | onNone: () => auth, | |
| 284 | onSome: (nextAction) => ({ ...auth, action: nextAction }), | |
| 285 | }), | |
| 286 | }; | |
| 287 | } | |
| 288 | ||
| 289 | const buildInitialAcpRegistrySnapshot = Effect.fn("AcpRegistryDriver.buildInitialSnapshot")( | |
| 290 | function* (input: SnapshotIdentity & { readonly settings: AcpRegistrySettings }) { | |
| 291 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 292 | return baseSnapshot({ | |
| 293 | ...input, | |
| 294 | checkedAt, | |
| 295 | installed: false, | |
| 296 | version: null, | |
| 297 | status: "warning", | |
| 298 | auth: { status: "unknown" }, | |
| 299 | message: input.settings.enabled | |
| 300 | ? "Checking ACP Registry agent readiness..." | |
| 301 | : "ACP Registry is disabled in T3 Code settings.", | |
| 302 | }); | |
| 303 | }, | |
| 304 | ); | |
| 305 | ||
| 306 | export function buildCheckedAcpRegistrySnapshot( | |
| 307 | input: SnapshotIdentity & { | |
| 308 | readonly settings: AcpRegistrySettings; | |
| 309 | readonly checkedAt: string; | |
| 310 | readonly inspection: | |
| 311 | | AcpRegistrySupport.AcpRegistryInspection | |
| 312 | | { readonly status: "failed"; readonly message: string }; | |
| 313 | readonly probe?: AcpRegistryConfigurationProbeResult; | |
| 314 | readonly probeError?: AcpRegistryOperationError; | |
| 315 | }, | |
| 316 | ): ServerProvider { | |
| 317 | const readiness = acpRegistrySnapshotReadiness(input.inspection); | |
| 318 | const probeFailed = input.probeError !== undefined; | |
| 319 | const advertisedAuthMethod = | |
| 320 | input.probeError?.reason === "authentication_failed" | |
| 321 | ? (input.probeError.authMethods?.find( | |
| 322 | (method) => method.id === input.settings.authMethodId, | |
| 323 | ) ?? input.probeError.authMethods?.[0]) | |
| 324 | : undefined; | |
| 325 | const authenticationMessage = advertisedAuthMethod | |
| 326 | ? advertisedAuthMethod.type === "terminal" && advertisedAuthMethod.command | |
| 327 | ? `Sign in in provider settings using "${advertisedAuthMethod.name}". The login terminal runs on this environment.` | |
| 328 | : advertisedAuthMethod.type === "env_var" && | |
| 329 | (advertisedAuthMethod.envVarNames?.length ?? 0) > 0 | |
| 330 | ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.` | |
| 331 | : `Sign in in provider settings using "${advertisedAuthMethod.name}".` | |
| 332 | : undefined; | |
| 333 | return baseSnapshot({ | |
| 334 | ...input, | |
| 335 | ...(input.inspection.status === "ready" && input.inspection.documentationUrl | |
| 336 | ? { documentationUrl: input.inspection.documentationUrl } | |
| 337 | : {}), | |
| 338 | installed: readiness.installed, | |
| 339 | version: readiness.version, | |
| 340 | // A failed discovery probe on a ready installation is a warning, not an | |
| 341 | // outage: the agent binary is present and a real turn may still work | |
| 342 | // (for example after the user completes authentication). | |
| 343 | status: probeFailed ? "warning" : readiness.status, | |
| 344 | auth: input.probe | |
| 345 | ? { | |
| 346 | // Discovery sessions and an empty auth-method list do not prove sign-in. | |
| 347 | status: "unknown", | |
| 348 | canLogout: input.probe.probe.sessionManagement.canLogout, | |
| 349 | } | |
| 350 | : input.probeError?.reason === "authentication_failed" | |
| 351 | ? { | |
| 352 | status: "unauthenticated", | |
| 353 | ...(advertisedAuthMethod | |
| 354 | ? { type: advertisedAuthMethod.type, label: advertisedAuthMethod.name } | |
| 355 | : {}), | |
| 356 | ...(input.probeError.authAction === undefined | |
| 357 | ? {} | |
| 358 | : { action: input.probeError.authAction }), | |
| 359 | } | |
| 360 | : { status: "unknown" }, | |
| 361 | ...(input.probeError | |
| 362 | ? { message: authenticationMessage ?? input.probeError.message } | |
| 363 | : readiness.message | |
| 364 | ? { message: readiness.message } | |
| 365 | : {}), | |
| 366 | }); | |
| 367 | } | |
| 368 | ||
| 369 | export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")( | |
| 370 | function* <Requirements>( | |
| 371 | input: SnapshotIdentity & { | |
| 372 | readonly settings: AcpRegistrySettings; | |
| 373 | readonly cwd: string; | |
| 374 | readonly environment: NodeJS.ProcessEnv; | |
| 375 | }, | |
| 376 | probeConfiguration: AcpRegistryConfigurationProbe<Requirements>, | |
| 377 | ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog | Requirements> { | |
| 378 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 379 | if (!input.settings.enabled) { | |
| 380 | return yield* buildInitialAcpRegistrySnapshot(input); | |
| 381 | } | |
| 382 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 383 | const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); | |
| 384 | if (Result.isFailure(inspected)) { | |
| 385 | return buildCheckedAcpRegistrySnapshot({ | |
| 386 | ...input, | |
| 387 | checkedAt, | |
| 388 | inspection: { | |
| 389 | status: "failed", | |
| 390 | message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, | |
| 391 | }, | |
| 392 | }); | |
| 393 | } | |
| 394 | if (inspected.success.status !== "ready") { | |
| 395 | return buildCheckedAcpRegistrySnapshot({ | |
| 396 | ...input, | |
| 397 | checkedAt, | |
| 398 | inspection: inspected.success, | |
| 399 | }); | |
| 400 | } | |
| 401 | const probed = yield* Effect.result( | |
| 402 | probeConfiguration({ | |
| 403 | instanceId: input.instanceId, | |
| 404 | settings: input.settings, | |
| 405 | cwd: input.cwd, | |
| 406 | environment: input.environment, | |
| 407 | }), | |
| 408 | ); | |
| 409 | return Result.isFailure(probed) | |
| 410 | ? buildCheckedAcpRegistrySnapshot({ | |
| 411 | ...input, | |
| 412 | checkedAt, | |
| 413 | inspection: inspected.success, | |
| 414 | probeError: probed.failure, | |
| 415 | }) | |
| 416 | : buildCheckedAcpRegistrySnapshot({ | |
| 417 | ...input, | |
| 418 | checkedAt, | |
| 419 | inspection: inspected.success, | |
| 420 | probe: probed.success, | |
| 421 | }); | |
| 422 | }, | |
| 423 | ); | |
| 424 | ||
| 425 | /** Publishes local executable readiness without starting an ACP process. */ | |
| 426 | export const checkAcpRegistryProviderReadiness = Effect.fn( | |
| 427 | "AcpRegistryDriver.checkProviderReadiness", | |
| 428 | )(function* ( | |
| 429 | input: SnapshotIdentity & { | |
| 430 | readonly settings: AcpRegistrySettings; | |
| 431 | readonly environment: NodeJS.ProcessEnv; | |
| 432 | }, | |
| 433 | ): Effect.fn.Return<ServerProvider, never, AcpRegistrySupport.AcpRegistryCatalog> { | |
| 434 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 435 | if (!input.settings.enabled) { | |
| 436 | return yield* buildInitialAcpRegistrySnapshot(input); | |
| 437 | } | |
| 438 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 439 | const inspected = yield* Effect.result(catalog.inspect(input.settings, input.environment)); | |
| 440 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 441 | ...input, | |
| 442 | checkedAt, | |
| 443 | inspection: Result.isFailure(inspected) | |
| 444 | ? { | |
| 445 | status: "failed", | |
| 446 | message: `Could not inspect ACP Registry agent: ${inspected.failure.message}`, | |
| 447 | } | |
| 448 | : inspected.success, | |
| 449 | }); | |
| 450 | return inspected._tag === "Success" && inspected.success.status === "ready" | |
| 451 | ? { | |
| 452 | ...snapshot, | |
| 453 | message: "Checking ACP authentication, models, and commands in the background...", | |
| 454 | } | |
| 455 | : snapshot; | |
| 456 | }); | |
| 457 | ||
| 458 | export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost; | |
| 459 | ||
| 460 | /** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */ | |
| 461 | export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryDriverEnv> = { | |
| 462 | driverKind: DRIVER_KIND, | |
| 463 | metadata: { | |
| 464 | displayName: "ACP Registry", | |
| 465 | supportsMultipleInstances: true, | |
| 466 | hasDefaultInstance: false, | |
| 467 | }, | |
| 468 | configSchema: AcpRegistrySettings, | |
| 469 | defaultConfig: () => decodeSettings({}), | |
| 470 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 471 | Effect.gen(function* () { | |
| 472 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 473 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 474 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 475 | ); | |
| 476 | if (Option.isSome(runtimeCoordinator)) { | |
| 477 | yield* runtimeCoordinator.value.clearAvailableCommands(instanceId); | |
| 478 | yield* runtimeCoordinator.value.clearLiveConfiguration(instanceId); | |
| 479 | yield* Effect.addFinalizer(() => | |
| 480 | runtimeCoordinator.value | |
| 481 | .clearAvailableCommands(instanceId) | |
| 482 | .pipe(Effect.andThen(runtimeCoordinator.value.clearLiveConfiguration(instanceId))), | |
| 483 | ); | |
| 484 | } | |
| 485 | const crypto = yield* Crypto.Crypto; | |
| 486 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 487 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 488 | const host = yield* ProviderHost; | |
| 489 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 490 | driverKind: DRIVER_KIND, | |
| 491 | instanceId, | |
| 492 | }); | |
| 493 | const identity = { | |
| 494 | instanceId, | |
| 495 | displayName, | |
| 496 | accentColor, | |
| 497 | continuationKey: continuationIdentity.continuationKey, | |
| 498 | }; | |
| 499 | const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; | |
| 500 | const processEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); | |
| 501 | const orchestrationAdapter = yield* AcpRegistryAdapterV2Driver.create({ | |
| 502 | instanceId, | |
| 503 | displayName, | |
| 504 | accentColor, | |
| 505 | environment, | |
| 506 | enabled, | |
| 507 | config, | |
| 508 | }).pipe( | |
| 509 | Effect.mapError( | |
| 510 | (cause) => | |
| 511 | new ProviderDriverError({ | |
| 512 | driver: DRIVER_KIND, | |
| 513 | instanceId, | |
| 514 | detail: "Failed to build ACP Registry orchestration adapter.", | |
| 515 | cause, | |
| 516 | }), | |
| 517 | ), | |
| 518 | ); | |
| 519 | const readinessInput = { | |
| 520 | ...identity, | |
| 521 | settings: effectiveConfig, | |
| 522 | environment: processEnvironment, | |
| 523 | }; | |
| 524 | const confirmedAuthentication = | |
| 525 | yield* AcpRegistryAuthenticationState.makeAcpRegistryAuthenticationState({ | |
| 526 | cacheDir: host.paths.providerStatusCacheDir, | |
| 527 | instanceId, | |
| 528 | settings: effectiveConfig, | |
| 529 | environment, | |
| 530 | processEnvironment, | |
| 531 | }); | |
| 532 | const withLiveRuntimeState = (input: ServerProvider) => | |
| 533 | Effect.gen(function* () { | |
| 534 | if (input.auth.status === "unauthenticated") yield* confirmedAuthentication.set(false); | |
| 535 | const provider = | |
| 536 | input.enabled && | |
| 537 | input.installed && | |
| 538 | input.auth.status === "unknown" && | |
| 539 | (yield* confirmedAuthentication.get) | |
| 540 | ? { ...input, auth: { ...input.auth, status: "authenticated" as const } } | |
| 541 | : input; | |
| 542 | return yield* Option.isNone(runtimeCoordinator) | |
| 543 | ? Effect.succeed(provider) | |
| 544 | : Effect.all({ | |
| 545 | commands: runtimeCoordinator.value.getAvailableCommands(instanceId), | |
| 546 | configuration: runtimeCoordinator.value.getLiveConfiguration(instanceId), | |
| 547 | authAction: runtimeCoordinator.value.getUrlAuthAction(instanceId), | |
| 548 | }).pipe( | |
| 549 | Effect.map(({ commands, configuration, authAction }) => { | |
| 550 | const withCommands = applyAcpRegistryAvailableCommands(provider, commands); | |
| 551 | const withConfiguration = Option.match(configuration, { | |
| 552 | onNone: () => withCommands, | |
| 553 | onSome: (liveConfiguration) => | |
| 554 | applyAcpRegistryLiveConfiguration( | |
| 555 | withCommands, | |
| 556 | liveConfiguration, | |
| 557 | effectiveConfig.customModels, | |
| 558 | ), | |
| 559 | }); | |
| 560 | return applyAcpRegistryUrlAuthAction(withConfiguration, authAction); | |
| 561 | }), | |
| 562 | ); | |
| 563 | }); | |
| 564 | const checkProvider = checkAcpRegistryProviderReadiness(readinessInput).pipe( | |
| 565 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 566 | Effect.flatMap(withLiveRuntimeState), | |
| 567 | ); | |
| 568 | const enrichProvider = checkAcpRegistryProviderStatus( | |
| 569 | { | |
| 570 | ...readinessInput, | |
| 571 | cwd: host.paths.cwd, | |
| 572 | }, | |
| 573 | probeAcpRegistryConfiguration, | |
| 574 | ).pipe( | |
| 575 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 576 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 577 | Effect.provideService(Crypto.Crypto, crypto), | |
| 578 | ); | |
| 579 | const enrichmentCache = yield* Ref.make<{ | |
| 580 | readonly generation: number; | |
| 581 | readonly entry: { | |
| 582 | readonly provider: ServerProvider; | |
| 583 | readonly expiresAt: number; | |
| 584 | } | null; | |
| 585 | }>({ generation: 0, entry: null }); | |
| 586 | const liveSnapshotSemaphore = yield* Semaphore.make(1); | |
| 587 | const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ | |
| 588 | generation: current.generation + 1, | |
| 589 | entry: null, | |
| 590 | })); | |
| 591 | const enrichProviderCached = (baseSnapshot: ServerProvider) => | |
| 592 | Effect.gen(function* () { | |
| 593 | const now = yield* Clock.currentTimeMillis; | |
| 594 | const cacheState = yield* Ref.get(enrichmentCache); | |
| 595 | const cached = cacheState.entry; | |
| 596 | if ( | |
| 597 | cached !== null && | |
| 598 | cached.expiresAt > now && | |
| 599 | cached.provider.version === baseSnapshot.version | |
| 600 | ) { | |
| 601 | return { | |
| 602 | provider: { ...cached.provider, checkedAt: baseSnapshot.checkedAt }, | |
| 603 | generation: cacheState.generation, | |
| 604 | }; | |
| 605 | } | |
| 606 | const enriched = yield* enrichProvider; | |
| 607 | if (enriched.status === "ready") { | |
| 608 | yield* Ref.update(enrichmentCache, (current) => | |
| 609 | current.generation === cacheState.generation | |
| 610 | ? { | |
| 611 | ...current, | |
| 612 | entry: { | |
| 613 | provider: enriched, | |
| 614 | expiresAt: now + PROBE_SUCCESS_TTL_MS, | |
| 615 | }, | |
| 616 | } | |
| 617 | : current, | |
| 618 | ); | |
| 619 | } | |
| 620 | return { provider: enriched, generation: cacheState.generation }; | |
| 621 | }); | |
| 622 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 623 | const snapshot = yield* makeManagedServerProvider< | |
| 624 | ProviderSnapshotSettings<AcpRegistrySettings> | |
| 625 | >({ | |
| 626 | resolveMaintenance: () => Effect.succeed(MAINTENANCE), | |
| 627 | getSettings: snapshotSettings.getSettings, | |
| 628 | streamSettings: snapshotSettings.streamSettings, | |
| 629 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 630 | initialSnapshot: () => checkProvider, | |
| 631 | checkProvider, | |
| 632 | enrichSnapshot: ({ snapshot, getSnapshot, publishSnapshot }) => { | |
| 633 | if (!snapshot.installed) return Effect.void; | |
| 634 | const publishEnrichment = ( | |
| 635 | Option.isSome(runtimeCoordinator) | |
| 636 | ? runtimeCoordinator.value.runBackgroundProbe( | |
| 637 | effectiveConfig.source === "local" | |
| 638 | ? `local:${instanceId}` | |
| 639 | : effectiveConfig.agentId, | |
| 640 | enrichProviderCached(snapshot), | |
| 641 | ) | |
| 642 | : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) | |
| 643 | ).pipe( | |
| 644 | Effect.flatMap( | |
| 645 | Option.match({ | |
| 646 | onNone: () => Effect.void, | |
| 647 | onSome: ({ provider, generation }) => | |
| 648 | liveSnapshotSemaphore.withPermit( | |
| 649 | Ref.get(enrichmentCache).pipe( | |
| 650 | Effect.flatMap((current) => | |
| 651 | current.generation === generation | |
| 652 | ? withLiveRuntimeState(provider).pipe(Effect.flatMap(publishSnapshot)) | |
| 653 | : Effect.void, | |
| 654 | ), | |
| 655 | ), | |
| 656 | ), | |
| 657 | }), | |
| 658 | ), | |
| 659 | ); | |
| 660 | if (Option.isNone(runtimeCoordinator)) return publishEnrichment; | |
| 661 | ||
| 662 | const publishLiveCommands = runtimeCoordinator.value.watchAvailableCommands( | |
| 663 | instanceId, | |
| 664 | ({ slashCommands, skills }) => | |
| 665 | liveSnapshotSemaphore.withPermit( | |
| 666 | getSnapshot.pipe( | |
| 667 | Effect.flatMap((current) => | |
| 668 | publishSnapshot({ | |
| 669 | ...current, | |
| 670 | slashCommands, | |
| 671 | skills, | |
| 672 | }), | |
| 673 | ), | |
| 674 | ), | |
| 675 | ), | |
| 676 | ); | |
| 677 | const publishLiveConfiguration = runtimeCoordinator.value.watchLiveConfiguration( | |
| 678 | instanceId, | |
| 679 | (configuration) => | |
| 680 | liveSnapshotSemaphore.withPermit( | |
| 681 | getSnapshot.pipe( | |
| 682 | Effect.flatMap((current) => | |
| 683 | publishSnapshot( | |
| 684 | applyAcpRegistryLiveConfiguration( | |
| 685 | current, | |
| 686 | configuration, | |
| 687 | effectiveConfig.customModels, | |
| 688 | ), | |
| 689 | ), | |
| 690 | ), | |
| 691 | ), | |
| 692 | ), | |
| 693 | ); | |
| 694 | const publishUrlAuthAction = runtimeCoordinator.value.watchUrlAuthAction( | |
| 695 | instanceId, | |
| 696 | (action) => | |
| 697 | liveSnapshotSemaphore.withPermit( | |
| 698 | getSnapshot.pipe( | |
| 699 | Effect.flatMap((current) => | |
| 700 | publishSnapshot( | |
| 701 | applyAcpRegistryUrlAuthAction(current, Option.fromNullishOr(action)), | |
| 702 | ), | |
| 703 | ), | |
| 704 | ), | |
| 705 | ), | |
| 706 | ); | |
| 707 | return Effect.all( | |
| 708 | [ | |
| 709 | publishEnrichment, | |
| 710 | publishLiveCommands, | |
| 711 | publishLiveConfiguration, | |
| 712 | publishUrlAuthAction, | |
| 713 | ], | |
| 714 | { | |
| 715 | concurrency: "unbounded", | |
| 716 | discard: true, | |
| 717 | }, | |
| 718 | ); | |
| 719 | }, | |
| 720 | }).pipe( | |
| 721 | Effect.mapError( | |
| 722 | (cause) => | |
| 723 | new ProviderDriverError({ | |
| 724 | driver: DRIVER_KIND, | |
| 725 | instanceId, | |
| 726 | detail: "Failed to build the ACP Registry provider snapshot.", | |
| 727 | cause, | |
| 728 | }), | |
| 729 | ), | |
| 730 | ); | |
| 731 | ||
| 732 | // The management probes read the coordinator via serviceOption, so it is | |
| 733 | // provided only when this driver instance actually has one. | |
| 734 | const provideAcpManagementServices = <A, E>( | |
| 735 | effect: Effect.Effect< | |
| 736 | A, | |
| 737 | E, | |
| 738 | | AcpRegistrySupport.AcpRegistryCatalog | |
| 739 | | ChildProcessSpawner.ChildProcessSpawner | |
| 740 | | Crypto.Crypto | |
| 741 | >, | |
| 742 | ): Effect.Effect<A, E> => { | |
| 743 | const provided = effect.pipe( | |
| 744 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 745 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 746 | Effect.provideService(Crypto.Crypto, crypto), | |
| 747 | ); | |
| 748 | return Option.isSome(runtimeCoordinator) | |
| 749 | ? provided.pipe( | |
| 750 | Effect.provideService( | |
| 751 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 752 | runtimeCoordinator.value, | |
| 753 | ), | |
| 754 | ) | |
| 755 | : provided; | |
| 756 | }; | |
| 757 | ||
| 758 | const clearLiveState = Option.isSome(runtimeCoordinator) | |
| 759 | ? runtimeCoordinator.value | |
| 760 | .clearLiveConfiguration(instanceId) | |
| 761 | .pipe(Effect.andThen(runtimeCoordinator.value.clearAvailableCommands(instanceId))) | |
| 762 | : Effect.void; | |
| 763 | const controller = yield* AcpRegistryAuth.makeAcpRegistryAuth({ | |
| 764 | instanceId, | |
| 765 | settings: effectiveConfig, | |
| 766 | cwd: host.paths.cwd, | |
| 767 | environment: processEnvironment, | |
| 768 | onChanged: (authenticated) => | |
| 769 | confirmedAuthentication | |
| 770 | .set(authenticated) | |
| 771 | .pipe( | |
| 772 | Effect.andThen(authenticated ? Effect.void : clearLiveState), | |
| 773 | Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), | |
| 774 | Effect.andThen(snapshot.refresh), | |
| 775 | Effect.asVoid, | |
| 776 | ), | |
| 777 | }).pipe( | |
| 778 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 779 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 780 | Effect.provideService(Crypto.Crypto, crypto), | |
| 781 | ); | |
| 782 | const auth = { | |
| 783 | ...controller, | |
| 784 | invalidate: (controller.invalidate ?? Effect.void).pipe( | |
| 785 | Effect.andThen(confirmedAuthentication.set(false)), | |
| 786 | Effect.andThen(clearLiveState), | |
| 787 | Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)), | |
| 788 | Effect.andThen(snapshot.refresh), | |
| 789 | Effect.asVoid, | |
| 790 | ), | |
| 791 | }; | |
| 792 | ||
| 793 | return { | |
| 794 | instanceId, | |
| 795 | driverKind: DRIVER_KIND, | |
| 796 | continuationIdentity, | |
| 797 | displayName, | |
| 798 | accentColor, | |
| 799 | enabled, | |
| 800 | auth, | |
| 801 | snapshot: { | |
| 802 | ...snapshot, | |
| 803 | refresh: snapshot.refresh.pipe( | |
| 804 | Effect.tap(() => controller.refreshMethods ?? Effect.void), | |
| 805 | ), | |
| 806 | }, | |
| 807 | orchestrationAdapter, | |
| 808 | textGeneration: makeUnsupportedTextGeneration(), | |
| 809 | acpSessionManagement: { | |
| 810 | listSessions: ({ cwd, cursor }) => | |
| 811 | provideAcpManagementServices( | |
| 812 | listAcpRegistrySessions({ | |
| 813 | instanceId, | |
| 814 | settings: effectiveConfig, | |
| 815 | cwd, | |
| 816 | environment: processEnvironment, | |
| 817 | ...(cursor === undefined ? {} : { cursor }), | |
| 818 | }), | |
| 819 | ), | |
| 820 | deleteSession: ({ cwd, sessionId }) => | |
| 821 | provideAcpManagementServices( | |
| 822 | deleteAcpRegistrySession({ | |
| 823 | instanceId, | |
| 824 | settings: effectiveConfig, | |
| 825 | cwd, | |
| 826 | environment: processEnvironment, | |
| 827 | sessionId, | |
| 828 | }), | |
| 829 | ), | |
| 830 | listProviders: (cwd) => | |
| 831 | provideAcpManagementServices( | |
| 832 | listAcpRegistryProviders({ | |
| 833 | instanceId, | |
| 834 | settings: effectiveConfig, | |
| 835 | cwd, | |
| 836 | environment: processEnvironment, | |
| 837 | }), | |
| 838 | ), | |
| 839 | setProvider: ({ cwd, providerId, apiType, baseUrl, headers }) => | |
| 840 | provideAcpManagementServices( | |
| 841 | setAcpRegistryProvider({ | |
| 842 | instanceId, | |
| 843 | settings: effectiveConfig, | |
| 844 | cwd, | |
| 845 | environment: processEnvironment, | |
| 846 | providerId, | |
| 847 | apiType, | |
| 848 | baseUrl, | |
| 849 | ...(headers === undefined ? {} : { headers }), | |
| 850 | }), | |
| 851 | ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), | |
| 852 | disableProvider: ({ cwd, providerId }) => | |
| 853 | provideAcpManagementServices( | |
| 854 | disableAcpRegistryProvider({ | |
| 855 | instanceId, | |
| 856 | settings: effectiveConfig, | |
| 857 | cwd, | |
| 858 | environment: processEnvironment, | |
| 859 | providerId, | |
| 860 | }), | |
| 861 | ).pipe(Effect.tap(() => liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), | |
| 862 | logout: (cwd) => { | |
| 863 | const logout = logoutAcpRegistry({ | |
| 864 | instanceId, | |
| 865 | settings: effectiveConfig, | |
| 866 | cwd, | |
| 867 | environment: processEnvironment, | |
| 868 | }).pipe( | |
| 869 | Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), | |
| 870 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 871 | Effect.provideService(Crypto.Crypto, crypto), | |
| 872 | Effect.tap(() => confirmedAuthentication.set(false)), | |
| 873 | Effect.tap(() => | |
| 874 | liveSnapshotSemaphore.withPermit( | |
| 875 | invalidateEnrichmentCache.pipe( | |
| 876 | Effect.andThen( | |
| 877 | Option.isSome(runtimeCoordinator) | |
| 878 | ? Effect.all( | |
| 879 | [ | |
| 880 | runtimeCoordinator.value.clearAvailableCommands(instanceId), | |
| 881 | runtimeCoordinator.value.clearLiveConfiguration(instanceId), | |
| 882 | ], | |
| 883 | { concurrency: "unbounded", discard: true }, | |
| 884 | ) | |
| 885 | : Effect.void, | |
| 886 | ), | |
| 887 | ), | |
| 888 | ), | |
| 889 | ), | |
| 890 | ); | |
| 891 | return Option.isSome(runtimeCoordinator) | |
| 892 | ? logout.pipe( | |
| 893 | Effect.provideService( | |
| 894 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 895 | runtimeCoordinator.value, | |
| 896 | ), | |
| 897 | ) | |
| 898 | : logout; | |
| 899 | }, | |
| 900 | }, | |
| 901 | } satisfies ProviderInstance; | |
| 902 | }), | |
| 903 | }; |
packages/provider-acp-registry/src/server/probe.ts created+782| ... | ... | @@ -0,0 +1,782 @@ |
| 1 | import { | |
| 2 | AcpRegistryListProvidersResult, | |
| 3 | AcpRegistryOperationError, | |
| 4 | AcpRegistryListSessionsResult, | |
| 5 | AcpRegistryProbeResult, | |
| 6 | type AcpRegistryProbeAuthMethod, | |
| 7 | type AcpRegistryProbeModel, | |
| 8 | type AcpRegistryUrlAuthAction, | |
| 9 | type ProviderInstanceId, | |
| 10 | type ServerProviderSkill, | |
| 11 | type ServerProviderSlashCommand, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { AcpRegistrySettings } from "../settings.ts"; | |
| 14 | import * as Crypto from "effect/Crypto"; | |
| 15 | import * as Deferred from "effect/Deferred"; | |
| 16 | import * as Duration from "effect/Duration"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as Layer from "effect/Layer"; | |
| 19 | import * as Option from "effect/Option"; | |
| 20 | import * as Ref from "effect/Ref"; | |
| 21 | import * as Result from "effect/Result"; | |
| 22 | import { ChildProcessSpawner } from "effect/process"; | |
| 23 | import type * as EffectAcpErrors from "effect-acp/errors"; | |
| 24 | import type * as EffectAcpSchema from "effect-acp/compat"; | |
| 25 | ||
| 26 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 27 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | |
| 28 | import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; | |
| 29 | import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; | |
| 30 | import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; | |
| 31 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | |
| 32 | ||
| 33 | const MAX_AUTH_METHODS = 32; | |
| 34 | const MAX_MODELS = 256; | |
| 35 | const MAX_ID_LENGTH = 128; | |
| 36 | const MAX_NAME_LENGTH = 160; | |
| 37 | const MAX_DESCRIPTION_LENGTH = 1_024; | |
| 38 | const MAX_COMMANDS = 128; | |
| 39 | const MAX_COMMAND_LINE_LENGTH = 2_048; | |
| 40 | // Covers a cold package command launch and ACP initialization after registry | |
| 41 | // preparation globally installs the exact package. | |
| 42 | const PROBE_TIMEOUT_SECONDS = 60; | |
| 43 | const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; | |
| 44 | const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; | |
| 45 | ||
| 46 | const boundedText = (value: string, maximumLength: number): string => | |
| 47 | value.trim().slice(0, maximumLength); | |
| 48 | ||
| 49 | const boundedOpaqueValue = (value: string, maximumLength: number): string | undefined => | |
| 50 | value.length > 0 && value === value.trim() && value.length <= maximumLength ? value : undefined; | |
| 51 | ||
| 52 | export function normalizeAcpRegistryWebUrl(value: string): string | undefined { | |
| 53 | if (value.length > 2_048 || !URL.canParse(value)) return undefined; | |
| 54 | const url = new URL(value); | |
| 55 | return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; | |
| 56 | } | |
| 57 | ||
| 58 | function modelConfigOptions( | |
| 59 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | |
| 60 | ): ReadonlyArray<EffectAcpSchema.SessionConfigSelectOption> { | |
| 61 | return (configOptions ?? []).flatMap((option) => { | |
| 62 | if (option.category !== "model" || option.type !== "select") return []; | |
| 63 | return option.options.flatMap((candidate) => | |
| 64 | "value" in candidate ? [candidate] : candidate.options, | |
| 65 | ); | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | function normalizeModels( | |
| 70 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, | |
| 71 | ): ReadonlyArray<AcpRegistryProbeModel> { | |
| 72 | // Model discovery is the model config option's base models; ACP has no | |
| 73 | // other portable model inventory. | |
| 74 | const candidates = modelConfigOptions(configOptions).map((model) => ({ | |
| 75 | id: model.value, | |
| 76 | name: model.name, | |
| 77 | description: model.description ?? null, | |
| 78 | })); | |
| 79 | const seen = new Set<string>(); | |
| 80 | const models: Array<AcpRegistryProbeModel> = []; | |
| 81 | for (const candidate of candidates) { | |
| 82 | const id = boundedOpaqueValue(candidate.id, MAX_ID_LENGTH); | |
| 83 | if (id === undefined || seen.has(id)) continue; | |
| 84 | seen.add(id); | |
| 85 | models.push({ | |
| 86 | id, | |
| 87 | name: boundedText(candidate.name, MAX_NAME_LENGTH) || id, | |
| 88 | description: | |
| 89 | candidate.description === null | |
| 90 | ? null | |
| 91 | : boundedText(candidate.description, MAX_DESCRIPTION_LENGTH) || null, | |
| 92 | }); | |
| 93 | if (models.length === MAX_MODELS) break; | |
| 94 | } | |
| 95 | return models; | |
| 96 | } | |
| 97 | ||
| 98 | /** Agent spawn recipe used to compose runnable terminal-auth command lines. */ | |
| 99 | export interface AcpRegistryAuthSpawnContext { | |
| 100 | readonly command: string; | |
| 101 | readonly args: ReadonlyArray<string>; | |
| 102 | } | |
| 103 | ||
| 104 | const SHELL_SAFE_TOKEN = /^[A-Za-z0-9_@%+=:,./-]+$/u; | |
| 105 | ||
| 106 | function shellDisplayToken(token: string): string { | |
| 107 | return SHELL_SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; | |
| 108 | } | |
| 109 | ||
| 110 | function terminalAuthCommand( | |
| 111 | method: Extract<EffectAcpSchema.AuthMethod, { readonly type: "terminal" }>, | |
| 112 | spawn: AcpRegistryAuthSpawnContext, | |
| 113 | ): string | undefined { | |
| 114 | const environmentPrefix = Object.entries(method.env ?? {}).map( | |
| 115 | ([name, value]) => `${name}=${shellDisplayToken(value)}`, | |
| 116 | ); | |
| 117 | const command = [spawn.command, ...spawn.args, ...(method.args ?? [])].map(shellDisplayToken); | |
| 118 | const displayCommand = [...environmentPrefix, ...command].join(" "); | |
| 119 | return displayCommand.length <= MAX_COMMAND_LINE_LENGTH ? displayCommand : undefined; | |
| 120 | } | |
| 121 | ||
| 122 | export function normalizeAcpRegistryAuthMethods( | |
| 123 | methods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | |
| 124 | spawn?: AcpRegistryAuthSpawnContext, | |
| 125 | ): ReadonlyArray<AcpRegistryProbeAuthMethod> { | |
| 126 | const normalized: Array<AcpRegistryProbeAuthMethod> = []; | |
| 127 | for (const method of methods ?? []) { | |
| 128 | const id = boundedOpaqueValue(method.id, MAX_ID_LENGTH); | |
| 129 | if (id === undefined) continue; | |
| 130 | const type = method.type ?? "agent"; | |
| 131 | const envVarNames = | |
| 132 | type === "env_var" && "vars" in method | |
| 133 | ? method.vars | |
| 134 | .flatMap((variable) => { | |
| 135 | const name = boundedOpaqueValue(variable.name, MAX_ID_LENGTH); | |
| 136 | return name === undefined ? [] : [name]; | |
| 137 | }) | |
| 138 | .slice(0, 16) | |
| 139 | : []; | |
| 140 | const command = | |
| 141 | spawn !== undefined && "type" in method && method.type === "terminal" | |
| 142 | ? terminalAuthCommand(method, spawn) | |
| 143 | : undefined; | |
| 144 | const link = | |
| 145 | type === "env_var" && "link" in method && method.link | |
| 146 | ? normalizeAcpRegistryWebUrl(method.link) | |
| 147 | : undefined; | |
| 148 | normalized.push({ | |
| 149 | id, | |
| 150 | name: boundedText(method.name, MAX_NAME_LENGTH) || id, | |
| 151 | description: | |
| 152 | method.description == null | |
| 153 | ? null | |
| 154 | : boundedText(method.description, MAX_DESCRIPTION_LENGTH) || null, | |
| 155 | type, | |
| 156 | ...(command === undefined ? {} : { command }), | |
| 157 | ...(envVarNames.length > 0 ? { envVarNames } : {}), | |
| 158 | ...(link === undefined ? {} : { link }), | |
| 159 | }); | |
| 160 | if (normalized.length === MAX_AUTH_METHODS) break; | |
| 161 | } | |
| 162 | return normalized; | |
| 163 | } | |
| 164 | ||
| 165 | export interface AcpRegistryAvailableCommands { | |
| 166 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 167 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 168 | } | |
| 169 | ||
| 170 | export interface AcpRegistryLiveConfiguration { | |
| 171 | readonly models: ReadonlyArray<AcpRegistryProbeModel>; | |
| 172 | readonly currentModelId: string | null; | |
| 173 | readonly configOptions: AcpRegistryProbeResult["configOptions"]; | |
| 174 | } | |
| 175 | ||
| 176 | /** Normalizes volatile session configuration using the same bounds as discovery probes. */ | |
| 177 | export function normalizeAcpRegistryLiveConfiguration( | |
| 178 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | |
| 179 | modeState?: Parameters<typeof acpProviderOptionDescriptors>[0]["modeState"], | |
| 180 | ): AcpRegistryLiveConfiguration { | |
| 181 | const modelOption = configOptions.find( | |
| 182 | (option) => option.category === "model" && option.type === "select", | |
| 183 | ); | |
| 184 | const boundedCurrentModelId = | |
| 185 | modelOption?.type === "select" | |
| 186 | ? (boundedOpaqueValue(modelOption.currentValue, MAX_ID_LENGTH) ?? null) | |
| 187 | : null; | |
| 188 | const models = normalizeModels(configOptions); | |
| 189 | return { | |
| 190 | models, | |
| 191 | currentModelId: models.some((model) => model.id === boundedCurrentModelId) | |
| 192 | ? boundedCurrentModelId | |
| 193 | : null, | |
| 194 | configOptions: acpProviderOptionDescriptors({ configOptions, modeState }), | |
| 195 | }; | |
| 196 | } | |
| 197 | ||
| 198 | const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({ | |
| 199 | slashCommands: [], | |
| 200 | skills: [], | |
| 201 | }); | |
| 202 | ||
| 203 | /** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */ | |
| 204 | export function normalizeAcpRegistryCommands( | |
| 205 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | |
| 206 | ): AcpRegistryAvailableCommands { | |
| 207 | const seen = new Set<string>(); | |
| 208 | const slashCommands: Array<ServerProviderSlashCommand> = []; | |
| 209 | const skills: Array<ServerProviderSkill> = []; | |
| 210 | let accepted = 0; | |
| 211 | for (const command of commands) { | |
| 212 | const name = boundedOpaqueValue(command.name, MAX_ID_LENGTH); | |
| 213 | if (name === undefined) continue; | |
| 214 | const key = name.toLowerCase(); | |
| 215 | if (seen.has(key)) continue; | |
| 216 | seen.add(key); | |
| 217 | const description = boundedText(command.description, MAX_DESCRIPTION_LENGTH); | |
| 218 | const hint = command.input ? boundedText(command.input.hint, MAX_DESCRIPTION_LENGTH) : ""; | |
| 219 | if (name.startsWith("$")) { | |
| 220 | const skillName = boundedOpaqueValue(name.slice(1), MAX_ID_LENGTH); | |
| 221 | if (skillName === undefined) continue; | |
| 222 | skills.push({ | |
| 223 | name: skillName, | |
| 224 | ...(description ? { description } : {}), | |
| 225 | path: `acp://skill/${encodeURIComponent(skillName)}`, | |
| 226 | scope: "agent", | |
| 227 | enabled: true, | |
| 228 | }); | |
| 229 | } else { | |
| 230 | slashCommands.push({ | |
| 231 | name, | |
| 232 | ...(description ? { description } : {}), | |
| 233 | ...(hint ? { input: { hint } } : {}), | |
| 234 | }); | |
| 235 | } | |
| 236 | accepted += 1; | |
| 237 | if (accepted === MAX_COMMANDS) break; | |
| 238 | } | |
| 239 | return { slashCommands, skills }; | |
| 240 | } | |
| 241 | ||
| 242 | /** Builds the bounded wire result from a successfully created disposable ACP session. */ | |
| 243 | export function acpRegistryProbeResult( | |
| 244 | instanceId: ProviderInstanceId, | |
| 245 | started: AcpSessionRuntime.AcpSessionRuntimeStartResult, | |
| 246 | icon: string | null = null, | |
| 247 | spawn?: AcpRegistryAuthSpawnContext, | |
| 248 | ): AcpRegistryProbeResult { | |
| 249 | const liveConfiguration = normalizeAcpRegistryLiveConfiguration( | |
| 250 | started.sessionSetupResult.configOptions ?? [], | |
| 251 | parseSessionModeState(started.sessionSetupResult), | |
| 252 | ); | |
| 253 | return AcpRegistryProbeResult.make({ | |
| 254 | instanceId, | |
| 255 | ready: true, | |
| 256 | icon, | |
| 257 | authMethods: normalizeAcpRegistryAuthMethods(started.initializeResult.authMethods, spawn), | |
| 258 | sessionManagement: { | |
| 259 | canList: started.initializeResult.agentCapabilities?.sessionCapabilities?.list != null, | |
| 260 | canLoad: started.initializeResult.agentCapabilities?.loadSession === true, | |
| 261 | canResume: started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null, | |
| 262 | canLogout: started.initializeResult.agentCapabilities?.auth?.logout != null, | |
| 263 | canDelete: started.initializeResult.agentCapabilities?.sessionCapabilities?.delete != null, | |
| 264 | canConfigureProviders: started.initializeResult.agentCapabilities?.providers != null, | |
| 265 | }, | |
| 266 | ...liveConfiguration, | |
| 267 | }); | |
| 268 | } | |
| 269 | ||
| 270 | export function acpRegistryProbeFailure( | |
| 271 | error: EffectAcpErrors.AcpError, | |
| 272 | authMethods: ReadonlyArray<AcpRegistryProbeAuthMethod> = [], | |
| 273 | authAction?: AcpRegistryUrlAuthAction, | |
| 274 | ): AcpRegistryOperationError { | |
| 275 | const detail = error._tag === "AcpTransportError" && error.detail ? error.detail : error.message; | |
| 276 | const authenticationFailed = | |
| 277 | (error._tag === "AcpRequestError" && error.code === -32000) || | |
| 278 | /\b(?:authenticat(?:e|ed|es|ing|ion)|credentials?|log(?:[\s-]+)?in)\b/iu.test(detail); | |
| 279 | return new AcpRegistryOperationError({ | |
| 280 | reason: authenticationFailed ? "authentication_failed" : "probe_failed", | |
| 281 | message: authenticationFailed | |
| 282 | ? "The ACP agent could not complete authentication." | |
| 283 | : "The ACP agent could not create a test session.", | |
| 284 | cause: error, | |
| 285 | ...(authMethods.length > 0 ? { authMethods } : {}), | |
| 286 | ...(authAction === undefined ? {} : { authAction }), | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | export interface AcpRegistryConfigurationProbeInput { | |
| 291 | readonly instanceId: ProviderInstanceId; | |
| 292 | readonly settings: AcpRegistrySettings; | |
| 293 | readonly cwd: string; | |
| 294 | readonly environment: NodeJS.ProcessEnv; | |
| 295 | } | |
| 296 | ||
| 297 | type AcpRegistryManagementInput = AcpRegistryConfigurationProbeInput; | |
| 298 | ||
| 299 | export interface AcpRegistryConfigurationProbeResult { | |
| 300 | readonly probe: AcpRegistryProbeResult; | |
| 301 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | |
| 302 | readonly skills: ReadonlyArray<ServerProviderSkill>; | |
| 303 | } | |
| 304 | ||
| 305 | export type AcpRegistryConfigurationProbe<Requirements = never> = ( | |
| 306 | input: AcpRegistryConfigurationProbeInput, | |
| 307 | ) => Effect.Effect<AcpRegistryConfigurationProbeResult, AcpRegistryOperationError, Requirements>; | |
| 308 | ||
| 309 | /** Starts and immediately disposes an ACP session for one already-decoded configuration. */ | |
| 310 | export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")( | |
| 311 | function* ( | |
| 312 | input: AcpRegistryConfigurationProbeInput, | |
| 313 | ): Effect.fn.Return< | |
| 314 | AcpRegistryConfigurationProbeResult, | |
| 315 | AcpRegistryOperationError, | |
| 316 | AcpRegistrySupport.AcpRegistryCatalog | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | |
| 317 | > { | |
| 318 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 319 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 320 | const crypto = yield* Crypto.Crypto; | |
| 321 | const pty = yield* Effect.serviceOption(PtyAdapter.PtyAdapter); | |
| 322 | ||
| 323 | const result = yield* Effect.gen(function* () { | |
| 324 | // Resolution may install a missing registry package. Keep it inside the | |
| 325 | // probe deadline so a provider refresh cannot inherit the installer's | |
| 326 | // much longer timeout. | |
| 327 | const resolved = yield* catalog | |
| 328 | .resolve(input.settings, input.cwd, input.environment) | |
| 329 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)); | |
| 330 | const authMethodsRef = yield* Ref.make<ReadonlyArray<AcpRegistryProbeAuthMethod>>([]); | |
| 331 | const authActionRef = yield* Ref.make<AcpRegistryUrlAuthAction | undefined>(undefined); | |
| 332 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 333 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 334 | ); | |
| 335 | const runtimeContext = yield* Layer.build( | |
| 336 | AcpSessionRuntime.layer({ | |
| 337 | spawn: resolved.spawn, | |
| 338 | cwd: input.cwd, | |
| 339 | clientCapabilities: { | |
| 340 | auth: { terminal: Option.isSome(pty) }, | |
| 341 | elicitation: { url: {} }, | |
| 342 | fs: { readTextFile: false, writeTextFile: false }, | |
| 343 | terminal: false, | |
| 344 | }, | |
| 345 | clientInfo: { name: "t3-code-provider-test", version: "0.0.0" }, | |
| 346 | authenticateOnAuthRequired: false, | |
| 347 | onInitialized: (initializeResult) => | |
| 348 | Ref.set( | |
| 349 | authMethodsRef, | |
| 350 | normalizeAcpRegistryAuthMethods(initializeResult.authMethods, { | |
| 351 | command: resolved.spawn.command, | |
| 352 | args: resolved.spawn.args, | |
| 353 | }), | |
| 354 | ), | |
| 355 | ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}), | |
| 356 | }).pipe( | |
| 357 | Layer.provide( | |
| 358 | Layer.mergeAll( | |
| 359 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 360 | Layer.succeed(Crypto.Crypto, crypto), | |
| 361 | ), | |
| 362 | ), | |
| 363 | ), | |
| 364 | ); | |
| 365 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 366 | Effect.provide(runtimeContext), | |
| 367 | ); | |
| 368 | const commandsRef = yield* Ref.make<AcpRegistryAvailableCommands>( | |
| 369 | emptyAcpRegistryAvailableCommands(), | |
| 370 | ); | |
| 371 | const commandsAdvertised = yield* Deferred.make<void>(); | |
| 372 | yield* runtime.handleElicitation((request) => | |
| 373 | Effect.gen(function* () { | |
| 374 | if ( | |
| 375 | request.mode !== "url" || | |
| 376 | !("url" in request) || | |
| 377 | !("elicitationId" in request) || | |
| 378 | typeof request.url !== "string" || | |
| 379 | typeof request.elicitationId !== "string" | |
| 380 | ) { | |
| 381 | return { action: "decline" } as const; | |
| 382 | } | |
| 383 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 384 | const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH); | |
| 385 | if (url === undefined || elicitationId === undefined) { | |
| 386 | return { action: "decline" } as const; | |
| 387 | } | |
| 388 | const action: AcpRegistryUrlAuthAction = { | |
| 389 | elicitationId, | |
| 390 | url, | |
| 391 | message: boundedText(request.message, MAX_DESCRIPTION_LENGTH), | |
| 392 | }; | |
| 393 | yield* Ref.set(authActionRef, action); | |
| 394 | const accepted = yield* Option.match(runtimeCoordinator, { | |
| 395 | onNone: () => Effect.succeed(false), | |
| 396 | onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action), | |
| 397 | }); | |
| 398 | return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const); | |
| 399 | }), | |
| 400 | ); | |
| 401 | yield* runtime.handleSessionUpdate((notification) => { | |
| 402 | const update = notification.update; | |
| 403 | return update.sessionUpdate === "available_commands_update" | |
| 404 | ? Ref.set(commandsRef, normalizeAcpRegistryCommands(update.availableCommands)).pipe( | |
| 405 | Effect.andThen(Deferred.succeed(commandsAdvertised, undefined)), | |
| 406 | Effect.asVoid, | |
| 407 | ) | |
| 408 | : Effect.void; | |
| 409 | }); | |
| 410 | const startResult = yield* Effect.result(runtime.start()); | |
| 411 | if (Result.isFailure(startResult)) { | |
| 412 | return yield* acpRegistryProbeFailure( | |
| 413 | startResult.failure, | |
| 414 | yield* Ref.get(authMethodsRef), | |
| 415 | yield* Ref.get(authActionRef), | |
| 416 | ); | |
| 417 | } | |
| 418 | const started = startResult.success; | |
| 419 | yield* Deferred.await(commandsAdvertised).pipe( | |
| 420 | Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE), | |
| 421 | ); | |
| 422 | return { resolved, started, commands: yield* Ref.get(commandsRef) }; | |
| 423 | }).pipe( | |
| 424 | Effect.scoped, | |
| 425 | Effect.timeoutOrElse({ | |
| 426 | duration: PROBE_TIMEOUT, | |
| 427 | orElse: () => | |
| 428 | Effect.fail( | |
| 429 | new AcpRegistryOperationError({ | |
| 430 | reason: "probe_failed", | |
| 431 | message: `The ACP agent did not resolve and create a test session within ${PROBE_TIMEOUT_SECONDS} seconds. Package installation or agent startup may be slow; this check retries on the next provider refresh.`, | |
| 432 | }), | |
| 433 | ), | |
| 434 | }), | |
| 435 | Effect.mapError((error) => | |
| 436 | error._tag === "AcpRegistryOperationError" ? error : acpRegistryProbeFailure(error), | |
| 437 | ), | |
| 438 | ); | |
| 439 | return { | |
| 440 | probe: acpRegistryProbeResult( | |
| 441 | input.instanceId, | |
| 442 | result.started, | |
| 443 | result.resolved.agent?.icon ?? null, | |
| 444 | { | |
| 445 | command: result.resolved.spawn.command, | |
| 446 | args: result.resolved.spawn.args, | |
| 447 | }, | |
| 448 | ), | |
| 449 | slashCommands: result.commands.slashCommands, | |
| 450 | skills: result.commands.skills, | |
| 451 | }; | |
| 452 | }, | |
| 453 | ); | |
| 454 | ||
| 455 | const MANAGEMENT_TIMEOUT = Duration.seconds(60); | |
| 456 | ||
| 457 | const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")( | |
| 458 | function* (input: AcpRegistryManagementInput) { | |
| 459 | const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; | |
| 460 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 461 | const crypto = yield* Crypto.Crypto; | |
| 462 | const runtimeCoordinator = yield* Effect.serviceOption( | |
| 463 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | |
| 464 | ); | |
| 465 | const resolved = yield* catalog | |
| 466 | .resolve(input.settings, input.cwd, input.environment) | |
| 467 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)); | |
| 468 | const runtimeContext = yield* Layer.build( | |
| 469 | AcpSessionRuntime.layer({ | |
| 470 | spawn: resolved.spawn, | |
| 471 | cwd: input.cwd, | |
| 472 | clientCapabilities: { | |
| 473 | auth: { terminal: false }, | |
| 474 | elicitation: { url: {} }, | |
| 475 | fs: { readTextFile: false, writeTextFile: false }, | |
| 476 | terminal: false, | |
| 477 | }, | |
| 478 | clientInfo: { name: "t3-code-session-manager", version: "0.0.0" }, | |
| 479 | authenticateOnAuthRequired: false, | |
| 480 | ...(input.settings.authMethodId ? { authMethodId: input.settings.authMethodId } : {}), | |
| 481 | }).pipe( | |
| 482 | Layer.provide( | |
| 483 | Layer.mergeAll( | |
| 484 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 485 | Layer.succeed(Crypto.Crypto, crypto), | |
| 486 | ), | |
| 487 | ), | |
| 488 | ), | |
| 489 | ); | |
| 490 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 491 | Effect.provide(runtimeContext), | |
| 492 | ); | |
| 493 | yield* runtime.handleElicitation((request) => | |
| 494 | Effect.gen(function* () { | |
| 495 | if ( | |
| 496 | request.mode !== "url" || | |
| 497 | !("url" in request) || | |
| 498 | !("elicitationId" in request) || | |
| 499 | typeof request.url !== "string" || | |
| 500 | typeof request.elicitationId !== "string" | |
| 501 | ) { | |
| 502 | return { action: "decline" } as const; | |
| 503 | } | |
| 504 | const url = normalizeAcpRegistryWebUrl(request.url); | |
| 505 | const elicitationId = boundedOpaqueValue(request.elicitationId, MAX_ID_LENGTH); | |
| 506 | if (url === undefined || elicitationId === undefined) { | |
| 507 | return { action: "decline" } as const; | |
| 508 | } | |
| 509 | const action: AcpRegistryUrlAuthAction = { | |
| 510 | elicitationId, | |
| 511 | url, | |
| 512 | message: boundedText(request.message, MAX_DESCRIPTION_LENGTH), | |
| 513 | }; | |
| 514 | const accepted = yield* Option.match(runtimeCoordinator, { | |
| 515 | onNone: () => Effect.succeed(false), | |
| 516 | onSome: (coordinator) => coordinator.requestUrlAuthentication(input.instanceId, action), | |
| 517 | }); | |
| 518 | return accepted ? ({ action: "accept" } as const) : ({ action: "decline" } as const); | |
| 519 | }), | |
| 520 | ); | |
| 521 | return runtime; | |
| 522 | }, | |
| 523 | ); | |
| 524 | ||
| 525 | function managementFailure( | |
| 526 | operation: | |
| 527 | | "list" | |
| 528 | | "delete" | |
| 529 | | "providers_list" | |
| 530 | | "providers_set" | |
| 531 | | "providers_disable" | |
| 532 | | "logout", | |
| 533 | error: EffectAcpErrors.AcpError | AcpRegistryOperationError, | |
| 534 | ): AcpRegistryOperationError { | |
| 535 | if (error._tag === "AcpRegistryOperationError") return error; | |
| 536 | if (error._tag === "AcpRequestError" && error.code === -32601) { | |
| 537 | if (operation === "list") { | |
| 538 | return new AcpRegistryOperationError({ | |
| 539 | reason: "session_list_unsupported", | |
| 540 | message: "The ACP agent does not advertise session listing.", | |
| 541 | cause: error, | |
| 542 | }); | |
| 543 | } | |
| 544 | if (operation === "delete") { | |
| 545 | return new AcpRegistryOperationError({ | |
| 546 | reason: "session_delete_unsupported", | |
| 547 | message: "The ACP agent does not advertise session deletion.", | |
| 548 | cause: error, | |
| 549 | }); | |
| 550 | } | |
| 551 | if (operation.startsWith("providers_")) { | |
| 552 | return new AcpRegistryOperationError({ | |
| 553 | reason: "providers_unsupported", | |
| 554 | message: "The ACP agent does not advertise provider configuration.", | |
| 555 | cause: error, | |
| 556 | }); | |
| 557 | } | |
| 558 | return new AcpRegistryOperationError({ | |
| 559 | reason: "logout_unsupported", | |
| 560 | message: "The ACP agent does not advertise logout.", | |
| 561 | cause: error, | |
| 562 | }); | |
| 563 | } | |
| 564 | const classified = acpRegistryProbeFailure(error); | |
| 565 | if (classified.reason === "authentication_failed") return classified; | |
| 566 | const failure = | |
| 567 | operation === "delete" | |
| 568 | ? { reason: "session_delete_failed" as const, message: "Could not delete the ACP session." } | |
| 569 | : operation === "providers_list" | |
| 570 | ? { reason: "providers_list_failed" as const, message: "Could not list ACP providers." } | |
| 571 | : operation === "providers_set" || operation === "providers_disable" | |
| 572 | ? { | |
| 573 | reason: "provider_configuration_failed" as const, | |
| 574 | message: "Could not update the ACP provider configuration.", | |
| 575 | } | |
| 576 | : undefined; | |
| 577 | return failure === undefined | |
| 578 | ? classified | |
| 579 | : new AcpRegistryOperationError({ ...failure, cause: error }); | |
| 580 | } | |
| 581 | ||
| 582 | export const listAcpRegistrySessions = Effect.fn("AcpRegistryProbe.listSessions")( | |
| 583 | function* (input: AcpRegistryManagementInput & { readonly cursor?: string }) { | |
| 584 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 585 | const initialized = yield* runtime.initialize(); | |
| 586 | const listed = yield* runtime.listSessions(input.cursor); | |
| 587 | const sessions = listed.sessions.slice(0, 256).flatMap((session) => { | |
| 588 | const sessionId = boundedOpaqueValue(session.sessionId, 1_024); | |
| 589 | const cwd = boundedOpaqueValue(session.cwd, 4_096); | |
| 590 | if (sessionId === undefined || cwd === undefined) return []; | |
| 591 | return [ | |
| 592 | { | |
| 593 | sessionId, | |
| 594 | cwd, | |
| 595 | additionalDirectories: (session.additionalDirectories ?? []) | |
| 596 | .flatMap((directory) => { | |
| 597 | const normalized = boundedOpaqueValue(directory, 4_096); | |
| 598 | return normalized === undefined ? [] : [normalized]; | |
| 599 | }) | |
| 600 | .slice(0, 32), | |
| 601 | title: | |
| 602 | session.title === undefined || session.title === null | |
| 603 | ? null | |
| 604 | : boundedText(session.title, 1_024) || null, | |
| 605 | updatedAt: | |
| 606 | session.updatedAt === undefined || session.updatedAt === null | |
| 607 | ? null | |
| 608 | : boundedText(session.updatedAt, 128) || null, | |
| 609 | importedThreadId: null, | |
| 610 | }, | |
| 611 | ]; | |
| 612 | }); | |
| 613 | return AcpRegistryListSessionsResult.make({ | |
| 614 | sessions, | |
| 615 | nextCursor: | |
| 616 | listed.nextCursor === undefined || listed.nextCursor === null | |
| 617 | ? null | |
| 618 | : boundedText(listed.nextCursor, 2_048) || null, | |
| 619 | canLoad: initialized.agentCapabilities?.loadSession === true, | |
| 620 | canResume: initialized.agentCapabilities?.sessionCapabilities?.resume != null, | |
| 621 | canDelete: initialized.agentCapabilities?.sessionCapabilities?.delete != null, | |
| 622 | }); | |
| 623 | }, | |
| 624 | (effect) => | |
| 625 | effect.pipe( | |
| 626 | Effect.scoped, | |
| 627 | Effect.timeoutOrElse({ | |
| 628 | duration: MANAGEMENT_TIMEOUT, | |
| 629 | orElse: () => | |
| 630 | Effect.fail( | |
| 631 | new AcpRegistryOperationError({ | |
| 632 | reason: "probe_failed", | |
| 633 | message: "The ACP session list request timed out.", | |
| 634 | }), | |
| 635 | ), | |
| 636 | }), | |
| 637 | Effect.mapError((error) => managementFailure("list", error)), | |
| 638 | ), | |
| 639 | ); | |
| 640 | ||
| 641 | export const deleteAcpRegistrySession = Effect.fn("AcpRegistryProbe.deleteSession")( | |
| 642 | function* (input: AcpRegistryManagementInput & { readonly sessionId: string }) { | |
| 643 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 644 | yield* runtime.deleteSession(input.sessionId); | |
| 645 | }, | |
| 646 | (effect) => | |
| 647 | effect.pipe( | |
| 648 | Effect.scoped, | |
| 649 | Effect.timeoutOrElse({ | |
| 650 | duration: MANAGEMENT_TIMEOUT, | |
| 651 | orElse: () => | |
| 652 | Effect.fail( | |
| 653 | new AcpRegistryOperationError({ | |
| 654 | reason: "session_delete_failed", | |
| 655 | message: "The ACP session delete request timed out.", | |
| 656 | }), | |
| 657 | ), | |
| 658 | }), | |
| 659 | Effect.mapError((error) => managementFailure("delete", error)), | |
| 660 | ), | |
| 661 | ); | |
| 662 | ||
| 663 | export const listAcpRegistryProviders = Effect.fn("AcpRegistryProbe.listProviders")( | |
| 664 | function* (input: AcpRegistryManagementInput) { | |
| 665 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 666 | const listed = yield* runtime.listProviders; | |
| 667 | return AcpRegistryListProvidersResult.make({ | |
| 668 | providers: listed.providers.slice(0, 64).flatMap((provider) => { | |
| 669 | const providerId = boundedOpaqueValue(provider.providerId, 256); | |
| 670 | if (providerId === undefined) return []; | |
| 671 | const supported = provider.supported | |
| 672 | .flatMap((protocol) => { | |
| 673 | const value = boundedOpaqueValue(protocol, 64); | |
| 674 | return value === undefined ? [] : [value]; | |
| 675 | }) | |
| 676 | .slice(0, 16); | |
| 677 | const current = provider.current; | |
| 678 | const currentApiType = | |
| 679 | current == null ? undefined : boundedOpaqueValue(current.apiType, 64); | |
| 680 | const currentBaseUrl = | |
| 681 | current == null ? undefined : normalizeAcpRegistryWebUrl(current.baseUrl); | |
| 682 | return [ | |
| 683 | { | |
| 684 | providerId, | |
| 685 | supported, | |
| 686 | required: provider.required, | |
| 687 | current: | |
| 688 | currentApiType === undefined || currentBaseUrl === undefined | |
| 689 | ? null | |
| 690 | : { apiType: currentApiType, baseUrl: currentBaseUrl }, | |
| 691 | }, | |
| 692 | ]; | |
| 693 | }), | |
| 694 | }); | |
| 695 | }, | |
| 696 | (effect) => | |
| 697 | effect.pipe( | |
| 698 | Effect.scoped, | |
| 699 | Effect.timeoutOrElse({ | |
| 700 | duration: MANAGEMENT_TIMEOUT, | |
| 701 | orElse: () => | |
| 702 | Effect.fail( | |
| 703 | new AcpRegistryOperationError({ | |
| 704 | reason: "providers_list_failed", | |
| 705 | message: "The ACP provider list request timed out.", | |
| 706 | }), | |
| 707 | ), | |
| 708 | }), | |
| 709 | Effect.mapError((error) => managementFailure("providers_list", error)), | |
| 710 | ), | |
| 711 | ); | |
| 712 | ||
| 713 | export const setAcpRegistryProvider = Effect.fn("AcpRegistryProbe.setProvider")( | |
| 714 | function* (input: AcpRegistryManagementInput & EffectAcpSchema.SetProviderRequest) { | |
| 715 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 716 | yield* runtime.setProvider({ | |
| 717 | providerId: input.providerId, | |
| 718 | apiType: input.apiType, | |
| 719 | baseUrl: input.baseUrl, | |
| 720 | ...(input.headers === undefined ? {} : { headers: input.headers }), | |
| 721 | }); | |
| 722 | }, | |
| 723 | (effect) => | |
| 724 | effect.pipe( | |
| 725 | Effect.scoped, | |
| 726 | Effect.timeoutOrElse({ | |
| 727 | duration: MANAGEMENT_TIMEOUT, | |
| 728 | orElse: () => | |
| 729 | Effect.fail( | |
| 730 | new AcpRegistryOperationError({ | |
| 731 | reason: "provider_configuration_failed", | |
| 732 | message: "The ACP provider configuration request timed out.", | |
| 733 | }), | |
| 734 | ), | |
| 735 | }), | |
| 736 | Effect.mapError((error) => managementFailure("providers_set", error)), | |
| 737 | ), | |
| 738 | ); | |
| 739 | ||
| 740 | export const disableAcpRegistryProvider = Effect.fn("AcpRegistryProbe.disableProvider")( | |
| 741 | function* (input: AcpRegistryManagementInput & { readonly providerId: string }) { | |
| 742 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 743 | yield* runtime.disableProvider(input.providerId); | |
| 744 | }, | |
| 745 | (effect) => | |
| 746 | effect.pipe( | |
| 747 | Effect.scoped, | |
| 748 | Effect.timeoutOrElse({ | |
| 749 | duration: MANAGEMENT_TIMEOUT, | |
| 750 | orElse: () => | |
| 751 | Effect.fail( | |
| 752 | new AcpRegistryOperationError({ | |
| 753 | reason: "provider_configuration_failed", | |
| 754 | message: "The ACP provider disable request timed out.", | |
| 755 | }), | |
| 756 | ), | |
| 757 | }), | |
| 758 | Effect.mapError((error) => managementFailure("providers_disable", error)), | |
| 759 | ), | |
| 760 | ); | |
| 761 | ||
| 762 | export const logoutAcpRegistry = Effect.fn("AcpRegistryProbe.logout")( | |
| 763 | function* (input: AcpRegistryManagementInput) { | |
| 764 | const runtime = yield* makeAcpRegistryManagementRuntime(input); | |
| 765 | yield* runtime.logout; | |
| 766 | }, | |
| 767 | (effect) => | |
| 768 | effect.pipe( | |
| 769 | Effect.scoped, | |
| 770 | Effect.timeoutOrElse({ | |
| 771 | duration: MANAGEMENT_TIMEOUT, | |
| 772 | orElse: () => | |
| 773 | Effect.fail( | |
| 774 | new AcpRegistryOperationError({ | |
| 775 | reason: "probe_failed", | |
| 776 | message: "The ACP logout request timed out.", | |
| 777 | }), | |
| 778 | ), | |
| 779 | }), | |
| 780 | Effect.mapError((error) => managementFailure("logout", error)), | |
| 781 | ), | |
| 782 | ); |
packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.npm.fixture.sh created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | #!/bin/sh | |
| 2 | printf '%s\n' "$*" >> "$FAKE_NPM_LOG" | |
| 3 | prefix="${npm_config_prefix:-$FAKE_NPM_PREFIX}" | |
| 4 | if [ "$1" = "root" ] && [ "$2" = "--global" ]; then | |
| 5 | printf '%s\n' "$prefix/lib/node_modules" | |
| 6 | exit 0 | |
| 7 | fi | |
| 8 | if [ "$1" = "prefix" ] && [ "$2" = "--global" ]; then | |
| 9 | printf '%s\n' "$prefix" | |
| 10 | exit 0 | |
| 11 | fi | |
| 12 | if [ "$1" = "install" ] && [ "$2" = "--global" ]; then | |
| 13 | package_root="$prefix/lib/node_modules/@example/acp" | |
| 14 | executable="$prefix/bin/example-agent" | |
| 15 | mkdir -p "$package_root" "$prefix/bin" | |
| 16 | printf '%s' "$FAKE_NPM_MANIFEST" > "$package_root/package.json" | |
| 17 | printf '#!/bin/sh\n' > "$executable" | |
| 18 | chmod 755 "$executable" | |
| 19 | exit 0 | |
| 20 | fi | |
| 21 | exit 64 |
packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.uv.fixture.sh created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | #!/bin/sh | |
| 2 | printf '%s\n' "$*" >> "$FAKE_UV_LOG" | |
| 3 | tool_bin="${UV_TOOL_BIN_DIR:-$FAKE_UV_BIN}" | |
| 4 | executable="$tool_bin/fast-agent" | |
| 5 | printf "tool-dir=%s bin-dir=%s\n" "$UV_TOOL_DIR" "$UV_TOOL_BIN_DIR" >> "$FAKE_UV_LOG" | |
| 6 | if [ "$1" = "tool" ] && [ "$2" = "dir" ] && [ "$3" = "--bin" ]; then | |
| 7 | printf '%s\n' "$tool_bin" | |
| 8 | exit 0 | |
| 9 | fi | |
| 10 | if [ "$1" = "tool" ] && [ "$2" = "list" ]; then | |
| 11 | if [ -x "$executable" ]; then | |
| 12 | printf 'fast-agent-acp v0.10.1\n- fast-agent\n' | |
| 13 | fi | |
| 14 | exit 0 | |
| 15 | fi | |
| 16 | if [ "$1" = "tool" ] && [ "$2" = "install" ] && [ "$3" = "--force" ]; then | |
| 17 | mkdir -p "$tool_bin" | |
| 18 | printf '#!/bin/sh\n' > "$executable" | |
| 19 | chmod 755 "$executable" | |
| 20 | exit 0 | |
| 21 | fi | |
| 22 | exit 64 |
packages/provider-acp-registry/src/settings.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | /** | |
| 2 | * ACP Registry instance settings. Shared by the server driver and the client | |
| 3 | * settings form, so it holds only browser-safe schema code. | |
| 4 | * | |
| 5 | * @module provider-acp-registry/settings | |
| 6 | */ | |
| 7 | import { TrimmedString, makeProviderSettingsSchema } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | ||
| 11 | export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); | |
| 12 | export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; | |
| 13 | ||
| 14 | export const AcpRegistrySettings = makeProviderSettingsSchema( | |
| 15 | { | |
| 16 | source: Schema.Literals(["registry", "local"]).pipe( | |
| 17 | Schema.withDecodingDefault(Effect.succeed("registry")), | |
| 18 | Schema.annotateKey({ | |
| 19 | title: "ACP source", | |
| 20 | providerSettingsForm: { | |
| 21 | control: "select", | |
| 22 | options: [ | |
| 23 | { value: "registry", label: "ACP Registry" }, | |
| 24 | { value: "local", label: "Local command" }, | |
| 25 | ], | |
| 26 | }, | |
| 27 | }), | |
| 28 | ), | |
| 29 | enabled: Schema.Boolean.pipe( | |
| 30 | Schema.withDecodingDefault(Effect.succeed(true)), | |
| 31 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 32 | ), | |
| 33 | agentId: TrimmedString.pipe( | |
| 34 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 35 | Schema.annotateKey({ | |
| 36 | title: "Registry agent ID", | |
| 37 | description: "Agent identifier from the official ACP Registry, for example 'devin'.", | |
| 38 | providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, | |
| 39 | }), | |
| 40 | ), | |
| 41 | commandPath: TrimmedString.pipe( | |
| 42 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 43 | Schema.annotateKey({ | |
| 44 | title: "Executable override", | |
| 45 | description: | |
| 46 | "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", | |
| 47 | providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, | |
| 48 | }), | |
| 49 | ), | |
| 50 | commandArgs: Schema.Array(Schema.String).pipe( | |
| 51 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 52 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 53 | ), | |
| 54 | authMethodId: TrimmedString.pipe( | |
| 55 | Schema.withDecodingDefault(Effect.succeed("")), | |
| 56 | Schema.annotateKey({ | |
| 57 | title: "Authentication method", | |
| 58 | description: | |
| 59 | "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", | |
| 60 | providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, | |
| 61 | }), | |
| 62 | ), | |
| 63 | distribution: AcpRegistryDistributionPreference.pipe( | |
| 64 | Schema.withDecodingDefault(Effect.succeed("auto")), | |
| 65 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 66 | ), | |
| 67 | customModels: Schema.Array(Schema.String).pipe( | |
| 68 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 69 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 70 | ), | |
| 71 | }, | |
| 72 | { | |
| 73 | order: ["source", "agentId", "commandPath", "authMethodId"], | |
| 74 | }, | |
| 75 | ); | |
| 76 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; |
packages/provider-acp-registry/src/testing.ts created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | /** | |
| 2 | * ACP Registry internals the server's replay testkit and ACP adapter tests | |
| 3 | * drive directly. | |
| 4 | * | |
| 5 | * @module provider-acp-registry/testing | |
| 6 | */ | |
| 7 | export { | |
| 8 | ACP_REGISTRY_DEFAULT_INSTANCE_ID, | |
| 9 | ACP_REGISTRY_PROVIDER, | |
| 10 | acpRegistryPromptFailure, | |
| 11 | makeAcpRegistryAdapterV2, | |
| 12 | registerMistralVibeAcpExtensions, | |
| 13 | } from "./server/adapter.ts"; | |
| 14 | export { | |
| 15 | extractDevinSubagentUpdate, | |
| 16 | normalizeDevinSessionUpdate, | |
| 17 | normalizeDevinToolCall, | |
| 18 | } from "./server/devinAcp.ts"; | |
| 19 | export { | |
| 20 | acpRegistryProbeFailure, | |
| 21 | acpRegistryProbeResult, | |
| 22 | listAcpRegistrySessions, | |
| 23 | logoutAcpRegistry, | |
| 24 | normalizeAcpRegistryAuthMethods, | |
| 25 | normalizeAcpRegistryCommands, | |
| 26 | probeAcpRegistryConfiguration, | |
| 27 | type AcpRegistryAvailableCommands, | |
| 28 | type AcpRegistryLiveConfiguration, | |
| 29 | } from "./server/probe.ts"; |
packages/provider-acp-registry/tsconfig.json created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"], | |
| 5 | "lib": ["ESNext", "esnext.disposable"] | |
| 6 | }, | |
| 7 | "include": ["src"] | |
| 8 | } |
packages/provider-acp/src/server/AcpSessionRuntime.ts+1-11| ... | ... | @@ -1329,17 +1329,7 @@ export class AcpSessionRuntime extends Context.Service< |
| 1329 | 1329 | payload: unknown, |
| 1330 | 1330 | ) => Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 1331 | 1331 | } |
| 1332 | >()("@t3tools/provider-acp/server/AcpSessionRuntime") { | |
| 1333 | static layer( | |
| 1334 | options: AcpSessionRuntimeOptions, | |
| 1335 | ): Layer.Layer< | |
| 1336 | AcpSessionRuntime, | |
| 1337 | EffectAcpErrors.AcpError, | |
| 1338 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | |
| 1339 | > { | |
| 1340 | return Layer.effect(AcpSessionRuntime, make(options)); | |
| 1341 | } | |
| 1342 | } | |
| 1332 | >()("@t3tools/provider-acp/server/AcpSessionRuntime") {} | |
| 1343 | 1333 | |
| 1344 | 1334 | interface AcpStartedState extends AcpSessionRuntimeStartResult {} |
| 1345 | 1335 |
packages/provider-core/package.json+4| ... | ... | @@ -99,6 +99,10 @@ |
| 99 | 99 | "types": "./src/server/ProviderAdapter.ts", |
| 100 | 100 | "import": "./src/server/ProviderAdapter.ts" |
| 101 | 101 | }, |
| 102 | "./server/ProviderAuthFlow": { | |
| 103 | "types": "./src/server/ProviderAuthFlow.ts", | |
| 104 | "import": "./src/server/ProviderAuthFlow.ts" | |
| 105 | }, | |
| 102 | 106 | "./server/ProviderEventLoggers": { |
| 103 | 107 | "types": "./src/server/ProviderEventLoggers.ts", |
| 104 | 108 | "import": "./src/server/ProviderEventLoggers.ts" |
packages/provider-core/src/server/ProviderAuthFlow.test.ts created+559| ... | ... | @@ -0,0 +1,559 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | ProviderInstanceId, | |
| 5 | ProviderSetupError, | |
| 6 | type ProviderAuthInteraction, | |
| 7 | type ProviderAuthResponse, | |
| 8 | type ProviderAuthState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Scope from "effect/Scope"; | |
| 13 | import * as Deferred from "effect/Deferred"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as TestClock from "effect/testing/TestClock"; | |
| 18 | ||
| 19 | import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; | |
| 20 | ||
| 21 | const instanceId = ProviderInstanceId.make("auth-flow-test"); | |
| 22 | const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; | |
| 23 | ||
| 24 | it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>(); | |
| 27 | const controller = yield* ProviderAuthFlow.make({ | |
| 28 | instanceId, | |
| 29 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 30 | methods: Deferred.await(discovered), | |
| 31 | authenticate: () => Effect.void, | |
| 32 | logout: Effect.void, | |
| 33 | }); | |
| 34 | const pending = yield* controller | |
| 35 | .subscribe("owner") | |
| 36 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 37 | assert.isUndefined(pending.methods); | |
| 38 | yield* Deferred.succeed(discovered, []); | |
| 39 | const ready = yield* controller.subscribe("owner").pipe( | |
| 40 | Stream.filter((state) => state.methods !== undefined), | |
| 41 | Stream.runHead, | |
| 42 | Effect.map(Option.getOrThrow), | |
| 43 | ); | |
| 44 | assert.deepEqual(ready.methods, []); | |
| 45 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 46 | ); | |
| 47 | ||
| 48 | const makeHarness = Effect.gen(function* () { | |
| 49 | const approved = yield* Deferred.make<void>(); | |
| 50 | const verified = yield* Deferred.make<void>(); | |
| 51 | const started = yield* Deferred.make<void>(); | |
| 52 | let attempts = 0; | |
| 53 | const controller = yield* ProviderAuthFlow.make({ | |
| 54 | instanceId, | |
| 55 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 56 | methods: Effect.succeed([method]), | |
| 57 | authenticate: (_, context) => | |
| 58 | Effect.gen(function* () { | |
| 59 | attempts++; | |
| 60 | yield* context.setInteraction( | |
| 61 | { | |
| 62 | type: "browser", | |
| 63 | id: "consent", | |
| 64 | url: "https://example.com/login", | |
| 65 | requiresConsent: true, | |
| 66 | }, | |
| 67 | (response) => | |
| 68 | response.type === "browser" && response.action === "accept" | |
| 69 | ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) | |
| 70 | : Effect.void, | |
| 71 | ); | |
| 72 | yield* Deferred.succeed(started, undefined); | |
| 73 | yield* Deferred.await(approved); | |
| 74 | yield* context.verifying; | |
| 75 | yield* Deferred.await(verified); | |
| 76 | }), | |
| 77 | logout: Effect.void, | |
| 78 | }); | |
| 79 | const phase = (phase: ProviderAuthState["phase"], owner = "owner") => | |
| 80 | controller.subscribe(owner).pipe( | |
| 81 | Stream.filter((state) => state.phase === phase), | |
| 82 | Stream.runHead, | |
| 83 | Effect.map(Option.getOrThrow), | |
| 84 | ); | |
| 85 | return { controller, phase, started, verified, attempts: () => attempts }; | |
| 86 | }); | |
| 87 | ||
| 88 | it.effect("requires owner consent and provider verification before success", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const { controller, phase, started, verified, attempts } = yield* makeHarness; | |
| 91 | const state = yield* controller.start("owner"); | |
| 92 | yield* Deferred.await(started); | |
| 93 | const response = { | |
| 94 | instanceId, | |
| 95 | flowId: state.flowId!, | |
| 96 | interactionId: "consent", | |
| 97 | response: { type: "browser" as const, action: "accept" as const }, | |
| 98 | }; | |
| 99 | const other = yield* phase("waiting", "other"); | |
| 100 | assert.isNull(other.interaction); | |
| 101 | assert.isNull(other.flowId); | |
| 102 | assert.isTrue( | |
| 103 | (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", | |
| 104 | ); | |
| 105 | assert.isTrue( | |
| 106 | (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( | |
| 107 | Effect.result, | |
| 108 | ))._tag === "Failure", | |
| 109 | ); | |
| 110 | yield* controller.start("owner"); | |
| 111 | assert.strictEqual(attempts(), 1); | |
| 112 | yield* controller.respond!("owner", response); | |
| 113 | yield* phase("verifying"); | |
| 114 | yield* Deferred.succeed(verified, undefined); | |
| 115 | assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); | |
| 116 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("cancellation rejects late consent and permits another login", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const { controller, started, phase } = yield* makeHarness; | |
| 122 | const state = yield* controller.start("owner"); | |
| 123 | yield* Deferred.await(started); | |
| 124 | yield* controller.cancel("owner", state.flowId!); | |
| 125 | assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); | |
| 126 | assert.isTrue( | |
| 127 | (yield* controller.respond!("owner", { | |
| 128 | instanceId, | |
| 129 | flowId: state.flowId!, | |
| 130 | interactionId: "consent", | |
| 131 | response: { type: "browser", action: "accept" }, | |
| 132 | }).pipe(Effect.result))._tag === "Failure", | |
| 133 | ); | |
| 134 | const next = yield* controller.start("other"); | |
| 135 | assert.notStrictEqual(next.flowId, state.flowId); | |
| 136 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 137 | ); | |
| 138 | ||
| 139 | it.effect("expires pending login and rejects its response", () => | |
| 140 | Effect.gen(function* () { | |
| 141 | const { controller, started, phase } = yield* makeHarness; | |
| 142 | const state = yield* controller.start("owner"); | |
| 143 | yield* Deferred.await(started); | |
| 144 | yield* TestClock.adjust(300_001); | |
| 145 | assert.include((yield* phase("failed")).message ?? "", "expired"); | |
| 146 | assert.isTrue( | |
| 147 | (yield* controller.respond!("owner", { | |
| 148 | instanceId, | |
| 149 | flowId: state.flowId!, | |
| 150 | interactionId: "consent", | |
| 151 | response: { type: "browser", action: "accept" }, | |
| 152 | }).pipe(Effect.result))._tag === "Failure", | |
| 153 | ); | |
| 154 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("closes admitted provider processes and blocks new ones while login is pending", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const { controller, started } = yield* makeHarness; | |
| 160 | let closed = false; | |
| 161 | yield* controller.withAccess!( | |
| 162 | Effect.addFinalizer(() => | |
| 163 | Effect.sync(() => { | |
| 164 | closed = true; | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 168 | yield* controller.start("owner"); | |
| 169 | yield* Deferred.await(started); | |
| 170 | assert.isTrue(closed); | |
| 171 | assert.isTrue( | |
| 172 | (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", | |
| 173 | ); | |
| 174 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const { controller } = yield* makeHarness; | |
| 180 | let closed = false; | |
| 181 | yield* controller.withAccess!( | |
| 182 | Effect.addFinalizer(() => | |
| 183 | Effect.sync(() => { | |
| 184 | closed = true; | |
| 185 | }), | |
| 186 | ), | |
| 187 | ); | |
| 188 | yield* controller.invalidate!; | |
| 189 | assert.isTrue(closed); | |
| 190 | assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); | |
| 191 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect.each([ | |
| 195 | { | |
| 196 | interaction: { type: "terminal", id: "terminal", output: "Log in" }, | |
| 197 | response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, | |
| 198 | }, | |
| 199 | { | |
| 200 | interaction: { | |
| 201 | type: "credentials", | |
| 202 | id: "credentials", | |
| 203 | fields: [{ name: "token", label: "Token", secret: true }], | |
| 204 | }, | |
| 205 | response: { type: "credentials", values: { token: "private-token" } }, | |
| 206 | }, | |
| 207 | ] satisfies ReadonlyArray<{ | |
| 208 | interaction: ProviderAuthInteraction; | |
| 209 | response: ProviderAuthResponse; | |
| 210 | }>)( | |
| 211 | "keeps $interaction.type responses private and waits for adapter verification", | |
| 212 | ({ interaction, response }) => | |
| 213 | Effect.gen(function* () { | |
| 214 | const received = yield* Deferred.make<ProviderAuthResponse>(); | |
| 215 | const controller = yield* ProviderAuthFlow.make({ | |
| 216 | instanceId, | |
| 217 | credentialBinding: { owner: "t3", key: "binding" }, | |
| 218 | methods: Effect.succeed([method]), | |
| 219 | authenticate: (_, context) => | |
| 220 | Effect.gen(function* () { | |
| 221 | yield* context.setInteraction(interaction, (response) => | |
| 222 | Deferred.succeed(received, response).pipe(Effect.asVoid), | |
| 223 | ); | |
| 224 | yield* Deferred.await(received); | |
| 225 | yield* context.verifying; | |
| 226 | return yield* Effect.never; | |
| 227 | }), | |
| 228 | logout: Effect.void, | |
| 229 | }); | |
| 230 | const state = yield* controller.start("owner"); | |
| 231 | const pending = yield* controller.subscribe("owner").pipe( | |
| 232 | Stream.filter((state) => state.phase === "waiting"), | |
| 233 | Stream.runHead, | |
| 234 | Effect.map(Option.getOrThrow), | |
| 235 | ); | |
| 236 | assert.deepStrictEqual(pending.interaction, interaction); | |
| 237 | const hidden = yield* controller | |
| 238 | .subscribe("other") | |
| 239 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 240 | assert.isNull(hidden.interaction); | |
| 241 | yield* controller.respond!("owner", { | |
| 242 | instanceId, | |
| 243 | flowId: state.flowId!, | |
| 244 | interactionId: interaction.id, | |
| 245 | response, | |
| 246 | }); | |
| 247 | assert.deepStrictEqual(yield* Deferred.await(received), response); | |
| 248 | const verifying = yield* controller.subscribe("owner").pipe( | |
| 249 | Stream.filter((state) => state.phase === "verifying"), | |
| 250 | Stream.runHead, | |
| 251 | Effect.map(Option.getOrThrow), | |
| 252 | ); | |
| 253 | assert.isNull(verifying.interaction); | |
| 254 | assert.notInclude(verifying.message ?? "", "private-token"); | |
| 255 | yield* controller.cancel("owner", state.flowId!); | |
| 256 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 257 | ); | |
| 258 | ||
| 259 | it.effect("shows a device code only to its owner without treating it as authenticated", () => | |
| 260 | Effect.gen(function* () { | |
| 261 | const controller = yield* ProviderAuthFlow.make({ | |
| 262 | instanceId, | |
| 263 | credentialBinding: { owner: "provider", key: "device" }, | |
| 264 | methods: Effect.succeed([method]), | |
| 265 | authenticate: (_, context) => | |
| 266 | context | |
| 267 | .setInteraction({ | |
| 268 | type: "deviceCode", | |
| 269 | id: "code", | |
| 270 | url: "https://example.com/device", | |
| 271 | userCode: "ABCD-EFGH", | |
| 272 | }) | |
| 273 | .pipe(Effect.andThen(Effect.never)), | |
| 274 | logout: Effect.void, | |
| 275 | }); | |
| 276 | const start = yield* controller.start("owner"); | |
| 277 | const waiting = yield* controller.subscribe("owner").pipe( | |
| 278 | Stream.filter((state) => state.phase === "waiting"), | |
| 279 | Stream.runHead, | |
| 280 | Effect.map(Option.getOrThrow), | |
| 281 | ); | |
| 282 | assert.strictEqual(waiting.interaction?.type, "deviceCode"); | |
| 283 | assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); | |
| 284 | const other = yield* controller | |
| 285 | .subscribe("other") | |
| 286 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 287 | assert.isNull(other.interaction); | |
| 288 | assert.isNull(other.authorizationUrl); | |
| 289 | yield* controller.cancel("owner", start.flowId!); | |
| 290 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect.each([ | |
| 294 | { | |
| 295 | failure: Effect.fail( | |
| 296 | new ProviderSetupError({ | |
| 297 | instanceId, | |
| 298 | operation: "authenticate", | |
| 299 | detail: "This account cannot sign in. Choose another account.", | |
| 300 | }), | |
| 301 | ), | |
| 302 | message: "This account cannot sign in. Choose another account.", | |
| 303 | }, | |
| 304 | { | |
| 305 | failure: Effect.die(new Error("private-token in native diagnostics")), | |
| 306 | message: "Sign-in failed. Start again.", | |
| 307 | }, | |
| 308 | ])("publishes only safe authentication failure text %#", ({ failure, message }) => | |
| 309 | Effect.gen(function* () { | |
| 310 | const controller = yield* ProviderAuthFlow.make({ | |
| 311 | instanceId, | |
| 312 | credentialBinding: { owner: "t3", key: "failure" }, | |
| 313 | methods: Effect.succeed([method]), | |
| 314 | authenticate: () => failure, | |
| 315 | logout: Effect.void, | |
| 316 | }); | |
| 317 | yield* controller.start("owner"); | |
| 318 | const failed = yield* controller.subscribe("owner").pipe( | |
| 319 | Stream.filter((state) => state.phase === "failed"), | |
| 320 | Stream.runHead, | |
| 321 | Effect.map(Option.getOrThrow), | |
| 322 | ); | |
| 323 | assert.strictEqual(failed.message, message); | |
| 324 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 325 | ); | |
| 326 | ||
| 327 | const makeBlockingResponseHarness = Effect.gen(function* () { | |
| 328 | const entered = yield* Deferred.make<void>(); | |
| 329 | const cleanupStarted = yield* Deferred.make<void>(); | |
| 330 | const cleanupReleased = yield* Deferred.make<void>(); | |
| 331 | const cleanupFinished = yield* Deferred.make<void>(); | |
| 332 | const authenticationFinished = yield* Deferred.make<void>(); | |
| 333 | let responses = 0; | |
| 334 | const controller = yield* ProviderAuthFlow.make({ | |
| 335 | instanceId, | |
| 336 | credentialBinding: { owner: "t3", key: "blocked-response" }, | |
| 337 | methods: Effect.succeed([method]), | |
| 338 | authenticate: (_, context) => | |
| 339 | Effect.gen(function* () { | |
| 340 | yield* context.setInteraction( | |
| 341 | { | |
| 342 | type: "browser", | |
| 343 | id: "blocked", | |
| 344 | url: "https://example.com/login", | |
| 345 | requiresConsent: true, | |
| 346 | }, | |
| 347 | () => | |
| 348 | Effect.gen(function* () { | |
| 349 | responses++; | |
| 350 | yield* Deferred.succeed(entered, undefined); | |
| 351 | return yield* Effect.never; | |
| 352 | }).pipe( | |
| 353 | Effect.ensuring( | |
| 354 | Effect.gen(function* () { | |
| 355 | yield* Deferred.succeed(cleanupStarted, undefined); | |
| 356 | yield* Deferred.await(cleanupReleased); | |
| 357 | yield* Deferred.succeed(cleanupFinished, undefined); | |
| 358 | }), | |
| 359 | ), | |
| 360 | ), | |
| 361 | ); | |
| 362 | yield* Deferred.await(authenticationFinished); | |
| 363 | }), | |
| 364 | logout: Effect.void, | |
| 365 | }); | |
| 366 | const start = yield* controller.start("owner"); | |
| 367 | yield* controller.subscribe("owner").pipe( | |
| 368 | Stream.filter((state) => state.phase === "waiting"), | |
| 369 | Stream.runHead, | |
| 370 | ); | |
| 371 | const input = { | |
| 372 | instanceId, | |
| 373 | flowId: start.flowId!, | |
| 374 | interactionId: "blocked", | |
| 375 | response: { type: "browser" as const, action: "accept" as const }, | |
| 376 | }; | |
| 377 | const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); | |
| 378 | yield* Deferred.await(entered); | |
| 379 | return { | |
| 380 | controller, | |
| 381 | input, | |
| 382 | response, | |
| 383 | cleanupStarted, | |
| 384 | cleanupReleased, | |
| 385 | cleanupFinished, | |
| 386 | authenticationFinished, | |
| 387 | responses: () => responses, | |
| 388 | }; | |
| 389 | }); | |
| 390 | ||
| 391 | it.effect.each(["cancel", "logout"] as const)( | |
| 392 | "%s interrupts and awaits a blocked adapter response before admitting another sign-in", | |
| 393 | (action) => | |
| 394 | Effect.gen(function* () { | |
| 395 | const harness = yield* makeBlockingResponseHarness; | |
| 396 | const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); | |
| 397 | assert.include(duplicate.detail, "already in progress"); | |
| 398 | assert.equal(harness.responses(), 1); | |
| 399 | const stopping = yield* ( | |
| 400 | action === "cancel" | |
| 401 | ? harness.controller.cancel("owner", harness.input.flowId) | |
| 402 | : harness.controller.logout(Effect.void) | |
| 403 | ).pipe(Effect.forkChild); | |
| 404 | yield* Deferred.await(harness.cleanupStarted); | |
| 405 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 406 | assert.isUndefined(stopping.pollUnsafe()); | |
| 407 | const premature = yield* Effect.flip(harness.controller.start("other")); | |
| 408 | assert.include(premature.detail, "in progress"); | |
| 409 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 410 | yield* Fiber.join(stopping); | |
| 411 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 412 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 413 | assert.isFalse(yield* harness.controller.isChangingCredentials!); | |
| 414 | assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); | |
| 415 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 416 | ); | |
| 417 | ||
| 418 | it.effect("timeout interrupts and drains a blocked response before publishing failure", () => | |
| 419 | Effect.gen(function* () { | |
| 420 | const harness = yield* makeBlockingResponseHarness; | |
| 421 | yield* TestClock.adjust(300_001); | |
| 422 | yield* Deferred.await(harness.cleanupStarted); | |
| 423 | assert.isTrue(yield* harness.controller.isChangingCredentials!); | |
| 424 | const duringCleanup = yield* harness.controller | |
| 425 | .subscribe("owner") | |
| 426 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 427 | assert.notEqual(duringCleanup.phase, "failed"); | |
| 428 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 429 | const failed = yield* harness.controller.subscribe("owner").pipe( | |
| 430 | Stream.filter((state) => state.phase === "failed"), | |
| 431 | Stream.runHead, | |
| 432 | Effect.map(Option.getOrThrow), | |
| 433 | ); | |
| 434 | assert.include(failed.message ?? "", "expired"); | |
| 435 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 436 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 437 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 438 | ); | |
| 439 | ||
| 440 | it.effect( | |
| 441 | "successful authentication drains a still-running response before admitting provider access", | |
| 442 | () => | |
| 443 | Effect.gen(function* () { | |
| 444 | const harness = yield* makeBlockingResponseHarness; | |
| 445 | yield* Deferred.succeed(harness.authenticationFinished, undefined); | |
| 446 | yield* Deferred.await(harness.cleanupStarted); | |
| 447 | const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); | |
| 448 | assert.include(denied.detail, "changing"); | |
| 449 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 450 | const succeeded = yield* harness.controller.subscribe("owner").pipe( | |
| 451 | Stream.filter((state) => state.phase === "succeeded"), | |
| 452 | Stream.runHead, | |
| 453 | Effect.map(Option.getOrThrow), | |
| 454 | ); | |
| 455 | assert.equal(succeeded.phase, "succeeded"); | |
| 456 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 457 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 458 | yield* harness.controller.withAccess!(Effect.void); | |
| 459 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 460 | ); | |
| 461 | ||
| 462 | it.effect("closing the controller scope interrupts and drains its adapter response", () => | |
| 463 | Effect.gen(function* () { | |
| 464 | const scope = yield* Scope.make(); | |
| 465 | const harness = yield* makeBlockingResponseHarness.pipe( | |
| 466 | Effect.provideService(Scope.Scope, scope), | |
| 467 | ); | |
| 468 | const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); | |
| 469 | yield* Deferred.await(harness.cleanupStarted); | |
| 470 | assert.isUndefined(closing.pollUnsafe()); | |
| 471 | yield* Deferred.succeed(harness.cleanupReleased, undefined); | |
| 472 | yield* Fiber.join(closing); | |
| 473 | assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); | |
| 474 | assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); | |
| 475 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 476 | ); | |
| 477 | ||
| 478 | it.effect("a failed method refresh keeps the last discovered methods", () => | |
| 479 | Effect.gen(function* () { | |
| 480 | let fail = false; | |
| 481 | const controller = yield* ProviderAuthFlow.make({ | |
| 482 | instanceId, | |
| 483 | credentialBinding: { owner: "provider", key: "shared-agent" }, | |
| 484 | methods: Effect.suspend(() => | |
| 485 | fail | |
| 486 | ? Effect.fail( | |
| 487 | new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), | |
| 488 | ) | |
| 489 | : Effect.succeed([method]), | |
| 490 | ), | |
| 491 | authenticate: () => Effect.void, | |
| 492 | logout: Effect.void, | |
| 493 | }); | |
| 494 | yield* controller.refreshMethods!; | |
| 495 | fail = true; | |
| 496 | yield* controller.refreshMethods!; | |
| 497 | const state = yield* controller | |
| 498 | .subscribe("owner") | |
| 499 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); | |
| 500 | assert.deepEqual(state.methods, [method]); | |
| 501 | assert.strictEqual(state.message, "interrupted"); | |
| 502 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const scope = yield* Scope.make(); | |
| 508 | const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); | |
| 509 | let closed = false; | |
| 510 | yield* controller.withAccess!( | |
| 511 | Effect.addFinalizer(() => | |
| 512 | Effect.sync(() => { | |
| 513 | closed = true; | |
| 514 | }), | |
| 515 | ), | |
| 516 | ); | |
| 517 | yield* Scope.close(scope, Exit.void); | |
| 518 | assert.isFalse(closed); | |
| 519 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 520 | ); | |
| 521 | ||
| 522 | it.effect("profile import stops owned sessions and gates access until credentials are saved", () => | |
| 523 | Effect.gen(function* () { | |
| 524 | const controller = yield* ProviderAuthFlow.make({ | |
| 525 | instanceId, | |
| 526 | credentialBinding: { owner: "t3", key: "handoff-binding" }, | |
| 527 | methods: Effect.succeed([method]), | |
| 528 | authenticate: () => Effect.void, | |
| 529 | logout: Effect.void, | |
| 530 | }); | |
| 531 | let closed = false; | |
| 532 | yield* controller.withAccess!( | |
| 533 | Effect.addFinalizer(() => | |
| 534 | Effect.sync(() => { | |
| 535 | closed = true; | |
| 536 | }), | |
| 537 | ), | |
| 538 | ); | |
| 539 | const writing = yield* Deferred.make<void>(); | |
| 540 | const release = yield* Deferred.make<void>(); | |
| 541 | const imported = yield* controller.adoptCredentials!( | |
| 542 | Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))), | |
| 543 | Effect.sync(() => { | |
| 544 | assert.isTrue(closed); | |
| 545 | }), | |
| 546 | ).pipe(Effect.forkChild); | |
| 547 | yield* Deferred.await(writing); | |
| 548 | const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe( | |
| 549 | Effect.result, | |
| 550 | ); | |
| 551 | assert.strictEqual(denied._tag, "Failure"); | |
| 552 | yield* Deferred.succeed(release, undefined); | |
| 553 | assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded"); | |
| 554 | assert.strictEqual( | |
| 555 | yield* controller.withAccess!(Effect.succeed("new credential process")), | |
| 556 | "new credential process", | |
| 557 | ); | |
| 558 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 559 | ); |
packages/provider-core/src/server/ProviderAuthFlow.ts created+525| ... | ... | @@ -0,0 +1,525 @@ |
| 1 | import { | |
| 2 | ProviderSetupError, | |
| 3 | type ProviderAuthInteraction, | |
| 4 | type ProviderAuthMethod, | |
| 5 | type ProviderAuthResponse, | |
| 6 | type ProviderAuthState, | |
| 7 | type ProviderInstanceId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Cause from "effect/Cause"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Clock from "effect/Clock"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as DateTime from "effect/DateTime"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Deferred from "effect/Deferred"; | |
| 17 | import * as Exit from "effect/Exit"; | |
| 18 | import * as Fiber from "effect/Fiber"; | |
| 19 | import * as Scope from "effect/Scope"; | |
| 20 | import * as Semaphore from "effect/Semaphore"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 23 | ||
| 24 | import type * as ProviderAuth from "./auth.ts"; | |
| 25 | ||
| 26 | export interface ProviderAuthFlowContext { | |
| 27 | readonly flowId: string; | |
| 28 | /** The lifetime timeout interrupts authenticate before publishing its expired result. */ | |
| 29 | readonly expiresAt: number; | |
| 30 | readonly returnUrl?: string; | |
| 31 | readonly callbackMode?: "server" | "client"; | |
| 32 | readonly setInteraction: ( | |
| 33 | interaction: ProviderAuthInteraction, | |
| 34 | respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>, | |
| 35 | complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>, | |
| 36 | ) => Effect.Effect<void>; | |
| 37 | readonly verifying: Effect.Effect<void>; | |
| 38 | } | |
| 39 | ||
| 40 | const isSetupError = Schema.is(ProviderSetupError); | |
| 41 | ||
| 42 | const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => { | |
| 43 | const error = Cause.findErrorOption(cause); | |
| 44 | return Option.isSome(error) && isSetupError(error.value) | |
| 45 | ? error.value.detail | |
| 46 | : "Sign-in failed. Start again."; | |
| 47 | }; | |
| 48 | ||
| 49 | interface Flow { | |
| 50 | readonly id: string; | |
| 51 | readonly owner: string; | |
| 52 | readonly expiresAt: number; | |
| 53 | complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined; | |
| 54 | fiber?: Fiber.Fiber<void>; | |
| 55 | responseFiber?: Fiber.Fiber<void, ProviderSetupError>; | |
| 56 | respond: | |
| 57 | | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>) | |
| 58 | | undefined; | |
| 59 | } | |
| 60 | ||
| 61 | /** Adapters do login and credential handling; this owns client consent and flow lifetime. */ | |
| 62 | export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { | |
| 63 | readonly instanceId: ProviderInstanceId; | |
| 64 | readonly credentialBinding: NonNullable<ProviderAuth.ProviderAuthController["credentialBinding"]>; | |
| 65 | readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>; | |
| 66 | readonly defaultMethodId?: string; | |
| 67 | /** Stored account profiles can change the advertised methods after auth/logout. */ | |
| 68 | readonly refreshMethodsAfterAuth?: boolean; | |
| 69 | /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ | |
| 70 | readonly authenticate: ( | |
| 71 | methodId: string, | |
| 72 | context: ProviderAuthFlowContext, | |
| 73 | ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>; | |
| 74 | readonly logout: Effect.Effect<void | string, ProviderSetupError>; | |
| 75 | readonly timeoutMs?: number; | |
| 76 | }) { | |
| 77 | const scope = yield* Scope.Scope; | |
| 78 | const crypto = yield* Crypto.Crypto; | |
| 79 | const lock = yield* Semaphore.make(1); | |
| 80 | const timeoutMs = options.timeoutMs ?? 300_000; | |
| 81 | const empty: ProviderAuthState = { | |
| 82 | instanceId: options.instanceId, | |
| 83 | phase: "idle", | |
| 84 | flowId: null, | |
| 85 | authorizationUrl: null, | |
| 86 | expiresAt: null, | |
| 87 | message: null, | |
| 88 | interaction: null, | |
| 89 | credentialOwner: options.credentialBinding.owner, | |
| 90 | }; | |
| 91 | const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); | |
| 92 | let active: Flow | undefined; | |
| 93 | let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; | |
| 94 | const sessions = new Set<Scope.Closeable>(); | |
| 95 | const stopOwnedSessions = Effect.suspend(() => | |
| 96 | Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { | |
| 97 | discard: true, | |
| 98 | concurrency: "unbounded", | |
| 99 | }), | |
| 100 | ); | |
| 101 | const publish = (flow: Flow, patch: Partial<ProviderAuthState>) => | |
| 102 | Effect.suspend(() => | |
| 103 | active === flow | |
| 104 | ? SubscriptionRef.update(snapshot, (current) => ({ | |
| 105 | owner: flow.owner, | |
| 106 | state: { ...current.state, ...patch }, | |
| 107 | })) | |
| 108 | : Effect.void, | |
| 109 | ); | |
| 110 | ||
| 111 | const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { | |
| 112 | if ( | |
| 113 | operation !== "auth" || | |
| 114 | !active || | |
| 115 | active.owner !== owner || | |
| 116 | active.id !== id || | |
| 117 | (yield* Clock.currentTimeMillis) >= active.expiresAt | |
| 118 | ) { | |
| 119 | return yield* new ProviderSetupError({ | |
| 120 | instanceId: options.instanceId, | |
| 121 | operation: "respond", | |
| 122 | detail: "This sign-in is no longer active in this client.", | |
| 123 | }); | |
| 124 | } | |
| 125 | return active; | |
| 126 | }); | |
| 127 | ||
| 128 | // Method discovery does not start a sign-in flow. | |
| 129 | const refreshMethods = options.methods.pipe( | |
| 130 | Effect.flatMap((methods) => | |
| 131 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 132 | ...current, | |
| 133 | state: { ...current.state, methods }, | |
| 134 | })), | |
| 135 | ), | |
| 136 | // Keep the last known methods when a refresh fails or is interrupted. | |
| 137 | Effect.catch((error) => | |
| 138 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 139 | ...current, | |
| 140 | state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, | |
| 141 | })), | |
| 142 | ), | |
| 143 | ); | |
| 144 | yield* refreshMethods.pipe(Effect.forkIn(scope)); | |
| 145 | ||
| 146 | const controller: ProviderAuth.ProviderAuthController = { | |
| 147 | credentialBinding: options.credentialBinding, | |
| 148 | adoptCredentials: (update, stopSessions) => | |
| 149 | Effect.gen(function* () { | |
| 150 | yield* lock.withPermit( | |
| 151 | Effect.gen(function* () { | |
| 152 | if (operation !== "idle") | |
| 153 | return yield* new ProviderSetupError({ | |
| 154 | instanceId: options.instanceId, | |
| 155 | operation: "import", | |
| 156 | detail: "Finish or cancel the existing sign-in first.", | |
| 157 | }); | |
| 158 | operation = "stopping"; | |
| 159 | }), | |
| 160 | ); | |
| 161 | const result = yield* Effect.gen(function* () { | |
| 162 | yield* stopOwnedSessions; | |
| 163 | yield* stopSessions; | |
| 164 | yield* update; | |
| 165 | yield* refreshMethods; | |
| 166 | }).pipe(Effect.exit); | |
| 167 | const state: ProviderAuthState = { | |
| 168 | ...empty, | |
| 169 | methods: snapshot.value.state.methods ?? [], | |
| 170 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 171 | message: Exit.isSuccess(result) ? null : failureMessage(result.cause), | |
| 172 | }; | |
| 173 | yield* lock.withPermit( | |
| 174 | Effect.gen(function* () { | |
| 175 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 176 | operation = "idle"; | |
| 177 | }), | |
| 178 | ); | |
| 179 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 180 | return state; | |
| 181 | }).pipe(Effect.uninterruptible), | |
| 182 | refreshMethods, | |
| 183 | invalidate: lock.withPermit( | |
| 184 | Effect.gen(function* () { | |
| 185 | if (operation !== "idle") return; | |
| 186 | yield* stopOwnedSessions; | |
| 187 | yield* SubscriptionRef.set(snapshot, { | |
| 188 | owner: null, | |
| 189 | state: { | |
| 190 | ...empty, | |
| 191 | methods: snapshot.value.state.methods ?? [], | |
| 192 | message: "This provider's shared sign-in changed.", | |
| 193 | }, | |
| 194 | }); | |
| 195 | }), | |
| 196 | ), | |
| 197 | isChangingCredentials: Effect.sync(() => operation !== "idle"), | |
| 198 | withAccess: (task) => | |
| 199 | Effect.uninterruptibleMask((restore) => | |
| 200 | Effect.gen(function* () { | |
| 201 | const parent = yield* Scope.Scope; | |
| 202 | const child = yield* lock.withPermit( | |
| 203 | Effect.gen(function* () { | |
| 204 | if (operation !== "idle") | |
| 205 | return yield* new ProviderSetupError({ | |
| 206 | instanceId: options.instanceId, | |
| 207 | operation: "session", | |
| 208 | detail: "Provider sign-in is changing. Try again after it finishes.", | |
| 209 | }); | |
| 210 | const child = yield* Scope.make(); | |
| 211 | sessions.add(child); | |
| 212 | yield* Scope.addFinalizer( | |
| 213 | child, | |
| 214 | Effect.sync(() => { | |
| 215 | sessions.delete(child); | |
| 216 | }), | |
| 217 | ); | |
| 218 | yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); | |
| 219 | return child; | |
| 220 | }), | |
| 221 | ); | |
| 222 | const fiber = yield* restore(task).pipe( | |
| 223 | Effect.provideService(Scope.Scope, child), | |
| 224 | Effect.forkIn(child), | |
| 225 | ); | |
| 226 | return yield* restore(Fiber.await(fiber)).pipe( | |
| 227 | Effect.flatMap((result) => result), | |
| 228 | Effect.onExit((result) => | |
| 229 | Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, | |
| 230 | ), | |
| 231 | ); | |
| 232 | }), | |
| 233 | ), | |
| 234 | start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) => | |
| 235 | lock.withPermit( | |
| 236 | Effect.gen(function* () { | |
| 237 | if (operation === "auth" && active?.owner === owner) return snapshot.value.state; | |
| 238 | if (operation !== "idle") | |
| 239 | return yield* new ProviderSetupError({ | |
| 240 | instanceId: options.instanceId, | |
| 241 | operation: "start", | |
| 242 | detail: "Provider setup is already in progress.", | |
| 243 | }); | |
| 244 | const id = yield* crypto.randomUUIDv4.pipe( | |
| 245 | Effect.mapError( | |
| 246 | () => | |
| 247 | new ProviderSetupError({ | |
| 248 | instanceId: options.instanceId, | |
| 249 | operation: "start", | |
| 250 | detail: "Could not start sign-in. Try again.", | |
| 251 | }), | |
| 252 | ), | |
| 253 | ); | |
| 254 | const flow: Flow = { | |
| 255 | id, | |
| 256 | owner, | |
| 257 | expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, | |
| 258 | respond: undefined, | |
| 259 | }; | |
| 260 | active = flow; | |
| 261 | operation = "auth"; | |
| 262 | const state: ProviderAuthState = { | |
| 263 | ...empty, | |
| 264 | methods: snapshot.value.state.methods ?? [], | |
| 265 | phase: "starting", | |
| 266 | flowId: id, | |
| 267 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), | |
| 268 | message: "Starting sign-in.", | |
| 269 | }; | |
| 270 | yield* SubscriptionRef.set(snapshot, { owner, state }); | |
| 271 | flow.fiber = yield* Effect.gen(function* () { | |
| 272 | const methods = yield* options.methods; | |
| 273 | yield* publish(flow, { methods }); | |
| 274 | const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; | |
| 275 | if (!methodId || !methods.some((method) => method.id === methodId)) | |
| 276 | return yield* new ProviderSetupError({ | |
| 277 | instanceId: options.instanceId, | |
| 278 | operation: "start", | |
| 279 | detail: "The provider did not advertise this sign-in method.", | |
| 280 | }); | |
| 281 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 282 | yield* options.authenticate(methodId, { | |
| 283 | flowId: id, | |
| 284 | expiresAt: flow.expiresAt, | |
| 285 | ...(returnUrl ? { returnUrl } : {}), | |
| 286 | ...(callbackMode ? { callbackMode } : {}), | |
| 287 | setInteraction: (interaction, respond, complete) => | |
| 288 | Effect.gen(function* () { | |
| 289 | if (active !== flow) return; | |
| 290 | flow.respond = respond; | |
| 291 | flow.complete = complete; | |
| 292 | yield* publish(flow, { | |
| 293 | phase: "waiting", | |
| 294 | interaction, | |
| 295 | authorizationUrl: | |
| 296 | interaction.type === "browser" || interaction.type === "deviceCode" | |
| 297 | ? interaction.url | |
| 298 | : null, | |
| 299 | message: "Complete sign-in to continue.", | |
| 300 | }); | |
| 301 | }), | |
| 302 | verifying: Effect.gen(function* () { | |
| 303 | flow.respond = undefined; | |
| 304 | flow.complete = undefined; | |
| 305 | yield* publish(flow, { | |
| 306 | phase: "verifying", | |
| 307 | interaction: null, | |
| 308 | authorizationUrl: null, | |
| 309 | message: "Checking provider sign-in.", | |
| 310 | }); | |
| 311 | }), | |
| 312 | }); | |
| 313 | }).pipe( | |
| 314 | Effect.scoped, | |
| 315 | Effect.timeoutOrElse({ | |
| 316 | duration: timeoutMs, | |
| 317 | orElse: () => | |
| 318 | Effect.fail( | |
| 319 | new ProviderSetupError({ | |
| 320 | instanceId: options.instanceId, | |
| 321 | operation: "start", | |
| 322 | detail: "Sign-in expired. Start again.", | |
| 323 | }), | |
| 324 | ), | |
| 325 | }), | |
| 326 | Effect.exit, | |
| 327 | Effect.flatMap((result) => | |
| 328 | Effect.gen(function* () { | |
| 329 | const finishing = yield* lock.withPermit( | |
| 330 | Effect.sync(() => { | |
| 331 | if (active !== flow) return false; | |
| 332 | operation = "stopping"; | |
| 333 | return true; | |
| 334 | }), | |
| 335 | ); | |
| 336 | if (!finishing) return; | |
| 337 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 338 | yield* lock.withPermit( | |
| 339 | Effect.gen(function* () { | |
| 340 | if (active !== flow) return; | |
| 341 | if (options.refreshMethodsAfterAuth) yield* refreshMethods; | |
| 342 | yield* publish(flow, { | |
| 343 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 344 | interaction: null, | |
| 345 | authorizationUrl: null, | |
| 346 | expiresAt: null, | |
| 347 | message: Exit.isSuccess(result) | |
| 348 | ? "Sign-in complete." | |
| 349 | : failureMessage(result.cause), | |
| 350 | }); | |
| 351 | active = undefined; | |
| 352 | operation = "idle"; | |
| 353 | }), | |
| 354 | ); | |
| 355 | }), | |
| 356 | ), | |
| 357 | Effect.interruptible, | |
| 358 | Effect.forkIn(scope), | |
| 359 | ); | |
| 360 | return state; | |
| 361 | }).pipe(Effect.uninterruptible), | |
| 362 | ), | |
| 363 | respond: (owner, input) => | |
| 364 | Effect.uninterruptibleMask((restore) => | |
| 365 | Effect.gen(function* () { | |
| 366 | const { flow, fiber, ready } = yield* lock.withPermit( | |
| 367 | Effect.gen(function* () { | |
| 368 | const flow = yield* requireFlow(owner, input.flowId); | |
| 369 | const interaction = snapshot.value.state.interaction; | |
| 370 | if ( | |
| 371 | !interaction || | |
| 372 | interaction.id !== input.interactionId || | |
| 373 | interaction.type !== input.response.type || | |
| 374 | !flow.respond | |
| 375 | ) | |
| 376 | return yield* new ProviderSetupError({ | |
| 377 | instanceId: options.instanceId, | |
| 378 | operation: "respond", | |
| 379 | detail: "This sign-in interaction is no longer available.", | |
| 380 | }); | |
| 381 | if (flow.responseFiber) | |
| 382 | return yield* new ProviderSetupError({ | |
| 383 | instanceId: options.instanceId, | |
| 384 | operation: "respond", | |
| 385 | detail: "A sign-in response is already in progress.", | |
| 386 | }); | |
| 387 | const ready = yield* Deferred.make<void>(); | |
| 388 | const callback = flow.respond; | |
| 389 | const fiber = yield* Deferred.await(ready).pipe( | |
| 390 | Effect.andThen(Effect.suspend(() => callback(input.response))), | |
| 391 | Effect.interruptible, | |
| 392 | Effect.forkIn(scope), | |
| 393 | ); | |
| 394 | flow.responseFiber = fiber; | |
| 395 | return { flow, fiber, ready }; | |
| 396 | }), | |
| 397 | ); | |
| 398 | yield* Deferred.succeed(ready, undefined); | |
| 399 | yield* restore(Fiber.join(fiber)).pipe( | |
| 400 | Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), | |
| 401 | Effect.ensuring( | |
| 402 | lock.withPermit( | |
| 403 | Effect.sync(() => { | |
| 404 | if (flow.responseFiber === fiber) delete flow.responseFiber; | |
| 405 | }), | |
| 406 | ), | |
| 407 | ), | |
| 408 | ); | |
| 409 | return snapshot.value.state; | |
| 410 | }), | |
| 411 | ), | |
| 412 | complete: (owner, input) => | |
| 413 | lock.withPermit( | |
| 414 | Effect.gen(function* () { | |
| 415 | const flow = yield* requireFlow(owner, input.flowId); | |
| 416 | const interaction = snapshot.value.state.interaction; | |
| 417 | if ( | |
| 418 | snapshot.value.state.phase !== "waiting" || | |
| 419 | interaction?.type !== "browser" || | |
| 420 | !interaction.acceptsCallback || | |
| 421 | !flow.complete | |
| 422 | ) | |
| 423 | return yield* new ProviderSetupError({ | |
| 424 | instanceId: options.instanceId, | |
| 425 | operation: "complete", | |
| 426 | detail: "This sign-in does not accept a redirect URL.", | |
| 427 | }); | |
| 428 | yield* flow.complete(input.callbackUrl); | |
| 429 | return snapshot.value.state; | |
| 430 | }), | |
| 431 | ), | |
| 432 | cancel: (owner, id) => | |
| 433 | Effect.gen(function* () { | |
| 434 | const flow = yield* lock.withPermit( | |
| 435 | Effect.gen(function* () { | |
| 436 | const flow = yield* requireFlow(owner, id); | |
| 437 | yield* publish(flow, { | |
| 438 | phase: "cancelled", | |
| 439 | interaction: null, | |
| 440 | authorizationUrl: null, | |
| 441 | expiresAt: null, | |
| 442 | message: "Sign-in cancelled.", | |
| 443 | }); | |
| 444 | active = undefined; | |
| 445 | operation = "stopping"; | |
| 446 | return flow; | |
| 447 | }), | |
| 448 | ); | |
| 449 | if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 450 | if (flow.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 451 | operation = "idle"; | |
| 452 | return snapshot.value.state; | |
| 453 | }).pipe(Effect.uninterruptible), | |
| 454 | logout: (stopSessions) => | |
| 455 | Effect.gen(function* () { | |
| 456 | const flow = yield* lock.withPermit( | |
| 457 | Effect.gen(function* () { | |
| 458 | if (operation !== "idle" && operation !== "auth") | |
| 459 | return yield* new ProviderSetupError({ | |
| 460 | instanceId: options.instanceId, | |
| 461 | operation: "logout", | |
| 462 | detail: "Provider setup is already stopping.", | |
| 463 | }); | |
| 464 | operation = "stopping"; | |
| 465 | const flow = active; | |
| 466 | active = undefined; | |
| 467 | return flow; | |
| 468 | }), | |
| 469 | ); | |
| 470 | const result = yield* Effect.gen(function* () { | |
| 471 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 472 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 473 | yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); | |
| 474 | const message = yield* options.logout; | |
| 475 | if (options.refreshMethodsAfterAuth) yield* refreshMethods; | |
| 476 | return message; | |
| 477 | }).pipe(Effect.exit); | |
| 478 | const state: ProviderAuthState = { | |
| 479 | ...empty, | |
| 480 | methods: snapshot.value.state.methods ?? [], | |
| 481 | phase: Exit.isSuccess(result) ? "idle" : "failed", | |
| 482 | message: Exit.isSuccess(result) | |
| 483 | ? (result.value ?? "Signed out.") | |
| 484 | : "Could not sign out. Try again.", | |
| 485 | }; | |
| 486 | yield* lock.withPermit( | |
| 487 | Effect.gen(function* () { | |
| 488 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 489 | operation = "idle"; | |
| 490 | }), | |
| 491 | ); | |
| 492 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 493 | return state; | |
| 494 | }).pipe(Effect.uninterruptible), | |
| 495 | subscribe: (owner) => | |
| 496 | SubscriptionRef.changes(snapshot).pipe( | |
| 497 | Stream.map((current) => | |
| 498 | current.owner === null || current.owner === owner | |
| 499 | ? current.state | |
| 500 | : { | |
| 501 | ...current.state, | |
| 502 | flowId: null, | |
| 503 | authorizationUrl: null, | |
| 504 | interaction: null, | |
| 505 | expiresAt: null, | |
| 506 | message: active | |
| 507 | ? "Sign-in is in progress in another client." | |
| 508 | : current.state.message, | |
| 509 | }, | |
| 510 | ), | |
| 511 | ), | |
| 512 | }; | |
| 513 | yield* Effect.addFinalizer(() => | |
| 514 | Effect.gen(function* () { | |
| 515 | operation = "closed"; | |
| 516 | const flow = active; | |
| 517 | active = undefined; | |
| 518 | if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); | |
| 519 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 520 | // Settings edits rebuild instances; admitted sessions (including those of | |
| 521 | // peers sharing this binding) end with their own scopes, not this one. | |
| 522 | }), | |
| 523 | ); | |
| 524 | return controller; | |
| 525 | }); |
packages/provider-core/src/server/ProviderHost.ts+4| ... | ... | @@ -49,6 +49,10 @@ interface ProviderHostService { |
| 49 | 49 | readonly paths: ProviderHostPaths; |
| 50 | 50 | readonly settings: { |
| 51 | 51 | readonly get: Effect.Effect<ServerSettings, ServerSettingsError>; |
| 52 | /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */ | |
| 53 | readonly withSnapshot: <A, E, R>( | |
| 54 | use: (settings: ServerSettings) => Effect.Effect<A, E, R>, | |
| 55 | ) => Effect.Effect<A, E | ServerSettingsError, R>; | |
| 52 | 56 | /** Every settings change after subscription, starting with the next one. */ |
| 53 | 57 | readonly changes: Stream.Stream<ServerSettings>; |
| 54 | 58 | /** Changes buffered from the moment the scoped subscription is acquired. */ |
packages/provider-core/src/server/managedProvider.test.ts+2| ... | ... | @@ -113,6 +113,7 @@ function layerProviderHost(input: { |
| 113 | 113 | }, |
| 114 | 114 | settings: input.settings ?? { |
| 115 | 115 | get: Effect.succeed(DEFAULT_SERVER_SETTINGS), |
| 116 | withSnapshot: (use) => use(DEFAULT_SERVER_SETTINGS), | |
| 116 | 117 | changes: Stream.empty, |
| 117 | 118 | subscribe: Effect.succeed(Stream.empty), |
| 118 | 119 | }, |
| ... | ... | @@ -285,6 +286,7 @@ describe("makeManagedServerProvider", () => { |
| 285 | 286 | const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>(); |
| 286 | 287 | const hostSettings: ProviderHost.ProviderHost["Service"]["settings"] = { |
| 287 | 288 | get: Ref.get(serverSettingsRef), |
| 289 | withSnapshot: (use) => Ref.get(serverSettingsRef).pipe(Effect.flatMap(use)), | |
| 288 | 290 | changes: Stream.empty, |
| 289 | 291 | subscribe: PubSub.subscribe(serverSettingsChanges).pipe( |
| 290 | 292 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
packages/provider-opencode/src/server/OpenCodeRuntime.ts+6-6| ... | ... | @@ -105,10 +105,10 @@ export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERRO |
| 105 | 105 | readonly operation: string; |
| 106 | 106 | readonly cause?: unknown; |
| 107 | 107 | readonly detail: string; |
| 108 | }> { | |
| 109 | static readonly is = (u: unknown): u is OpenCodeRuntimeError => | |
| 110 | P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); | |
| 111 | } | |
| 108 | }> {} | |
| 109 | ||
| 110 | export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError => | |
| 111 | P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); | |
| 112 | 112 | |
| 113 | 113 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { |
| 114 | 114 | const result = encodeUnknownJsonStringExit(input); |
| ... | ... | @@ -116,7 +116,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | 118 | export function openCodeRuntimeErrorDetail(cause: unknown): string { |
| 119 | if (OpenCodeRuntimeError.is(cause)) return cause.detail; | |
| 119 | if (isOpenCodeRuntimeError(cause)) return cause.detail; | |
| 120 | 120 | if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); |
| 121 | 121 | if (cause && typeof cause === "object") { |
| 122 | 122 | // SDK v2 throws { response, request, error? } shapes — extract what's useful |
| ... | ... | @@ -583,7 +583,7 @@ function ensureRuntimeError( |
| 583 | 583 | detail: string, |
| 584 | 584 | cause: unknown, |
| 585 | 585 | ): OpenCodeRuntimeError { |
| 586 | return OpenCodeRuntimeError.is(cause) | |
| 586 | return isOpenCodeRuntimeError(cause) | |
| 587 | 587 | ? cause |
| 588 | 588 | : new OpenCodeRuntimeError({ operation, detail, cause }); |
| 589 | 589 | } |
packages/provider-opencode/src/server/v2/OpenCode2Server.ts+1-1| ... | ... | @@ -123,7 +123,7 @@ export const verifyServer = (client: OpenCodeClient) => |
| 123 | 123 | ), |
| 124 | 124 | }), |
| 125 | 125 | Effect.mapError((cause) => |
| 126 | OpenCodeRuntime.OpenCodeRuntimeError.is(cause) | |
| 126 | OpenCodeRuntime.isOpenCodeRuntimeError(cause) | |
| 127 | 127 | ? cause |
| 128 | 128 | : new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 129 | 129 | operation: "server.info", |
packages/provider-testing/src/host.ts+24-6| ... | ... | @@ -7,26 +7,38 @@ |
| 7 | 7 | */ |
| 8 | 8 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; |
| 9 | 9 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 10 | import * as Context from "effect/Context"; | |
| 10 | 11 | import * as Effect from "effect/Effect"; |
| 11 | 12 | import * as FileSystem from "effect/FileSystem"; |
| 12 | 13 | import * as Layer from "effect/Layer"; |
| 13 | 14 | import * as Option from "effect/Option"; |
| 14 | 15 | import * as Path from "effect/Path"; |
| 16 | import * as Ref from "effect/Ref"; | |
| 15 | 17 | import * as Stream from "effect/Stream"; |
| 16 | 18 | |
| 17 | 19 | export interface TestProviderHostOptions { |
| 18 | 20 | /** Session fallback cwd. Defaults to the test process cwd. */ |
| 19 | 21 | readonly cwd?: string; |
| 22 | /** Initial settings. A test changes them through `TestProviderHostSettings`. */ | |
| 20 | 23 | readonly settings?: ServerSettings; |
| 21 | 24 | /** Whether background work such as status probes may run. Defaults to `true`. */ |
| 22 | 25 | readonly runBackgroundWork?: boolean; |
| 23 | 26 | } |
| 24 | 27 | |
| 28 | /** Lets a test change the settings its `layerTestProviderHost` reports. */ | |
| 29 | export class TestProviderHostSettings extends Context.Service< | |
| 30 | TestProviderHostSettings, | |
| 31 | { readonly set: (settings: ServerSettings) => Effect.Effect<void> } | |
| 32 | >()("@t3tools/provider-testing/host/TestProviderHostSettings") {} | |
| 33 | ||
| 25 | 34 | export const layerTestProviderHost = ( |
| 26 | 35 | options: TestProviderHostOptions = {}, |
| 27 | ): Layer.Layer<ProviderHost.ProviderHost, never, FileSystem.FileSystem | Path.Path> => | |
| 28 | Layer.effect( | |
| 29 | ProviderHost.ProviderHost, | |
| 36 | ): Layer.Layer< | |
| 37 | ProviderHost.ProviderHost | TestProviderHostSettings, | |
| 38 | never, | |
| 39 | FileSystem.FileSystem | Path.Path | |
| 40 | > => | |
| 41 | Layer.effectContext( | |
| 30 | 42 | Effect.gen(function* () { |
| 31 | 43 | const fileSystem = yield* FileSystem.FileSystem; |
| 32 | 44 | const path = yield* Path.Path; |
| ... | ... | @@ -39,9 +51,9 @@ export const layerTestProviderHost = ( |
| 39 | 51 | for (const directory of [stateDir, providerStatusCacheDir, attachmentsDir]) { |
| 40 | 52 | yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); |
| 41 | 53 | } |
| 42 | const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; | |
| 54 | const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); | |
| 43 | 55 | const credentials = new Map<string, Uint8Array>(); |
| 44 | return ProviderHost.ProviderHost.of({ | |
| 56 | const host = ProviderHost.ProviderHost.of({ | |
| 45 | 57 | paths: { |
| 46 | 58 | cwd: options.cwd ?? process.cwd(), |
| 47 | 59 | baseDir, |
| ... | ... | @@ -50,7 +62,8 @@ export const layerTestProviderHost = ( |
| 50 | 62 | attachmentsDir, |
| 51 | 63 | }, |
| 52 | 64 | settings: { |
| 53 | get: Effect.succeed(settings), | |
| 65 | get: Ref.get(settings), | |
| 66 | withSnapshot: (use) => Ref.get(settings).pipe(Effect.flatMap(use)), | |
| 54 | 67 | changes: Stream.empty, |
| 55 | 68 | subscribe: Effect.succeed(Stream.empty), |
| 56 | 69 | }, |
| ... | ... | @@ -69,5 +82,10 @@ export const layerTestProviderHost = ( |
| 69 | 82 | }; |
| 70 | 83 | }), |
| 71 | 84 | }); |
| 85 | return Context.make(ProviderHost.ProviderHost, host).pipe( | |
| 86 | Context.add(TestProviderHostSettings, { | |
| 87 | set: (next) => Ref.set(settings, next), | |
| 88 | }), | |
| 89 | ); | |
| 72 | 90 | }), |
| 73 | 91 | ); |
packages/shared/package.json+4| ... | ... | @@ -470,6 +470,10 @@ |
| 470 | 470 | "./mcpResponsePayloads": { |
| 471 | 471 | "types": "./src/mcpResponsePayloads.ts", |
| 472 | 472 | "import": "./src/mcpResponsePayloads.ts" |
| 473 | }, | |
| 474 | "./PtyAdapter": { | |
| 475 | "types": "./src/PtyAdapter.ts", | |
| 476 | "import": "./src/PtyAdapter.ts" | |
| 473 | 477 | } |
| 474 | 478 | }, |
| 475 | 479 | "scripts": { |
packages/shared/src/PtyAdapter.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | /** | |
| 2 | * PtyAdapter - Terminal PTY adapter service contract. | |
| 3 | * | |
| 4 | * Defines the process primitives required by terminal session management | |
| 5 | * without binding to a specific PTY implementation. | |
| 6 | * | |
| 7 | * @module PtyAdapter | |
| 8 | */ | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | /** | |
| 14 | * PtySpawnError - Error type for PTY spawn failures. | |
| 15 | */ | |
| 16 | export class PtySpawnError extends Schema.TaggedError<PtySpawnError>()("PtySpawnError", { | |
| 17 | adapter: Schema.String, | |
| 18 | shell: Schema.optional(Schema.String), | |
| 19 | attemptedShells: Schema.optional(Schema.Array(Schema.String)), | |
| 20 | cause: Schema.optional(Schema.Defect()), | |
| 21 | }) { | |
| 22 | override get message(): string { | |
| 23 | const shell = this.shell === undefined ? "" : ` '${this.shell}'`; | |
| 24 | const attemptedShells = | |
| 25 | this.attemptedShells === undefined || this.attemptedShells.length === 0 | |
| 26 | ? "" | |
| 27 | : ` Tried shells: ${this.attemptedShells.join(", ")}.`; | |
| 28 | return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export interface PtyExitEvent { | |
| 33 | exitCode: number; | |
| 34 | signal: number | null; | |
| 35 | } | |
| 36 | ||
| 37 | export interface PtyProcess { | |
| 38 | readonly pid: number; | |
| 39 | write(data: string): void; | |
| 40 | resize(cols: number, rows: number): void; | |
| 41 | kill(signal?: string): void; | |
| 42 | onData(callback: (data: string) => void): () => void; | |
| 43 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 44 | } | |
| 45 | ||
| 46 | export interface PtySpawnInput { | |
| 47 | shell: string; | |
| 48 | args?: string[]; | |
| 49 | cwd: string; | |
| 50 | cols: number; | |
| 51 | rows: number; | |
| 52 | env: NodeJS.ProcessEnv; | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * PtyAdapter - Service tag for PTY process integration. | |
| 57 | */ | |
| 58 | export class PtyAdapter extends Context.Service< | |
| 59 | PtyAdapter, | |
| 60 | { | |
| 61 | /** | |
| 62 | * Spawn a PTY process for a terminal session. | |
| 63 | */ | |
| 64 | readonly spawn: (input: PtySpawnInput) => Effect.Effect<PtyProcess, PtySpawnError>; | |
| 65 | } | |
| 66 | >()("@t3tools/shared/PtyAdapter") {} |
pnpm-lock.yaml+49-3| ... | ... | @@ -353,6 +353,9 @@ importers: |
| 353 | 353 | '@t3tools/mobile-terminal-native': |
| 354 | 354 | specifier: file:./modules/t3-terminal |
| 355 | 355 | version: file:apps/mobile/modules/t3-terminal |
| 356 | '@t3tools/provider-acp-registry': | |
| 357 | specifier: workspace:* | |
| 358 | version: link:../../packages/provider-acp-registry | |
| 356 | 359 | '@t3tools/provider-core': |
| 357 | 360 | specifier: workspace:* |
| 358 | 361 | version: link:../../packages/provider-core |
| ... | ... | @@ -597,15 +600,15 @@ importers: |
| 597 | 600 | '@napi-rs/keyring': |
| 598 | 601 | specifier: ^1.3.0 |
| 599 | 602 | version: 1.3.0 |
| 600 | '@noble/hashes': | |
| 601 | specifier: 'catalog:' | |
| 602 | version: 1.8.0 | |
| 603 | 603 | '@opencode-ai/sdk': |
| 604 | 604 | specifier: ^1.3.15 |
| 605 | 605 | version: 1.15.13 |
| 606 | 606 | '@t3tools/provider-acp': |
| 607 | 607 | specifier: workspace:* |
| 608 | 608 | version: link:../../packages/provider-acp |
| 609 | '@t3tools/provider-acp-registry': | |
| 610 | specifier: workspace:* | |
| 611 | version: link:../../packages/provider-acp-registry | |
| 609 | 612 | '@t3tools/provider-core': |
| 610 | 613 | specifier: workspace:* |
| 611 | 614 | version: link:../../packages/provider-core |
| ... | ... | @@ -748,6 +751,9 @@ importers: |
| 748 | 751 | '@t3tools/contracts': |
| 749 | 752 | specifier: workspace:* |
| 750 | 753 | version: link:../../packages/contracts |
| 754 | '@t3tools/provider-acp-registry': | |
| 755 | specifier: workspace:* | |
| 756 | version: link:../../packages/provider-acp-registry | |
| 751 | 757 | '@t3tools/provider-core': |
| 752 | 758 | specifier: workspace:* |
| 753 | 759 | version: link:../../packages/provider-core |
| ... | ... | @@ -1138,6 +1144,46 @@ importers: |
| 1138 | 1144 | specifier: 1.0.0 |
| 1139 | 1145 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1140 | 1146 | |
| 1147 | packages/provider-acp-registry: | |
| 1148 | dependencies: | |
| 1149 | '@noble/hashes': | |
| 1150 | specifier: 'catalog:' | |
| 1151 | version: 1.8.0 | |
| 1152 | '@t3tools/contracts': | |
| 1153 | specifier: workspace:* | |
| 1154 | version: link:../contracts | |
| 1155 | '@t3tools/provider-acp': | |
| 1156 | specifier: workspace:* | |
| 1157 | version: link:../provider-acp | |
| 1158 | '@t3tools/provider-core': | |
| 1159 | specifier: workspace:* | |
| 1160 | version: link:../provider-core | |
| 1161 | '@t3tools/shared': | |
| 1162 | specifier: workspace:* | |
| 1163 | version: link:../shared | |
| 1164 | effect: | |
| 1165 | specifier: 4.0.1 | |
| 1166 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) | |
| 1167 | devDependencies: | |
| 1168 | '@effect/platform-node': | |
| 1169 | specifier: 4.0.1 | |
| 1170 | version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) | |
| 1171 | '@effect/vitest': | |
| 1172 | specifier: 4.0.1 | |
| 1173 | version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1174 | '@t3tools/provider-testing': | |
| 1175 | specifier: workspace:* | |
| 1176 | version: link:../provider-testing | |
| 1177 | '@types/node': | |
| 1178 | specifier: 24.12.4 | |
| 1179 | version: 24.12.4 | |
| 1180 | effect-acp: | |
| 1181 | specifier: workspace:* | |
| 1182 | version: link:../effect-acp | |
| 1183 | vite-plus: | |
| 1184 | specifier: 1.0.0 | |
| 1185 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1186 | ||
| 1141 | 1187 | packages/provider-core: |
| 1142 | 1188 | dependencies: |
| 1143 | 1189 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | ... | @@ -32,6 +32,7 @@ const workspaceFiles = [ |
| 32 | 32 | "packages/provider-testing/package.json", |
| 33 | 33 | "packages/provider-pi/package.json", |
| 34 | 34 | "packages/provider-acp/package.json", |
| 35 | "packages/provider-acp-registry/package.json", | |
| 35 | 36 | "packages/provider-cursor/package.json", |
| 36 | 37 | "packages/provider-grok/package.json", |
| 37 | 38 | "packages/provider-muse/package.json", |