diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index e822ea4efd4d..907d0794111a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as EffectAcpErrors from "effect-acp/errors"; import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, @@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll( IdAllocator.layer, serverConfigLayer, registryLayer, + ServerSettings.layerTest(), ); describe("AcpRegistryAdapterV2", () => { @@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => { assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { + source: "registry", + commandArgs: [], enabled: true, agentId: "", commandPath: "", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index 76326b274528..af9530a043ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { const runtimeCoordinator = options.runtimeCoordinator; - const isDevin = options.settings.agentId === "devin"; + const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; + const startupKey = + options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; + const isDevin = registryAgentId === "devin"; const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, capabilities: AcpProviderCapabilitiesV2, - promptFailure: (cause) => acpRegistryPromptFailure(options.settings.agentId, cause), + promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), // Per-agent exceptions (Mistral Vibe, Devin): see the note above // registerMistralVibeAcpExtensions before adding any more. - ...(options.settings.agentId === "mistral-vibe" + ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), ...(isDevin @@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { }); }, withRuntimeStartup: (effect: Effect.Effect) => - runtimeCoordinator.withForegroundStartup(options.settings.agentId, effect), + runtimeCoordinator.withForegroundStartup(startupKey, effect), }), ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), }; diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts index 289039cd0f2c..ceb9c695a590 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => { }), ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); + expect( + acpRegistrySnapshotReadiness({ + status: "missing_runner", + version: null, + distribution: "local", + }), + ).toEqual({ + installed: false, + version: null, + status: "error", + message: "Local ACP executable is not available on this environment's PATH.", + }); + expect( acpRegistrySnapshotReadiness({ status: "unprepared", @@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => { expect(snapshot.models[0]?.isDefault).toBe(true); }); - it("reports failed authentication without hiding successful local inspection", () => { - const snapshot = buildCheckedAcpRegistrySnapshot({ - ...identity, - settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }), - checkedAt: "2026-08-13T10:00:00.000Z", - inspection: { - status: "ready", - agentId: "test-agent", - version: "1.0.0", - distribution: "binary", - }, - probeError: new AcpRegistryOperationError({ - reason: "authentication_failed", - message: "Login required.", - authMethods: [ - { - id: "api-key", - name: "API key", - description: null, - type: "env_var", - }, - { - id: "grok-login", - name: "Log in with Grok", - description: null, - type: "agent", - }, - ], - }), - }); + it.each(["registry", "local"] as const)( + "reports failed authentication after successful %s inspection", + (source) => { + const snapshot = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ + source, + ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), + authMethodId: "grok-login", + }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: source === "local" ? null : "1.0.0", + distribution: source === "local" ? "local" : "binary", + }, + probeError: new AcpRegistryOperationError({ + reason: "authentication_failed", + message: "Login required.", + authMethods: [ + { + id: "api-key", + name: "API key", + description: null, + type: "env_var", + }, + { + id: "grok-login", + name: "Log in with Grok", + description: null, + type: "agent", + }, + ], + }), + }); - expect(snapshot).toMatchObject({ - installed: true, - version: "1.0.0", - status: "warning", - auth: { - status: "unauthenticated", - type: "agent", - label: "Log in with Grok", - }, - message: 'Sign in in provider settings using "Log in with Grok".', - }); - }); + expect(snapshot).toMatchObject({ + installed: true, + version: source === "local" ? null : "1.0.0", + status: "warning", + setup: { canAuthenticate: true }, + auth: { + status: "unauthenticated", + type: "agent", + label: "Log in with Grok", + }, + message: 'Sign in in provider settings using "Log in with Grok".', + }); + }, + ); it.effect("runs the disposable probe only after local inspection is ready", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 68bf797fa084..897dbec5d7fc 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness( installed: false, version: null, status: "warning", - message: "Select an ACP Registry agent before starting a thread.", + message: + "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", }; case "not_found": return { @@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness( installed: false, version: inspection.version, status: "error", - message: `ACP Registry agent '${inspection.agentId}' requires '${inspection.runner}' on this environment's PATH.`, + message: + inspection.distribution === "local" + ? "Local ACP executable is not available on this environment's PATH." + : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, }; case "unprepared": return { @@ -196,11 +200,14 @@ function baseSnapshot( readonly documentationUrl?: string; readonly message?: string; readonly probe?: AcpRegistryConfigurationProbeResult; + readonly probeError?: AcpRegistryOperationError; }, ): ServerProvider { const iconUrl = - resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? - officialAcpRegistryIconUrlForAgentId(input.settings.agentId); + input.settings.source === "local" + ? null + : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? + officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); return { instanceId: input.instanceId, driver: DRIVER_KIND, @@ -225,7 +232,9 @@ function baseSnapshot( input.installed && (input.probe ? input.probe.probe.authMethods.length > 0 - : input.settings.agentId.length > 0), + : input.settings.source === "local" + ? (input.probeError?.authMethods?.length ?? 0) > 0 + : input.settings.agentId.length > 0), }, ...(input.message ? { message: input.message } : {}), models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), @@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver Effect.die("unused"), }; -const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => +const makeHarness = ( + method: AcpSchema.AuthMethod, + failVerification = false, + settings = decodeSettings({ agentId: "test-agent" }), + providerInstanceId = instanceId, +) => Effect.gen(function* () { const verify = yield* Deferred.make(); const changed: boolean[] = []; @@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => const written: string[] = []; const sizes: number[][] = []; const controller = yield* makeAcpRegistryAuth({ - instanceId, - settings: decodeSettings({ agentId: "test-agent" }), + instanceId: providerInstanceId, + settings, cwd: "/workspace", environment: { PATH: "/tools", OVERRIDE: "base" }, onChanged: (value) => @@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => }; }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); +it.effect("keeps local credentials separate from a matching registry agent ID", () => + Effect.gen(function* () { + const registry = yield* makeHarness(browserMethod); + const local = yield* makeHarness( + browserMethod, + false, + decodeSettings({ source: "local", commandPath: "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/different/agent" }), + ProviderInstanceId.make("test-agent"), + ); + assert.deepEqual(registry.controller.credentialBinding, { + owner: "provider", + key: "acp:test-agent", + }); + assert.deepEqual(local.controller.credentialBinding, { + owner: "provider", + key: "acp:local:test-agent", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect( "discovers methods without signing in, then waits for browser consent and session verification", () => diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/apps/server/src/provider/acp/AcpRegistryAuth.ts index 4befdb1cec40..6c8a0d9f12c1 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuth.ts @@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o const coordinator = yield* Effect.serviceOption( AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, ); + const agentKey = + options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; const failure = (operation: string, detail: string, cause?: unknown) => new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); const resolve = catalog @@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o const methods = Option.isSome(coordinator) ? coordinator.value - .runBackgroundProbe(options.settings.agentId, discoverMethods) + .runBackgroundProbe(agentKey, discoverMethods) .pipe( Effect.flatMap((result) => Option.isSome(result) @@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o }), ); yield* Option.isSome(coordinator) - ? coordinator.value.withForegroundStartup(options.settings.agentId, login) + ? coordinator.value.withForegroundStartup(agentKey, login) : login; yield* options.onChanged(true); }); @@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o }), ); const signOut = ( - Option.isSome(coordinator) - ? coordinator.value.withForegroundStartup(options.settings.agentId, logout) - : logout + Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout ).pipe(Effect.andThen(options.onChanged(false))); return yield* ProviderAuthFlow.make({ instanceId: options.instanceId, // ACP doesn't advertise its credential scope. Conservatively treat all // instances of the same agent on this environment as sharing credentials. - credentialBinding: { owner: "provider", key: `acp:${options.settings.agentId}` }, + credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, methods, ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), authenticate, diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts index 1f4799e65c24..b38fcfef54e3 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts @@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth // Cosmetic settings and model discovery can rebuild the driver without // changing the account. Credential overrides and profile paths cannot. const binding = hash({ + ...(input.settings.source === "local" + ? { source: "local", commandArgs: input.settings.commandArgs } + : {}), agentId: input.settings.agentId, commandPath: input.settings.commandPath, distribution: input.settings.distribution, diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts index 4f7422ff783c..9d5288c957ee 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -222,6 +222,75 @@ describe("ACP Registry probe", () => { expect(result.currentModelId).toBeNull(); }); + it("preserves grouped model values and advertises reasoning choices", () => { + const codexModel = '["codex","gpt-6.1-sol"]'; + const goModel = '["opencode-go","deepseek-v4.1-flash"]'; + const result = acpRegistryProbeResult(instanceId, { + sessionId: "probe-session", + initializeResult: { protocolVersion: 1 }, + sessionSetupResult: { + sessionId: "probe-session", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: goModel, + options: [ + { + groupId: "codex", + name: "Codex", + options: [{ value: codexModel, name: "GPT-6.1 Sol" }], + }, + { + groupId: "opencode-go", + name: "OpenCode Go", + options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }], + }, + ], + }, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "high", + options: [ + { + groupId: "effort", + name: "Effort", + options: [ + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + }, + ], + }, + modelConfigId: "model", + } satisfies AcpSessionRuntimeStartResult); + + expect(result.models).toEqual([ + { id: codexModel, name: "GPT-6.1 Sol", description: null }, + { id: goModel, name: "DeepSeek V4.1 Flash", description: null }, + ]); + expect(result.currentModelId).toBe(goModel); + expect(result.configOptions).toEqual([ + { + id: "reasoning", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ]); + }); + it("omits a current model that falls outside the bounded model catalog", () => { const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts index 588a04e97629..76d1ca0effde 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo probe: acpRegistryProbeResult( input.instanceId, result.started, - result.resolved.agent.icon ?? null, + result.resolved.agent?.icon ?? null, { command: result.resolved.spawn.command, args: result.resolved.spawn.args, diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts index b12b13e3a4ef..dd29f89a2549 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { AcpRegistrySettings } from "@t3tools/contracts"; +import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessEnvironment, @@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as TestClock from "effect/testing/TestClock"; @@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import * as ServerSettings from "../../serverSettings.ts"; const registryUrl = "https://registry.test/registry.json"; const archiveUrl = "https://registry.test/example-agent.bin"; @@ -57,6 +56,7 @@ function resolverLayer( ) { return Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.succeed(HostProcessPlatform, "linux"), Layer.succeed(HostProcessArchitecture, "x64"), Layer.succeed(HostProcessEnvironment, environment), @@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => { ).toBeUndefined(); }); + it.effect("resolves a local command on the selected environment without the registry", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); + const commandPath = `${cacheDir}/dsh`; + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; + const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); + const localSettings = decodeAcpRegistrySettings({ + source: "local", + commandPath: "dsh", + commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], + }); + + expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); + expect(yield* resolver.inspect(localSettings, environment)).toEqual({ + status: "ready", + agentId: "dsh", + version: null, + distribution: "local", + }); + const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); + expect(resolved).toEqual({ + distribution: "local", + spawn: { + command: commandPath, + args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], + cwd: "/workspace", + env: environment, + shell: false, + }, + }); + expect(requests).toEqual([]); + expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => { + requests.push(request.url); + return Effect.die("unexpected registry request for a local provider"); + }), + ), + ); + }); + + it.effect("resolves a local executable path and inherits the host environment", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); + const commandPath = `${cacheDir}/dsh wrapper`; + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + const environment = { PATH: "/unused", INHERITED: "host" }; + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe(Effect.provideService(HostProcessEnvironment, environment)); + const resolved = yield* resolver.resolve( + decodeAcpRegistrySettings({ source: "local", commandPath }), + "/workspace", + ); + + expect(resolved.spawn).toEqual({ + command: commandPath, + args: [], + cwd: "/workspace", + env: environment, + shell: false, + }); + expect(resolved.agent).toBeUndefined(); + + for (const extension of [".cmd", ".BAT"]) { + const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), + ); + const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); + for (const failure of [ + yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), + yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), + ]) { + expect(failure).toMatchObject({ + reason: "runner_unavailable", + detail: expect.stringContaining("node.exe"), + }); + } + } + }).pipe( + Effect.scoped, + Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), + ), + ); + + it.effect("reports missing or non-executable local commands without registry fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-local-missing-", + }); + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const environment = { PATH: cacheDir }; + const unconfigured = decodeAcpRegistrySettings({ source: "local" }); + expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ + status: "unconfigured", + }); + expect( + yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), + ).toMatchObject({ reason: "agent_not_configured" }); + + const nonExecutable = `${cacheDir}/non-executable`; + yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); + yield* fileSystem.chmod(nonExecutable, 0o644); + for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { + const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); + expect(yield* resolver.inspect(localSettings, environment)).toEqual({ + status: "missing_runner", + version: null, + distribution: "local", + }); + expect( + yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), + ).toMatchObject({ + reason: "runner_unavailable", + detail: "Local ACP executable is not available on this environment's PATH.", + }); + } + }).pipe( + Effect.scoped, + Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), + ), + ); + it.effect("resolves command overrides while preserving registry args and environment", () => { const agent = makeAgent({ binary: { @@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => { ); }).pipe( Effect.scoped, - Effect.provide(NodeServices.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), ); @@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => { ); }); - it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-registry-uninstall-", - }); - const agentRoot = `${cacheDir}/tools/example-agent`; - const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; - yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); - yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); - yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { - recursive: true, - }); - yield* fileSystem.writeFileString(runnerCache, "{}"); - - const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ - cacheDir, - toolsDir: `${cacheDir}/tools`, - registryUrl, - }); - const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); - const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + it.effect.each([ + { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, + { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, + { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, + { name: "local command", source: "local", agentId: "example-agent", referenced: false }, + ] as const)( + "checks $name references before uninstalling only managed binaries", + ({ source, agentId, referenced }) => { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-uninstall-", + }); + const agentRoot = `${cacheDir}/tools/example-agent`; + const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; + yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); + yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); + yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { + recursive: true, + }); + yield* fileSystem.writeFileString(runnerCache, "{}"); - expect(first).toEqual({ agentId: "example-agent", removed: true }); - expect(second).toEqual({ agentId: "example-agent", removed: false }); - expect(yield* fileSystem.exists(agentRoot)).toBe(false); - expect(yield* fileSystem.exists(runnerCache)).toBe(true); - }).pipe( - Effect.scoped, - Effect.provide( - resolverLayer((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const instanceId = ProviderInstanceId.make("uninstall-reference"); + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Uninstall reference", + enabled: true, + config: { + agentId, + commandPath: "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/local/agent", + ...(source ? { source } : {}), + }, + }, + }); + const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); + expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); + yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); + + expect(second).toEqual({ agentId: "example-agent", removed: referenced }); + expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ + agentId: "example-agent", + removed: false, + }); + expect(yield* fileSystem.exists(agentRoot)).toBe(false); + expect(yield* fileSystem.exists(runnerCache)).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + resolverLayer((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), + ), ), - ), - ); - }); + ); + }, + ); it.effect("keeps managed package installs when removing the same agent's binaries", () => Effect.gen(function* () { @@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => { return Effect.gen(function* () { const downloadStarted = yield* Deferred.make(); const releaseDownload = yield* Deferred.make(); - const referenceChecked = yield* Deferred.make(); - const isReferenced = yield* Ref.make(false); return yield* Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => { .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(downloadStarted); const uninstallFiber = yield* resolver - .uninstallManagedBinary( - { agentId: agent.id }, - Deferred.succeed(referenceChecked, undefined).pipe( - Effect.andThen(Ref.get(isReferenced)), - ), - ) + .uninstallManagedBinary({ agentId: agent.id }) .pipe(Effect.forkChild({ startImmediately: true })); - expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true); - yield* Ref.set(isReferenced, true); + expect(uninstallFiber.pollUnsafe()).toBeUndefined(); yield* Deferred.succeed(releaseDownload, undefined); expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); @@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => { const agentRoot = `${cacheDir}/tools/${agent.id}`; expect(yield* fileSystem.exists(agentRoot)).toBe(true); - yield* Ref.set(isReferenced, false); - expect( - yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), - ).toEqual({ agentId: agent.id, removed: true }); - expect( - yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), - ).toEqual({ agentId: agent.id, removed: false }); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const instanceId = ProviderInstanceId.make("registry-reference"); + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Registry reference", + enabled: true, + config: { agentId: agent.id }, + }, + }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: false, + }); + yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: true, + }); + expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ + agentId: agent.id, + removed: false, + }); }).pipe( Effect.scoped, Effect.provide( diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index 21c34e6dcf69..600ba3a6aaaa 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeCrypto from "node:crypto"; import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; @@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr }); } -export const isAcpRegistryError = Schema.is(AcpRegistryError); +const isAcpRegistryError = Schema.is(AcpRegistryError); export type AcpRegistryPlatformTarget = | "darwin-aarch64" @@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: { } export interface ResolvedAcpRegistryAgent { - readonly agent: AcpRegistryAgent; - readonly distribution: AcpRegistryDistributionKind; + readonly agent?: AcpRegistryAgent; + readonly distribution: AcpRegistryDistributionKind | "local"; readonly spawn: AcpSpawnInput; } @@ -459,6 +460,11 @@ export type AcpRegistryInspection = readonly distribution: AcpRegistryDistributionKind; readonly runner: string; } + | { + readonly status: "missing_runner"; + readonly version: null; + readonly distribution: "local"; + } | { readonly status: "unprepared"; readonly agentId: string; @@ -469,7 +475,7 @@ export type AcpRegistryInspection = readonly status: "ready"; readonly agentId: string; readonly version: string | null; - readonly distribution: AcpRegistryDistributionKind; + readonly distribution: AcpRegistryDistributionKind | "local"; readonly documentationUrl?: string; }; @@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service< ) => Effect.Effect; readonly uninstallManagedBinary: ( input: AcpRegistryManagedBinaryUninstallInput, - isReferenced?: Effect.Effect, ) => Effect.Effect; } >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { @@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + | ServerSettings.ServerSettingsService > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettings.ServerSettingsService; const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const hostEnvironment = yield* HostProcessEnvironment; @@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } satisfies AcpRegistryPrepareResult; }); + const validateLocalExecutable = (command: string) => + platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) + ? Effect.fail( + new AcpRegistryError({ + reason: "runner_unavailable", + detail: + "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.", + }), + ) + : Effect.void; + const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => Effect.gen(function* () { + if (settings.source === "local") { + const command = settings.commandPath.trim(); + if (!command) return { status: "unconfigured" } as const; + const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); + if (executable !== undefined) yield* validateLocalExecutable(executable); + return executable === undefined + ? ({ + status: "missing_runner", + version: null, + distribution: "local", + } as const) + : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); + } const agentId = settings.agentId.trim(); if (agentId.length === 0) return { status: "unconfigured" } as const; const registry = yield* loadCachedRegistry(); @@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => Effect.gen(function* () { + if (settings.source === "local") { + const executable = settings.commandPath.trim(); + if (!executable) { + return yield* new AcpRegistryError({ + reason: "agent_not_configured", + detail: "Local ACP provider requires an executable.", + }); + } + const env = environment ?? hostEnvironment; + const command = resolveExecutable(executable, platform, env); + if (command === undefined) { + return yield* new AcpRegistryError({ + reason: "runner_unavailable", + detail: "Local ACP executable is not available on this environment's PATH.", + }); + } + yield* validateLocalExecutable(command); + return { + distribution: "local", + spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, + } satisfies ResolvedAcpRegistryAgent; + } const agentId = settings.agentId.trim(); if (agentId.length === 0) { return yield* new AcpRegistryError({ @@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } satisfies ResolvedAcpRegistryAgent; }); - const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = ( - input, - isReferenced = Effect.succeed(false), - ) => - installSemaphore.withPermits(1)( - Effect.gen(function* () { - const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: "ACP Registry managed binary uninstall received an invalid agent ID.", - cause, - }), - ), - ); - if (yield* isReferenced) { - yield* consumePreparedBinaryReservation(safeAgentId); - return { agentId: safeAgentId, removed: false }; - } - if (yield* hasPreparedBinaryReservation(safeAgentId)) { - return { agentId: safeAgentId, removed: false }; - } - const agentRoot = path.join(installsDirectory, safeAgentId); - const existed = yield* fileSystem.exists(agentRoot).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, - cause, - }), - ), - ); - if (!existed) return { agentId: safeAgentId, removed: false }; - - let removed = false; - yield* Effect.gen(function* () { - for (const version of yield* fileSystem.readDirectory(agentRoot)) { - const versionRoot = path.join(agentRoot, version); - for (const entry of yield* fileSystem.readDirectory(versionRoot)) { - if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; - yield* fileSystem.remove(path.join(versionRoot, entry), { - recursive: true, - force: true, - }); - removed = true; + const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => + serverSettings + .withSettingsSnapshot((settings) => + installSemaphore.withPermits(1)( + Effect.gen(function* () { + const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: "ACP Registry managed binary uninstall received an invalid agent ID.", + cause, + }), + ), + ); + const isReferenced = Object.values(settings.providerInstances).some((instance) => { + if ( + instance.driver !== "acpRegistry" || + instance.config === null || + typeof instance.config !== "object" + ) + return false; + const config = instance.config as Record; + return ( + config.source !== "local" && + typeof config.agentId === "string" && + config.agentId.trim() === safeAgentId + ); + }); + if (isReferenced) { + yield* consumePreparedBinaryReservation(safeAgentId); + return { agentId: safeAgentId, removed: false }; } - if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) - yield* fileSystem.remove(versionRoot, { recursive: true }); - } - if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) - yield* fileSystem.remove(agentRoot, { recursive: true }); - }).pipe( - Effect.mapError( - (cause) => - new AcpRegistryError({ - reason: "install_failed", - detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, - cause, - }), - ), - ); - return { agentId: safeAgentId, removed }; - }), - ); + if (yield* hasPreparedBinaryReservation(safeAgentId)) { + return { agentId: safeAgentId, removed: false }; + } + const agentRoot = path.join(installsDirectory, safeAgentId); + const existed = yield* fileSystem.exists(agentRoot).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + if (!existed) return { agentId: safeAgentId, removed: false }; + + let removed = false; + yield* Effect.gen(function* () { + for (const version of yield* fileSystem.readDirectory(agentRoot)) { + const versionRoot = path.join(agentRoot, version); + for (const entry of yield* fileSystem.readDirectory(versionRoot)) { + if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; + yield* fileSystem.remove(path.join(versionRoot, entry), { + recursive: true, + force: true, + }); + removed = true; + } + if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) + yield* fileSystem.remove(versionRoot, { recursive: true }); + } + if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) + yield* fileSystem.remove(agentRoot, { recursive: true }); + }).pipe( + Effect.mapError( + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, + cause, + }), + ), + ); + return { agentId: safeAgentId, removed }; + }), + ), + ) + .pipe( + Effect.catchTag( + "ServerSettingsError", + (cause) => + new AcpRegistryError({ + reason: "install_failed", + detail: + "Could not read provider settings while checking managed ACP binary references.", + cause, + }), + ), + ); return AcpRegistryCatalog.of({ search, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index dbb78eec8031..eb7129cf9482 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -87,6 +87,7 @@ export interface AcpSpawnInput { readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean; + readonly shell?: false; } export interface AcpSessionRuntimeOptions { @@ -1516,10 +1517,13 @@ export const make = ( ), ); - const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { - ...(options.spawn.env ? { env: options.spawn.env } : {}), - extendEnv: options.spawn.extendEnv ?? true, - }); + const spawnCommand = + options.spawn.shell === false + ? { command: options.spawn.command, args: options.spawn.args, shell: false } + : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + ...(options.spawn.env ? { env: options.spawn.env } : {}), + extendEnv: options.spawn.extendEnv ?? true, + }); const linuxCgroupLease = options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" ? yield* Effect.sync(() => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 96639a4d41aa..50322b82857d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -574,7 +574,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( Layer.provideMerge(PtyAdapterLive), // Search, prepare, status inspection, and turn launch share one registry // cache so every client and provider instance sees the same prepared agents. - Layer.provideMerge(AcpRegistryCatalogLive), + Layer.provideMerge(AcpRegistryCatalogLive.pipe(Layer.provide(ServerSettingsLayerLive))), // Shared native/canonical NDJSON writers used by both the per-instance // V2 drivers and the orchestration runtime. Provide resource attribution so // the rewritten telemetry pipeline can account for logical NDJSON writes. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 92eb7c0f9a6d..8838de05b423 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2114,36 +2114,9 @@ const makeWsRpcLayer = ( [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => observeRpcEffect( WS_METHODS.serverUninstallAcpRegistryManagedBinary, - serverSettings - .withSettingsSnapshot((settings) => - acpRegistryCatalog.uninstallManagedBinary( - input, - Effect.succeed( - Object.values(settings.providerInstances).some((instance) => { - if ( - instance.driver !== "acpRegistry" || - instance.config === null || - typeof instance.config !== "object" - ) { - return false; - } - return (instance.config as Record).agentId === input.agentId; - }), - ), - ), - ) - .pipe( - Effect.mapError((cause) => - AcpRegistrySupport.isAcpRegistryError(cause) - ? cause - : new AcpRegistrySupport.AcpRegistryError({ - reason: "install_failed", - detail: `Could not read provider settings while checking references for ACP Registry agent ${input.agentId}.`, - cause, - }), - ), - Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError), - ), + acpRegistryCatalog + .uninstallManagedBinary(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), { "rpc.aggregate": "server", "acp_registry.agent_id": input.agentId, diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx index 2084c035d671..55178f481a5d 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx @@ -34,6 +34,7 @@ interface AcpRegistrySearchStepProps { readonly providerInstances: Readonly>; readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; readonly onManualConfiguration: () => void; + readonly onLocalConfiguration?: () => void; readonly onLoadingChange?: (loading: boolean) => void; readonly onPreparingChange?: (preparing: boolean) => void; } @@ -55,6 +56,7 @@ export function AcpRegistrySearchStep({ providerInstances, onPrepared, onManualConfiguration, + onLocalConfiguration, onLoadingChange, onPreparingChange, }: AcpRegistrySearchStepProps) { @@ -169,6 +171,17 @@ export function AcpRegistrySearchStep({ > Enter manually + {onLocalConfiguration ? ( + + ) : null}
diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 1a0f9276c5af..d112af1fed03 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { ).not.toBeNull(); }); + it("creates a local command in the selected environment without a sign-in step", async () => { + const onOpenChange = vi.fn(); + let tree = render(onOpenChange); + const search = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep", + ); + (search!.props.onLocalConfiguration as () => void)(); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + expect(findByChildren(tree, "Executable is required.")).not.toBeNull(); + expect(settingsHooks.mutate).not.toHaveBeenCalled(); + + const configuration = visitElements( + tree, + (element) => element.props.idPrefix === "add-provider-acpRegistry-manual", + ); + const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""]; + (configuration!.props.onChange as (value: Record) => void)({ + source: "local", + commandPath: "dsh", + commandArgs, + }); + const environmentEditor = visitElements( + tree, + (element) => + typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection", + ); + const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }]; + (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment); + tree = render(onOpenChange); + (findByChildren(tree, "Next").props.onClick as () => void)(); + tree = render(onOpenChange); + const label = visitElements(tree, (element) => element.props.id === "add-provider-label"); + (label!.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: "Deepseek Harness" }, + }); + tree = render(onOpenChange); + (findByChildren(tree, "Add instance").props.onClick as () => void)(); + await Promise.resolve(); + await Promise.resolve(); + + expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.mutate).toHaveBeenCalledWith({ + operation: "create", + instanceId: "acpRegistry_deepseek_harness", + instance: { + driver: "acpRegistry", + enabled: true, + displayName: "Deepseek Harness", + config: { source: "local", commandPath: "dsh", commandArgs }, + environment, + }, + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + tree = render(onOpenChange); + expect( + visitElements( + tree, + (element) => + typeof element.type === "function" && + element.type.name === "ProviderWizardAuthenticationStep", + ), + ).toBeNull(); + }); + it("keeps the dialog open when the atomic upsert fails", async () => { settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); const onOpenChange = vi.fn(); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 20d575c1cc58..aecb2c723d05 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; +export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const; export interface ProviderIdentityDraft { readonly label: string; @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( ) { return false; } - return (instance.config as Record).agentId === agentId; + const config = instance.config as Record; + return config.source !== "local" && config.agentId === agentId; }); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 1288d990b991..0d137c89dec3 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -13,6 +13,7 @@ import { ProviderDriverKind, type EnvironmentId, type ProviderInstanceConfig, + type ProviderInstanceEnvironmentVariable, } from "@t3tools/contracts"; import { @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza import { ADD_PROVIDER_WIZARD_STEPS, ACP_REGISTRY_WIZARD_STEPS, + LOCAL_ACP_WIZARD_STEPS, deriveAvailableInstanceId, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; +import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; /** * Normalize a user-provided label into a slug suffix for the instance id. @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ {}, ); const [selectedAcp, setSelectedAcp] = useState(null); + const [localEnvironment, setLocalEnvironment] = useState< + ReadonlyArray + >([]); const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); const [isRegistryLoading, setIsRegistryLoading] = useState(false); const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ const previewLabel = label.trim() || `${driverOption.label} Workspace`; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; + const isLocalAcp = isAcpRegistry && isManualAcpConfiguration && configDraft.source === "local"; + const localCommandPath = + typeof configDraft.commandPath === "string" ? configDraft.commandPath.trim() : ""; const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; - const acpSelectionError = - selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) + const acpSelectionError = isLocalAcp + ? localCommandPath.length > 0 + ? null + : "Executable is required." + : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) ? null : "Select an ACP or configure one manually."; const wizardStepSummaries = isAcpRegistry - ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) + ? ([ + isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), + previewLabel, + null, + ] as const) : ([driverOption.label, previewLabel, null] as const); const setConfigDraft = (config: Record | undefined) => { setConfigByDriver((existing) => { @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ if (navigation.kind === "blocked") { setHasAttemptedSubmit(true); } - if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { + if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) { void handleSave(); return; } @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ const navigateToStep = (requestedStep: number) => { applyWizardNavigation( isAcpRegistry - ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { - instanceIdError, - selectionError: acpSelectionError, - }) + ? isLocalAcp + ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, { + instanceIdError, + prerequisite: { step: 0, error: acpSelectionError }, + }) + : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { + instanceIdError, + selectionError: acpSelectionError, + }) : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { instanceIdError, }), @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(false); }; + const handleLocalAcpConfiguration = () => { + setDriver(ACP_REGISTRY_DRIVER_KIND); + setSelectedAcp(null); + setIsManualAcpConfiguration(true); + setConfigByDriver((existing) => ({ + ...existing, + [ACP_REGISTRY_DRIVER_KIND]: { source: "local", commandArgs: [] }, + })); + setIdentityByDriver((existing) => + updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { + label: "Local ACP", + instanceIdOverride: null, + }), + ); + setLocalEnvironment([]); + setHasAttemptedSubmit(false); + }; + const handleSave = async () => { if (isSaving || createdInstanceId) return; setHasAttemptedSubmit(true); @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), ...(hasConfig ? { config } : {}), + ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}), }; // `ProviderInstanceId.make` revalidates the slug; we've already checked // it via `validateInstanceId`, but going through the brand constructor @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ return; } onCreated?.(brandedId); - if (isAcpRegistry) { + if (isAcpRegistry && !isLocalAcp) { setCreatedInstanceId(brandedId); setIsSaving(false); setWizardStep(2); @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ currentStep={wizardStep} summaries={wizardStepSummaries} instanceIdError={instanceIdError} - steps={ACP_REGISTRY_WIZARD_STEPS} + steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS} disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} identityStep={1} prerequisite={{ step: 0, error: acpSelectionError }} @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({
-

Enter manually

+

+ {isLocalAcp ? "Local ACP command" : "Enter manually"} +

- Enter an official registry ID and any local executable or auth override. + {isLocalAcp + ? `Run an installed ACP executable on ${environmentLabel}.` + : "Enter an official registry ID and any local executable or auth override."}

) : ( )} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 415ebb97c029..9965925ad27e 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { ); } -function ProviderEnvironmentSection(props: { +export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; }) { @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ driverKind={driverKind ?? instance.driver} displayName={displayName} accentColor={accentColor} - acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined} - acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined} + acpRegistryAgentId={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "agentId") ?? undefined) + } + acpRegistryIconUrl={ + readConfigString(instance.config, "source") === "local" + ? undefined + : (readConfigString(instance.config, "registryIconUrl") ?? undefined) + } showBadge={Boolean(accentColor)} className="size-5" iconClassName="size-4 text-foreground/80" diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 5737e47f2a13..6edeedb30740 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { expect(acpRegistry).toBeDefined(); expect(acpRegistry?.hasDefaultInstance).toBe(false); expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ + "source", "agentId", "commandPath", "authMethodId", ]); }); + it("shows the local executable without registry identity or authentication fields", () => { + const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + expect( + deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), + ).toEqual(["source", "commandPath"]); + }); + it("derives a select control with its choices for the Antigravity sign-in method", () => { const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; expect(antigravity).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 94372b97c868..dfb35facbd60 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { @@ -9,8 +9,10 @@ import type { ProviderSettingsFormOption, ProviderSettingsFormSchemaAnnotation, } from "@t3tools/contracts"; +import { PlusIcon, XIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -76,7 +78,10 @@ function readFieldBooleanDefault( export function deriveProviderSettingsFields( definition: ProviderClientDefinition, + value?: unknown, ): ReadonlyArray { + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; + if (isLocalAcp && key !== "source" && key !== "commandPath") return []; const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( { key, control: formAnnotation.control ?? "text", - label: annotatedTitle ?? titleizeFieldKey(key), - ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), - ...(formAnnotation.placeholder !== undefined - ? { placeholder: formAnnotation.placeholder } - : {}), + label: + isLocalAcp && key === "commandPath" + ? "Executable" + : (annotatedTitle ?? titleizeFieldKey(key)), + ...(isLocalAcp && key === "commandPath" + ? { description: "Executable name or path on this environment." } + : annotatedDescription !== undefined + ? { description: annotatedDescription } + : {}), + ...(isLocalAcp && key === "commandPath" + ? { placeholder: "e.g. dsh" } + : formAnnotation.placeholder !== undefined + ? { placeholder: formAnnotation.placeholder } + : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( }); } +let commandArgumentDraftId = 0; +const makeCommandArgumentDraftRow = (value: string) => ({ + id: `provider-argument-${commandArgumentDraftId++}`, + value, +}); + +function commandArgumentsEqual(left: ReadonlyArray, right: ReadonlyArray) { + return left.length === right.length && left.every((argument, index) => argument === right[index]); +} + +function ProviderCommandArguments({ + value, + onChange, +}: Pick) { + const args = useMemo(() => { + const configured = + value !== null && typeof value === "object" + ? (value as Record).commandArgs + : undefined; + return Array.isArray(configured) + ? configured.filter((argument): argument is string => typeof argument === "string") + : []; + }, [value]); + const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow)); + const rowsRef = useRef(rows); + const previousArgsRef = useRef(args); + const lastPublishedArgsRef = useRef | undefined>(undefined); + + useEffect(() => { + const previousArgs = previousArgsRef.current; + const lastPublishedArgs = lastPublishedArgsRef.current; + previousArgsRef.current = args; + lastPublishedArgsRef.current = undefined; + if ( + commandArgumentsEqual(previousArgs, args) || + (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args)) + ) + return; + const nextRows = args.map(makeCommandArgumentDraftRow); + rowsRef.current = nextRows; + setRows(nextRows); + }, [args]); + + const updateArguments = (nextRows: typeof rows) => { + rowsRef.current = nextRows; + setRows(nextRows); + const next = nextRows.map((row) => row.value); + lastPublishedArgsRef.current = next; + const config = + value !== null && typeof value === "object" ? { ...(value as Record) } : {}; + onChange({ ...config, commandArgs: next }); + }; + + return ( + updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])} + > + + Add argument + + } + > + {rows.length > 0 ? ( +
+ {rows.map((argument, index) => ( +
+ + updateArguments( + rowsRef.current.map((current) => + current.id === argument.id ? { ...current, value: next } : current, + ), + ) + } + aria-label={`Argument ${index + 1}`} + spellCheck={false} + /> + +
+ ))} +
+ ) : null} +
+ ); +} + function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ variant, onChange, }: ProviderSettingsFormProps) { - const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]); + const fields = useMemo( + () => deriveProviderSettingsFields(definition, value), + [definition, value], + ); + const isLocalAcp = + definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; if (fields.length === 0) { return null; @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ onChange={onChange} /> ))} + {isLocalAcp ? : null} ); } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 41fd9efb5da4..cc8fe347c124 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ } if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; + if (providerConfigString(row.instance.config, "source") === "local") return; const agentId = providerConfigString(row.instance.config, "agentId"); if (agentId === null) return; diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a5bb26e691c0..4ff23880ade6 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { }); describe("provider icon metadata", () => { + it("clears stale registry branding when an instance switches to a local command", () => { + const instanceId = ProviderInstanceId.make("custom-acp"); + const driver = ProviderDriverKind.make("acpRegistry"); + const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; + const snapshots = deriveProviderInstanceEntries([ + { ...provider({ provider: driver, instanceId }), iconUrl }, + ]); + const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; + const brandedEntries = applyProviderInstanceSettings(snapshots, { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); + expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); + + const [localEntry] = applyProviderInstanceSettings(brandedEntries, { + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: { ...registryConfig, source: "local", commandPath: "dsh" }, + }, + }, + providers: {} as never, + }); + expect(localEntry?.acpRegistryAgentId).toBeUndefined(); + expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); + expect(localEntry?.enabled).toBe(false); + expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); + + const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { + providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, + providers: {} as never, + }); + expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); + expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); + expect(restoredEntry?.enabled).toBe(true); + }); + it("retains server-published registry icons without local settings", () => { const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; const [entry] = deriveProviderInstanceEntries([ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ae2ea5699bf5..60630c5f826b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( explicitInstance.config !== null && typeof explicitInstance.config === "object" ? (explicitInstance.config as Readonly>) : null; + if (config?.source === "local") { + return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; + } const agentId = config?.agentId; const iconUrl = config?.registryIconUrl; return { diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index adf4e17af631..9f179e88da0f 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -1,6 +1,6 @@ -# ACP Registry +# ACP providers -T3 Code can run coding agents from the official +T3 Code can run installed ACP commands or coding agents from the official [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task delegation. @@ -23,6 +23,17 @@ work through the same generic integration. Search only shows agents that can run on the connected server. Registry agents are third-party code; review an agent's source and license before adding it. +## Add a local command + +In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable +name or path on the selected environment, add one literal argument per row, and choose a display +name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`. + +Local commands require no registry entry or download. Environment overrides apply to the command; +credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning +options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly +without expanding shell expressions. + ## Where agents run Registry agents always run on the machine that hosts your T3 Code server. That stays true when you diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 082459efc3df..c2720d0a371f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr export const AcpRegistrySettings = makeProviderSettingsSchema( { + source: Schema.Literals(["registry", "local"]).pipe( + Schema.withDecodingDefault(Effect.succeed("registry")), + Schema.annotateKey({ + title: "ACP source", + providerSettingsForm: { + control: "select", + options: [ + { value: "registry", label: "ACP Registry" }, + { value: "local", label: "Local command" }, + ], + }, + }), + ), enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "Executable override", description: - "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", + "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, }), ), + commandArgs: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), authMethodId: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ @@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ), }, { - order: ["agentId", "commandPath", "authMethodId"], + order: ["source", "agentId", "commandPath", "authMethodId"], }, ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;