| author | |
| committer | |
| log | 9d029a1df25126745dd70ac561ff25d3f8fbac42 |
| tree | 8aaf43d965842d07272f2e5d9a21c9125a495b94 |
| parent | 4dbc0129c244d5869d7598a22f8d9386cca17884 |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 982 insertions(+), 250 deletions(-)
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+4| ... | @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | import * as EffectAcpErrors from "effect-acp/errors"; | 15 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 16 | 16 | ||
| 17 | import * as ServerConfig from "../../config.ts"; | 17 | import * as ServerConfig from "../../config.ts"; |
| 18 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 18 | import type { | 19 | import type { |
| 19 | AcpRegistryAvailableCommands, | 20 | AcpRegistryAvailableCommands, |
| 20 | AcpRegistryLiveConfiguration, | 21 | AcpRegistryLiveConfiguration, |
| ... | @@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll( | ... | @@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll( |
| 87 | IdAllocator.layer, | 88 | IdAllocator.layer, |
| 88 | serverConfigLayer, | 89 | serverConfigLayer, |
| 89 | registryLayer, | 90 | registryLayer, |
| 91 | ServerSettings.layerTest(), | ||
| 90 | ); | 92 | ); |
| 91 | 93 | ||
| 92 | describe("AcpRegistryAdapterV2", () => { | 94 | describe("AcpRegistryAdapterV2", () => { |
| ... | @@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => { | ... | @@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => { |
| 121 | assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); | 123 | assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); |
| 122 | assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); | 124 | assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); |
| 123 | assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { | 125 | assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { |
| 126 | source: "registry", | ||
| 127 | commandArgs: [], | ||
| 124 | enabled: true, | 128 | enabled: true, |
| 125 | agentId: "", | 129 | agentId: "", |
| 126 | commandPath: "", | 130 | commandPath: "", |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+7-4| ... | @@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { | ... | @@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { |
| 183 | 183 | ||
| 184 | export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { | 184 | export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { |
| 185 | const runtimeCoordinator = options.runtimeCoordinator; | 185 | const runtimeCoordinator = options.runtimeCoordinator; |
| 186 | const isDevin = options.settings.agentId === "devin"; | 186 | const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; |
| 187 | const startupKey = | ||
| 188 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; | ||
| 189 | const isDevin = registryAgentId === "devin"; | ||
| 187 | const flavor: AcpAdapterV2Flavor = { | 190 | const flavor: AcpAdapterV2Flavor = { |
| 188 | driver: ACP_REGISTRY_PROVIDER, | 191 | driver: ACP_REGISTRY_PROVIDER, |
| 189 | capabilities: AcpProviderCapabilitiesV2, | 192 | capabilities: AcpProviderCapabilitiesV2, |
| 190 | promptFailure: (cause) => acpRegistryPromptFailure(options.settings.agentId, cause), | 193 | promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), |
| 191 | // Per-agent exceptions (Mistral Vibe, Devin): see the note above | 194 | // Per-agent exceptions (Mistral Vibe, Devin): see the note above |
| 192 | // registerMistralVibeAcpExtensions before adding any more. | 195 | // registerMistralVibeAcpExtensions before adding any more. |
| 193 | ...(options.settings.agentId === "mistral-vibe" | 196 | ...(registryAgentId === "mistral-vibe" |
| 194 | ? { registerExtensions: registerMistralVibeAcpExtensions } | 197 | ? { registerExtensions: registerMistralVibeAcpExtensions } |
| 195 | : {}), | 198 | : {}), |
| 196 | ...(isDevin | 199 | ...(isDevin |
| ... | @@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { | ... | @@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { |
| 230 | }); | 233 | }); |
| 231 | }, | 234 | }, |
| 232 | withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) => | 235 | withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 233 | runtimeCoordinator.withForegroundStartup(options.settings.agentId, effect), | 236 | runtimeCoordinator.withForegroundStartup(startupKey, effect), |
| 234 | }), | 237 | }), |
| 235 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | 238 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), |
| 236 | }; | 239 | }; |
apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts+63-42| ... | @@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => { | ... | @@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => { |
| 201 | }), | 201 | }), |
| 202 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); | 202 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); |
| 203 | 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 | |||
| 204 | expect( | 217 | expect( |
| 205 | acpRegistrySnapshotReadiness({ | 218 | acpRegistrySnapshotReadiness({ |
| 206 | status: "unprepared", | 219 | status: "unprepared", |
| ... | @@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => { | ... | @@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => { |
| 312 | expect(snapshot.models[0]?.isDefault).toBe(true); | 325 | expect(snapshot.models[0]?.isDefault).toBe(true); |
| 313 | }); | 326 | }); |
| 314 | 327 | ||
| 315 | it("reports failed authentication without hiding successful local inspection", () => { | 328 | it.each(["registry", "local"] as const)( |
| 316 | const snapshot = buildCheckedAcpRegistrySnapshot({ | 329 | "reports failed authentication after successful %s inspection", |
| 317 | ...identity, | 330 | (source) => { |
| 318 | settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }), | 331 | const snapshot = buildCheckedAcpRegistrySnapshot({ |
| 319 | checkedAt: "2026-08-13T10:00:00.000Z", | 332 | ...identity, |
| 320 | inspection: { | 333 | settings: decodeSettings({ |
| 321 | status: "ready", | 334 | source, |
| 322 | agentId: "test-agent", | 335 | ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), |
| 323 | version: "1.0.0", | 336 | authMethodId: "grok-login", |
| 324 | distribution: "binary", | 337 | }), |
| 325 | }, | 338 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 326 | probeError: new AcpRegistryOperationError({ | 339 | inspection: { |
| 327 | reason: "authentication_failed", | 340 | status: "ready", |
| 328 | message: "Login required.", | 341 | agentId: "test-agent", |
| 329 | authMethods: [ | 342 | version: source === "local" ? null : "1.0.0", |
| 330 | { | 343 | distribution: source === "local" ? "local" : "binary", |
| 331 | id: "api-key", | 344 | }, |
| 332 | name: "API key", | 345 | probeError: new AcpRegistryOperationError({ |
| 333 | description: null, | 346 | reason: "authentication_failed", |
| 334 | type: "env_var", | 347 | message: "Login required.", |
| 335 | }, | 348 | authMethods: [ |
| 336 | { | 349 | { |
| 337 | id: "grok-login", | 350 | id: "api-key", |
| 338 | name: "Log in with Grok", | 351 | name: "API key", |
| 339 | description: null, | 352 | description: null, |
| 340 | type: "agent", | 353 | type: "env_var", |
| 341 | }, | 354 | }, |
| 342 | ], | 355 | { |
| 343 | }), | 356 | id: "grok-login", |
| 344 | }); | 357 | name: "Log in with Grok", |
| 358 | description: null, | ||
| 359 | type: "agent", | ||
| 360 | }, | ||
| 361 | ], | ||
| 362 | }), | ||
| 363 | }); | ||
| 345 | 364 | ||
| 346 | expect(snapshot).toMatchObject({ | 365 | expect(snapshot).toMatchObject({ |
| 347 | installed: true, | 366 | installed: true, |
| 348 | version: "1.0.0", | 367 | version: source === "local" ? null : "1.0.0", |
| 349 | status: "warning", | 368 | status: "warning", |
| 350 | auth: { | 369 | setup: { canAuthenticate: true }, |
| 351 | status: "unauthenticated", | 370 | auth: { |
| 352 | type: "agent", | 371 | status: "unauthenticated", |
| 353 | label: "Log in with Grok", | 372 | type: "agent", |
| 354 | }, | 373 | label: "Log in with Grok", |
| 355 | message: 'Sign in in provider settings using "Log in with Grok".', | 374 | }, |
| 356 | }); | 375 | message: 'Sign in in provider settings using "Log in with Grok".', |
| 357 | }); | 376 | }); |
| 377 | }, | ||
| 378 | ); | ||
| 358 | 379 | ||
| 359 | it.effect("runs the disposable probe only after local inspection is ready", () => | 380 | it.effect("runs the disposable probe only after local inspection is ready", () => |
| 360 | Effect.gen(function* () { | 381 | Effect.gen(function* () { |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+17-6| ... | @@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness( | ... | @@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness( |
| 138 | installed: false, | 138 | installed: false, |
| 139 | version: null, | 139 | version: null, |
| 140 | status: "warning", | 140 | status: "warning", |
| 141 | message: "Select an ACP Registry agent before starting a thread.", | 141 | message: |
| 142 | "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", | ||
| 142 | }; | 143 | }; |
| 143 | case "not_found": | 144 | case "not_found": |
| 144 | return { | 145 | return { |
| ... | @@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness( | ... | @@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness( |
| 159 | installed: false, | 160 | installed: false, |
| 160 | version: inspection.version, | 161 | version: inspection.version, |
| 161 | status: "error", | 162 | status: "error", |
| 162 | message: `ACP Registry agent '${inspection.agentId}' requires '${inspection.runner}' on this environment's PATH.`, | 163 | message: |
| 164 | inspection.distribution === "local" | ||
| 165 | ? "Local ACP executable is not available on this environment's PATH." | ||
| 166 | : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, | ||
| 163 | }; | 167 | }; |
| 164 | case "unprepared": | 168 | case "unprepared": |
| 165 | return { | 169 | return { |
| ... | @@ -196,11 +200,14 @@ function baseSnapshot( | ... | @@ -196,11 +200,14 @@ function baseSnapshot( |
| 196 | readonly documentationUrl?: string; | 200 | readonly documentationUrl?: string; |
| 197 | readonly message?: string; | 201 | readonly message?: string; |
| 198 | readonly probe?: AcpRegistryConfigurationProbeResult; | 202 | readonly probe?: AcpRegistryConfigurationProbeResult; |
| 203 | readonly probeError?: AcpRegistryOperationError; | ||
| 199 | }, | 204 | }, |
| 200 | ): ServerProvider { | 205 | ): ServerProvider { |
| 201 | const iconUrl = | 206 | const iconUrl = |
| 202 | resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | 207 | input.settings.source === "local" |
| 203 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId); | 208 | ? null |
| 209 | : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | ||
| 210 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); | ||
| 204 | return { | 211 | return { |
| 205 | instanceId: input.instanceId, | 212 | instanceId: input.instanceId, |
| 206 | driver: DRIVER_KIND, | 213 | driver: DRIVER_KIND, |
| ... | @@ -225,7 +232,9 @@ function baseSnapshot( | ... | @@ -225,7 +232,9 @@ function baseSnapshot( |
| 225 | input.installed && | 232 | input.installed && |
| 226 | (input.probe | 233 | (input.probe |
| 227 | ? input.probe.probe.authMethods.length > 0 | 234 | ? input.probe.probe.authMethods.length > 0 |
| 228 | : input.settings.agentId.length > 0), | 235 | : input.settings.source === "local" |
| 236 | ? (input.probeError?.authMethods?.length ?? 0) > 0 | ||
| 237 | : input.settings.agentId.length > 0), | ||
| 229 | }, | 238 | }, |
| 230 | ...(input.message ? { message: input.message } : {}), | 239 | ...(input.message ? { message: input.message } : {}), |
| 231 | models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), | 240 | models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), |
| ... | @@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD | ... | @@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 633 | const publishEnrichment = ( | 642 | const publishEnrichment = ( |
| 634 | Option.isSome(runtimeCoordinator) | 643 | Option.isSome(runtimeCoordinator) |
| 635 | ? runtimeCoordinator.value.runBackgroundProbe( | 644 | ? runtimeCoordinator.value.runBackgroundProbe( |
| 636 | effectiveConfig.agentId, | 645 | effectiveConfig.source === "local" |
| 646 | ? `local:${instanceId}` | ||
| 647 | : effectiveConfig.agentId, | ||
| 637 | enrichProviderCached(snapshot), | 648 | enrichProviderCached(snapshot), |
| 638 | ) | 649 | ) |
| 639 | : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) | 650 | : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) |
apps/server/src/provider/acp/AcpRegistryAuth.test.ts+28-3| ... | @@ -57,7 +57,12 @@ const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = { | ... | @@ -57,7 +57,12 @@ const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = { |
| 57 | uninstallManagedBinary: () => Effect.die("unused"), | 57 | uninstallManagedBinary: () => Effect.die("unused"), |
| 58 | }; | 58 | }; |
| 59 | 59 | ||
| 60 | const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => | 60 | const makeHarness = ( |
| 61 | method: AcpSchema.AuthMethod, | ||
| 62 | failVerification = false, | ||
| 63 | settings = decodeSettings({ agentId: "test-agent" }), | ||
| 64 | providerInstanceId = instanceId, | ||
| 65 | ) => | ||
| 61 | Effect.gen(function* () { | 66 | Effect.gen(function* () { |
| 62 | const verify = yield* Deferred.make<void>(); | 67 | const verify = yield* Deferred.make<void>(); |
| 63 | const changed: boolean[] = []; | 68 | const changed: boolean[] = []; |
| ... | @@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => | ... | @@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => |
| 74 | const written: string[] = []; | 79 | const written: string[] = []; |
| 75 | const sizes: number[][] = []; | 80 | const sizes: number[][] = []; |
| 76 | const controller = yield* makeAcpRegistryAuth({ | 81 | const controller = yield* makeAcpRegistryAuth({ |
| 77 | instanceId, | 82 | instanceId: providerInstanceId, |
| 78 | settings: decodeSettings({ agentId: "test-agent" }), | 83 | settings, |
| 79 | cwd: "/workspace", | 84 | cwd: "/workspace", |
| 80 | environment: { PATH: "/tools", OVERRIDE: "base" }, | 85 | environment: { PATH: "/tools", OVERRIDE: "base" }, |
| 81 | onChanged: (value) => | 86 | onChanged: (value) => |
| ... | @@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => | ... | @@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => |
| 211 | }; | 216 | }; |
| 212 | }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); | 217 | }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); |
| 213 | 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 | |||
| 214 | it.effect( | 239 | it.effect( |
| 215 | "discovers methods without signing in, then waits for browser consent and session verification", | 240 | "discovers methods without signing in, then waits for browser consent and session verification", |
| 216 | () => | 241 | () => |
apps/server/src/provider/acp/AcpRegistryAuth.ts+6-6| ... | @@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o | ... | @@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 45 | const coordinator = yield* Effect.serviceOption( | 45 | const coordinator = yield* Effect.serviceOption( |
| 46 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, | 46 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, |
| 47 | ); | 47 | ); |
| 48 | const agentKey = | ||
| 49 | options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; | ||
| 48 | const failure = (operation: string, detail: string, cause?: unknown) => | 50 | const failure = (operation: string, detail: string, cause?: unknown) => |
| 49 | new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); | 51 | new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); |
| 50 | const resolve = catalog | 52 | const resolve = catalog |
| ... | @@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o | ... | @@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 139 | 141 | ||
| 140 | const methods = Option.isSome(coordinator) | 142 | const methods = Option.isSome(coordinator) |
| 141 | ? coordinator.value | 143 | ? coordinator.value |
| 142 | .runBackgroundProbe(options.settings.agentId, discoverMethods) | 144 | .runBackgroundProbe(agentKey, discoverMethods) |
| 143 | .pipe( | 145 | .pipe( |
| 144 | Effect.flatMap((result) => | 146 | Effect.flatMap((result) => |
| 145 | Option.isSome(result) | 147 | Option.isSome(result) |
| ... | @@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o | ... | @@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 327 | }), | 329 | }), |
| 328 | ); | 330 | ); |
| 329 | yield* Option.isSome(coordinator) | 331 | yield* Option.isSome(coordinator) |
| 330 | ? coordinator.value.withForegroundStartup(options.settings.agentId, login) | 332 | ? coordinator.value.withForegroundStartup(agentKey, login) |
| 331 | : login; | 333 | : login; |
| 332 | yield* options.onChanged(true); | 334 | yield* options.onChanged(true); |
| 333 | }); | 335 | }); |
| ... | @@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o | ... | @@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 353 | }), | 355 | }), |
| 354 | ); | 356 | ); |
| 355 | const signOut = ( | 357 | const signOut = ( |
| 356 | Option.isSome(coordinator) | 358 | Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout |
| 357 | ? coordinator.value.withForegroundStartup(options.settings.agentId, logout) | ||
| 358 | : logout | ||
| 359 | ).pipe(Effect.andThen(options.onChanged(false))); | 359 | ).pipe(Effect.andThen(options.onChanged(false))); |
| 360 | return yield* ProviderAuthFlow.make({ | 360 | return yield* ProviderAuthFlow.make({ |
| 361 | instanceId: options.instanceId, | 361 | instanceId: options.instanceId, |
| 362 | // ACP doesn't advertise its credential scope. Conservatively treat all | 362 | // ACP doesn't advertise its credential scope. Conservatively treat all |
| 363 | // instances of the same agent on this environment as sharing credentials. | 363 | // instances of the same agent on this environment as sharing credentials. |
| 364 | credentialBinding: { owner: "provider", key: `acp:${options.settings.agentId}` }, | 364 | credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, |
| 365 | methods, | 365 | methods, |
| 366 | ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), | 366 | ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), |
| 367 | authenticate, | 367 | authenticate, |
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+3| ... | @@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth | ... | @@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth |
| 34 | // Cosmetic settings and model discovery can rebuild the driver without | 34 | // Cosmetic settings and model discovery can rebuild the driver without |
| 35 | // changing the account. Credential overrides and profile paths cannot. | 35 | // changing the account. Credential overrides and profile paths cannot. |
| 36 | const binding = hash({ | 36 | const binding = hash({ |
| 37 | ...(input.settings.source === "local" | ||
| 38 | ? { source: "local", commandArgs: input.settings.commandArgs } | ||
| 39 | : {}), | ||
| 37 | agentId: input.settings.agentId, | 40 | agentId: input.settings.agentId, |
| 38 | commandPath: input.settings.commandPath, | 41 | commandPath: input.settings.commandPath, |
| 39 | distribution: input.settings.distribution, | 42 | distribution: input.settings.distribution, |
apps/server/src/provider/acp/AcpRegistryProbe.test.ts+69| ... | @@ -222,6 +222,75 @@ describe("ACP Registry probe", () => { | ... | @@ -222,6 +222,75 @@ describe("ACP Registry probe", () => { |
| 222 | expect(result.currentModelId).toBeNull(); | 222 | expect(result.currentModelId).toBeNull(); |
| 223 | }); | 223 | }); |
| 224 | 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 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 | |||
| 225 | it("omits a current model that falls outside the bounded model catalog", () => { | 294 | it("omits a current model that falls outside the bounded model catalog", () => { |
| 226 | const result = acpRegistryProbeResult(instanceId, { | 295 | const result = acpRegistryProbeResult(instanceId, { |
| 227 | sessionId: "probe-session", | 296 | sessionId: "probe-session", |
apps/server/src/provider/acp/AcpRegistryProbe.ts+1-1| ... | @@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo | ... | @@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo |
| 440 | probe: acpRegistryProbeResult( | 440 | probe: acpRegistryProbeResult( |
| 441 | input.instanceId, | 441 | input.instanceId, |
| 442 | result.started, | 442 | result.started, |
| 443 | result.resolved.agent.icon ?? null, | 443 | result.resolved.agent?.icon ?? null, |
| 444 | { | 444 | { |
| 445 | command: result.resolved.spawn.command, | 445 | command: result.resolved.spawn.command, |
| 446 | args: result.resolved.spawn.args, | 446 | args: result.resolved.spawn.args, |
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+243-55| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { AcpRegistrySettings } from "@t3tools/contracts"; | 3 | import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; |
| 4 | import { | 4 | import { |
| 5 | HostProcessArchitecture, | 5 | HostProcessArchitecture, |
| 6 | HostProcessEnvironment, | 6 | HostProcessEnvironment, |
| ... | @@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 14 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Schema from "effect/Schema"; | 16 | import * as Schema from "effect/Schema"; |
| 19 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 17 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | 18 | import * as TestClock from "effect/testing/TestClock"; |
| ... | @@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto"; | ... | @@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto"; |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 21 | ||
| 24 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; | 22 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 23 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 25 | 24 | ||
| 26 | const registryUrl = "https://registry.test/registry.json"; | 25 | const registryUrl = "https://registry.test/registry.json"; |
| 27 | const archiveUrl = "https://registry.test/example-agent.bin"; | 26 | const archiveUrl = "https://registry.test/example-agent.bin"; |
| ... | @@ -57,6 +56,7 @@ function resolverLayer( | ... | @@ -57,6 +56,7 @@ function resolverLayer( |
| 57 | ) { | 56 | ) { |
| 58 | return Layer.mergeAll( | 57 | return Layer.mergeAll( |
| 59 | NodeServices.layer, | 58 | NodeServices.layer, |
| 59 | ServerSettings.layerTest(), | ||
| 60 | Layer.succeed(HostProcessPlatform, "linux"), | 60 | Layer.succeed(HostProcessPlatform, "linux"), |
| 61 | Layer.succeed(HostProcessArchitecture, "x64"), | 61 | Layer.succeed(HostProcessArchitecture, "x64"), |
| 62 | Layer.succeed(HostProcessEnvironment, environment), | 62 | Layer.succeed(HostProcessEnvironment, environment), |
| ... | @@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => { | ... | @@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => { |
| 209 | ).toBeUndefined(); | 209 | ).toBeUndefined(); |
| 210 | }); | 210 | }); |
| 211 | 211 | ||
| 212 | it.effect("resolves a local command on the selected environment without the registry", () => { | ||
| 213 | const requests: Array<string> = []; | ||
| 214 | return Effect.gen(function* () { | ||
| 215 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 216 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); | ||
| 217 | const commandPath = `${cacheDir}/dsh`; | ||
| 218 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | ||
| 219 | yield* fileSystem.chmod(commandPath, 0o755); | ||
| 220 | const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; | ||
| 221 | const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; | ||
| 222 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | ||
| 223 | cacheDir, | ||
| 224 | toolsDir: `${cacheDir}/tools`, | ||
| 225 | registryUrl, | ||
| 226 | }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); | ||
| 227 | const localSettings = decodeAcpRegistrySettings({ | ||
| 228 | source: "local", | ||
| 229 | commandPath: "dsh", | ||
| 230 | commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | ||
| 231 | }); | ||
| 232 | |||
| 233 | expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); | ||
| 234 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | ||
| 235 | status: "ready", | ||
| 236 | agentId: "dsh", | ||
| 237 | version: null, | ||
| 238 | distribution: "local", | ||
| 239 | }); | ||
| 240 | const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); | ||
| 241 | expect(resolved).toEqual({ | ||
| 242 | distribution: "local", | ||
| 243 | spawn: { | ||
| 244 | command: commandPath, | ||
| 245 | args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | ||
| 246 | cwd: "/workspace", | ||
| 247 | env: environment, | ||
| 248 | shell: false, | ||
| 249 | }, | ||
| 250 | }); | ||
| 251 | expect(requests).toEqual([]); | ||
| 252 | expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); | ||
| 253 | }).pipe( | ||
| 254 | Effect.scoped, | ||
| 255 | Effect.provide( | ||
| 256 | resolverLayer((request) => { | ||
| 257 | requests.push(request.url); | ||
| 258 | return Effect.die("unexpected registry request for a local provider"); | ||
| 259 | }), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | }); | ||
| 263 | |||
| 264 | it.effect("resolves a local executable path and inherits the host environment", () => | ||
| 265 | Effect.gen(function* () { | ||
| 266 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 267 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); | ||
| 268 | const commandPath = `${cacheDir}/dsh wrapper`; | ||
| 269 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | ||
| 270 | yield* fileSystem.chmod(commandPath, 0o755); | ||
| 271 | const environment = { PATH: "/unused", INHERITED: "host" }; | ||
| 272 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | ||
| 273 | cacheDir, | ||
| 274 | toolsDir: `${cacheDir}/tools`, | ||
| 275 | registryUrl, | ||
| 276 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | ||
| 277 | const resolved = yield* resolver.resolve( | ||
| 278 | decodeAcpRegistrySettings({ source: "local", commandPath }), | ||
| 279 | "/workspace", | ||
| 280 | ); | ||
| 281 | |||
| 282 | expect(resolved.spawn).toEqual({ | ||
| 283 | command: commandPath, | ||
| 284 | args: [], | ||
| 285 | cwd: "/workspace", | ||
| 286 | env: environment, | ||
| 287 | shell: false, | ||
| 288 | }); | ||
| 289 | expect(resolved.agent).toBeUndefined(); | ||
| 290 | |||
| 291 | for (const extension of [".cmd", ".BAT"]) { | ||
| 292 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | ||
| 293 | cacheDir, | ||
| 294 | toolsDir: `${cacheDir}/tools`, | ||
| 295 | registryUrl, | ||
| 296 | }).pipe( | ||
| 297 | Effect.provideService(HostProcessPlatform, "win32"), | ||
| 298 | Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), | ||
| 299 | ); | ||
| 300 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); | ||
| 301 | for (const failure of [ | ||
| 302 | yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), | ||
| 303 | yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), | ||
| 304 | ]) { | ||
| 305 | expect(failure).toMatchObject({ | ||
| 306 | reason: "runner_unavailable", | ||
| 307 | detail: expect.stringContaining("node.exe"), | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | } | ||
| 311 | }).pipe( | ||
| 312 | Effect.scoped, | ||
| 313 | Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), | ||
| 314 | ), | ||
| 315 | ); | ||
| 316 | |||
| 317 | it.effect("reports missing or non-executable local commands without registry fallback", () => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 320 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 321 | prefix: "t3-acp-local-missing-", | ||
| 322 | }); | ||
| 323 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | ||
| 324 | cacheDir, | ||
| 325 | toolsDir: `${cacheDir}/tools`, | ||
| 326 | registryUrl, | ||
| 327 | }); | ||
| 328 | const environment = { PATH: cacheDir }; | ||
| 329 | const unconfigured = decodeAcpRegistrySettings({ source: "local" }); | ||
| 330 | expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ | ||
| 331 | status: "unconfigured", | ||
| 332 | }); | ||
| 333 | expect( | ||
| 334 | yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), | ||
| 335 | ).toMatchObject({ reason: "agent_not_configured" }); | ||
| 336 | |||
| 337 | const nonExecutable = `${cacheDir}/non-executable`; | ||
| 338 | yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); | ||
| 339 | yield* fileSystem.chmod(nonExecutable, 0o644); | ||
| 340 | for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { | ||
| 341 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); | ||
| 342 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | ||
| 343 | status: "missing_runner", | ||
| 344 | version: null, | ||
| 345 | distribution: "local", | ||
| 346 | }); | ||
| 347 | expect( | ||
| 348 | yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), | ||
| 349 | ).toMatchObject({ | ||
| 350 | reason: "runner_unavailable", | ||
| 351 | detail: "Local ACP executable is not available on this environment's PATH.", | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | }).pipe( | ||
| 355 | Effect.scoped, | ||
| 356 | Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), | ||
| 357 | ), | ||
| 358 | ); | ||
| 359 | |||
| 212 | it.effect("resolves command overrides while preserving registry args and environment", () => { | 360 | it.effect("resolves command overrides while preserving registry args and environment", () => { |
| 213 | const agent = makeAgent({ | 361 | const agent = makeAgent({ |
| 214 | binary: { | 362 | binary: { |
| ... | @@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => { | ... | @@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => { |
| 619 | ); | 767 | ); |
| 620 | }).pipe( | 768 | }).pipe( |
| 621 | Effect.scoped, | 769 | Effect.scoped, |
| 622 | Effect.provide(NodeServices.layer), | 770 | Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), |
| 623 | Effect.provideService(HostProcessPlatform, "linux"), | 771 | Effect.provideService(HostProcessPlatform, "linux"), |
| 624 | Effect.provideService(HostProcessArchitecture, "x64"), | 772 | Effect.provideService(HostProcessArchitecture, "x64"), |
| 625 | ); | 773 | ); |
| ... | @@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => { | ... | @@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => { |
| 1188 | ); | 1336 | ); |
| 1189 | }); | 1337 | }); |
| 1190 | 1338 | ||
| 1191 | it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => { | 1339 | it.effect.each([ |
| 1192 | return Effect.gen(function* () { | 1340 | { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, |
| 1193 | const fileSystem = yield* FileSystem.FileSystem; | 1341 | { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, |
| 1194 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | 1342 | { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, |
| 1195 | prefix: "t3-acp-registry-uninstall-", | 1343 | { name: "local command", source: "local", agentId: "example-agent", referenced: false }, |
| 1196 | }); | 1344 | ] as const)( |
| 1197 | const agentRoot = `${cacheDir}/tools/example-agent`; | 1345 | "checks $name references before uninstalling only managed binaries", |
| 1198 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | 1346 | ({ source, agentId, referenced }) => { |
| 1199 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); | 1347 | return Effect.gen(function* () { |
| 1200 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); | 1348 | const fileSystem = yield* FileSystem.FileSystem; |
| 1201 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { | 1349 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 1202 | recursive: true, | 1350 | prefix: "t3-acp-registry-uninstall-", |
| 1203 | }); | 1351 | }); |
| 1204 | yield* fileSystem.writeFileString(runnerCache, "{}"); | 1352 | const agentRoot = `${cacheDir}/tools/example-agent`; |
| 1205 | 1353 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | |
| 1206 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | 1354 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); |
| 1207 | cacheDir, | 1355 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); |
| 1208 | toolsDir: `${cacheDir}/tools`, | 1356 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { |
| 1209 | registryUrl, | 1357 | recursive: true, |
| 1210 | }); | 1358 | }); |
| 1211 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | 1359 | yield* fileSystem.writeFileString(runnerCache, "{}"); |
| 1212 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | ||
| 1213 | 1360 | ||
| 1214 | expect(first).toEqual({ agentId: "example-agent", removed: true }); | 1361 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ |
| 1215 | expect(second).toEqual({ agentId: "example-agent", removed: false }); | 1362 | cacheDir, |
| 1216 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | 1363 | toolsDir: `${cacheDir}/tools`, |
| 1217 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | 1364 | registryUrl, |
| 1218 | }).pipe( | 1365 | }); |
| 1219 | Effect.scoped, | 1366 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1220 | Effect.provide( | 1367 | const instanceId = ProviderInstanceId.make("uninstall-reference"); |
| 1221 | resolverLayer((request) => | 1368 | yield* serverSettings.updateProviderInstance({ |
| 1222 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | 1369 | operation: "upsert", |
| 1370 | instanceId, | ||
| 1371 | instance: { | ||
| 1372 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 1373 | displayName: "Uninstall reference", | ||
| 1374 | enabled: true, | ||
| 1375 | config: { | ||
| 1376 | agentId, | ||
| 1377 | commandPath: "/local/agent", | ||
| 1378 | ...(source ? { source } : {}), | ||
| 1379 | }, | ||
| 1380 | }, | ||
| 1381 | }); | ||
| 1382 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | ||
| 1383 | expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); | ||
| 1384 | expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); | ||
| 1385 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | ||
| 1386 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | ||
| 1387 | |||
| 1388 | expect(second).toEqual({ agentId: "example-agent", removed: referenced }); | ||
| 1389 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | ||
| 1390 | agentId: "example-agent", | ||
| 1391 | removed: false, | ||
| 1392 | }); | ||
| 1393 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | ||
| 1394 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | ||
| 1395 | }).pipe( | ||
| 1396 | Effect.scoped, | ||
| 1397 | Effect.provide( | ||
| 1398 | resolverLayer((request) => | ||
| 1399 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | ||
| 1400 | ), | ||
| 1223 | ), | 1401 | ), |
| 1224 | ), | 1402 | ); |
| 1225 | ); | 1403 | }, |
| 1226 | }); | 1404 | ); |
| 1227 | 1405 | ||
| 1228 | it.effect("keeps managed package installs when removing the same agent's binaries", () => | 1406 | it.effect("keeps managed package installs when removing the same agent's binaries", () => |
| 1229 | Effect.gen(function* () { | 1407 | Effect.gen(function* () { |
| ... | @@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => { | ... | @@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => { |
| 1272 | return Effect.gen(function* () { | 1450 | return Effect.gen(function* () { |
| 1273 | const downloadStarted = yield* Deferred.make<void>(); | 1451 | const downloadStarted = yield* Deferred.make<void>(); |
| 1274 | const releaseDownload = yield* Deferred.make<void>(); | 1452 | const releaseDownload = yield* Deferred.make<void>(); |
| 1275 | const referenceChecked = yield* Deferred.make<void>(); | ||
| 1276 | const isReferenced = yield* Ref.make(false); | ||
| 1277 | return yield* Effect.gen(function* () { | 1453 | return yield* Effect.gen(function* () { |
| 1278 | const fileSystem = yield* FileSystem.FileSystem; | 1454 | const fileSystem = yield* FileSystem.FileSystem; |
| 1279 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | 1455 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ |
| ... | @@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => { | ... | @@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => { |
| 1290 | .pipe(Effect.forkChild({ startImmediately: true })); | 1466 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1291 | yield* Deferred.await(downloadStarted); | 1467 | yield* Deferred.await(downloadStarted); |
| 1292 | const uninstallFiber = yield* resolver | 1468 | const uninstallFiber = yield* resolver |
| 1293 | .uninstallManagedBinary( | 1469 | .uninstallManagedBinary({ agentId: agent.id }) |
| 1294 | { agentId: agent.id }, | ||
| 1295 | Deferred.succeed(referenceChecked, undefined).pipe( | ||
| 1296 | Effect.andThen(Ref.get(isReferenced)), | ||
| 1297 | ), | ||
| 1298 | ) | ||
| 1299 | .pipe(Effect.forkChild({ startImmediately: true })); | 1470 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1300 | 1471 | ||
| 1301 | expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true); | 1472 | expect(uninstallFiber.pollUnsafe()).toBeUndefined(); |
| 1302 | yield* Ref.set(isReferenced, true); | ||
| 1303 | yield* Deferred.succeed(releaseDownload, undefined); | 1473 | yield* Deferred.succeed(releaseDownload, undefined); |
| 1304 | 1474 | ||
| 1305 | expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); | 1475 | expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); |
| ... | @@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => { | ... | @@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => { |
| 1310 | const agentRoot = `${cacheDir}/tools/${agent.id}`; | 1480 | const agentRoot = `${cacheDir}/tools/${agent.id}`; |
| 1311 | expect(yield* fileSystem.exists(agentRoot)).toBe(true); | 1481 | expect(yield* fileSystem.exists(agentRoot)).toBe(true); |
| 1312 | 1482 | ||
| 1313 | yield* Ref.set(isReferenced, false); | 1483 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1314 | expect( | 1484 | const instanceId = ProviderInstanceId.make("registry-reference"); |
| 1315 | yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), | 1485 | yield* serverSettings.updateProviderInstance({ |
| 1316 | ).toEqual({ agentId: agent.id, removed: true }); | 1486 | operation: "upsert", |
| 1317 | expect( | 1487 | instanceId, |
| 1318 | yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), | 1488 | instance: { |
| 1319 | ).toEqual({ agentId: agent.id, removed: false }); | 1489 | driver: ProviderDriverKind.make("acpRegistry"), |
| 1490 | displayName: "Registry reference", | ||
| 1491 | enabled: true, | ||
| 1492 | config: { agentId: agent.id }, | ||
| 1493 | }, | ||
| 1494 | }); | ||
| 1495 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | ||
| 1496 | agentId: agent.id, | ||
| 1497 | removed: false, | ||
| 1498 | }); | ||
| 1499 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | ||
| 1500 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | ||
| 1501 | agentId: agent.id, | ||
| 1502 | removed: true, | ||
| 1503 | }); | ||
| 1504 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | ||
| 1505 | agentId: agent.id, | ||
| 1506 | removed: false, | ||
| 1507 | }); | ||
| 1320 | }).pipe( | 1508 | }).pipe( |
| 1321 | Effect.scoped, | 1509 | Effect.scoped, |
| 1322 | Effect.provide( | 1510 | Effect.provide( |
apps/server/src/provider/acp/AcpRegistrySupport.ts+149-70| ... | @@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 40 | import * as NodeCrypto from "node:crypto"; | 40 | import * as NodeCrypto from "node:crypto"; |
| 41 | 41 | ||
| 42 | import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; | 42 | import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; |
| 43 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 43 | import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; | 44 | import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; |
| 44 | 45 | ||
| 45 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; | 46 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; |
| ... | @@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr | ... | @@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr |
| 209 | }); | 210 | }); |
| 210 | } | 211 | } |
| 211 | 212 | ||
| 212 | export const isAcpRegistryError = Schema.is(AcpRegistryError); | 213 | const isAcpRegistryError = Schema.is(AcpRegistryError); |
| 213 | 214 | ||
| 214 | export type AcpRegistryPlatformTarget = | 215 | export type AcpRegistryPlatformTarget = |
| 215 | | "darwin-aarch64" | 216 | | "darwin-aarch64" |
| ... | @@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: { | ... | @@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: { |
| 439 | } | 440 | } |
| 440 | 441 | ||
| 441 | export interface ResolvedAcpRegistryAgent { | 442 | export interface ResolvedAcpRegistryAgent { |
| 442 | readonly agent: AcpRegistryAgent; | 443 | readonly agent?: AcpRegistryAgent; |
| 443 | readonly distribution: AcpRegistryDistributionKind; | 444 | readonly distribution: AcpRegistryDistributionKind | "local"; |
| 444 | readonly spawn: AcpSpawnInput; | 445 | readonly spawn: AcpSpawnInput; |
| 445 | } | 446 | } |
| 446 | 447 | ||
| ... | @@ -459,6 +460,11 @@ export type AcpRegistryInspection = | ... | @@ -459,6 +460,11 @@ export type AcpRegistryInspection = |
| 459 | readonly distribution: AcpRegistryDistributionKind; | 460 | readonly distribution: AcpRegistryDistributionKind; |
| 460 | readonly runner: string; | 461 | readonly runner: string; |
| 461 | } | 462 | } |
| 463 | | { | ||
| 464 | readonly status: "missing_runner"; | ||
| 465 | readonly version: null; | ||
| 466 | readonly distribution: "local"; | ||
| 467 | } | ||
| 462 | | { | 468 | | { |
| 463 | readonly status: "unprepared"; | 469 | readonly status: "unprepared"; |
| 464 | readonly agentId: string; | 470 | readonly agentId: string; |
| ... | @@ -469,7 +475,7 @@ export type AcpRegistryInspection = | ... | @@ -469,7 +475,7 @@ export type AcpRegistryInspection = |
| 469 | readonly status: "ready"; | 475 | readonly status: "ready"; |
| 470 | readonly agentId: string; | 476 | readonly agentId: string; |
| 471 | readonly version: string | null; | 477 | readonly version: string | null; |
| 472 | readonly distribution: AcpRegistryDistributionKind; | 478 | readonly distribution: AcpRegistryDistributionKind | "local"; |
| 473 | readonly documentationUrl?: string; | 479 | readonly documentationUrl?: string; |
| 474 | }; | 480 | }; |
| 475 | 481 | ||
| ... | @@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service< | ... | @@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service< |
| 493 | ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; | 499 | ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; |
| 494 | readonly uninstallManagedBinary: ( | 500 | readonly uninstallManagedBinary: ( |
| 495 | input: AcpRegistryManagedBinaryUninstallInput, | 501 | input: AcpRegistryManagedBinaryUninstallInput, |
| 496 | isReferenced?: Effect.Effect<boolean, AcpRegistryError>, | ||
| 497 | ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>; | 502 | ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>; |
| 498 | } | 503 | } |
| 499 | >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { | 504 | >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { |
| ... | @@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | ... | @@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 595 | | FileSystem.FileSystem | 600 | | FileSystem.FileSystem |
| 596 | | HttpClient.HttpClient | 601 | | HttpClient.HttpClient |
| 597 | | Path.Path | 602 | | Path.Path |
| 603 | | ServerSettings.ServerSettingsService | ||
| 598 | > { | 604 | > { |
| 599 | const fileSystem = yield* FileSystem.FileSystem; | 605 | const fileSystem = yield* FileSystem.FileSystem; |
| 600 | const path = yield* Path.Path; | 606 | const path = yield* Path.Path; |
| 601 | const httpClient = yield* HttpClient.HttpClient; | 607 | const httpClient = yield* HttpClient.HttpClient; |
| 602 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 608 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 609 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 603 | const platform = yield* HostProcessPlatform; | 610 | const platform = yield* HostProcessPlatform; |
| 604 | const architecture = yield* HostProcessArchitecture; | 611 | const architecture = yield* HostProcessArchitecture; |
| 605 | const hostEnvironment = yield* HostProcessEnvironment; | 612 | const hostEnvironment = yield* HostProcessEnvironment; |
| ... | @@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | ... | @@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1610 | } satisfies AcpRegistryPrepareResult; | 1617 | } satisfies AcpRegistryPrepareResult; |
| 1611 | }); | 1618 | }); |
| 1612 | 1619 | ||
| 1620 | const validateLocalExecutable = (command: string) => | ||
| 1621 | platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) | ||
| 1622 | ? Effect.fail( | ||
| 1623 | new AcpRegistryError({ | ||
| 1624 | reason: "runner_unavailable", | ||
| 1625 | detail: | ||
| 1626 | "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.", | ||
| 1627 | }), | ||
| 1628 | ) | ||
| 1629 | : Effect.void; | ||
| 1630 | |||
| 1613 | const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => | 1631 | const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => |
| 1614 | Effect.gen(function* () { | 1632 | Effect.gen(function* () { |
| 1633 | if (settings.source === "local") { | ||
| 1634 | const command = settings.commandPath.trim(); | ||
| 1635 | if (!command) return { status: "unconfigured" } as const; | ||
| 1636 | const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); | ||
| 1637 | if (executable !== undefined) yield* validateLocalExecutable(executable); | ||
| 1638 | return executable === undefined | ||
| 1639 | ? ({ | ||
| 1640 | status: "missing_runner", | ||
| 1641 | version: null, | ||
| 1642 | distribution: "local", | ||
| 1643 | } as const) | ||
| 1644 | : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); | ||
| 1645 | } | ||
| 1615 | const agentId = settings.agentId.trim(); | 1646 | const agentId = settings.agentId.trim(); |
| 1616 | if (agentId.length === 0) return { status: "unconfigured" } as const; | 1647 | if (agentId.length === 0) return { status: "unconfigured" } as const; |
| 1617 | const registry = yield* loadCachedRegistry(); | 1648 | const registry = yield* loadCachedRegistry(); |
| ... | @@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | ... | @@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1713 | 1744 | ||
| 1714 | const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => | 1745 | const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => |
| 1715 | Effect.gen(function* () { | 1746 | Effect.gen(function* () { |
| 1747 | if (settings.source === "local") { | ||
| 1748 | const executable = settings.commandPath.trim(); | ||
| 1749 | if (!executable) { | ||
| 1750 | return yield* new AcpRegistryError({ | ||
| 1751 | reason: "agent_not_configured", | ||
| 1752 | detail: "Local ACP provider requires an executable.", | ||
| 1753 | }); | ||
| 1754 | } | ||
| 1755 | const env = environment ?? hostEnvironment; | ||
| 1756 | const command = resolveExecutable(executable, platform, env); | ||
| 1757 | if (command === undefined) { | ||
| 1758 | return yield* new AcpRegistryError({ | ||
| 1759 | reason: "runner_unavailable", | ||
| 1760 | detail: "Local ACP executable is not available on this environment's PATH.", | ||
| 1761 | }); | ||
| 1762 | } | ||
| 1763 | yield* validateLocalExecutable(command); | ||
| 1764 | return { | ||
| 1765 | distribution: "local", | ||
| 1766 | spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, | ||
| 1767 | } satisfies ResolvedAcpRegistryAgent; | ||
| 1768 | } | ||
| 1716 | const agentId = settings.agentId.trim(); | 1769 | const agentId = settings.agentId.trim(); |
| 1717 | if (agentId.length === 0) { | 1770 | if (agentId.length === 0) { |
| 1718 | return yield* new AcpRegistryError({ | 1771 | return yield* new AcpRegistryError({ |
| ... | @@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | ... | @@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1782 | } satisfies ResolvedAcpRegistryAgent; | 1835 | } satisfies ResolvedAcpRegistryAgent; |
| 1783 | }); | 1836 | }); |
| 1784 | 1837 | ||
| 1785 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = ( | 1838 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => |
| 1786 | input, | 1839 | serverSettings |
| 1787 | isReferenced = Effect.succeed(false), | 1840 | .withSettingsSnapshot((settings) => |
| 1788 | ) => | 1841 | installSemaphore.withPermits(1)( |
| 1789 | installSemaphore.withPermits(1)( | 1842 | Effect.gen(function* () { |
| 1790 | Effect.gen(function* () { | 1843 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( |
| 1791 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( | 1844 | Effect.mapError( |
| 1792 | Effect.mapError( | 1845 | (cause) => |
| 1793 | (cause) => | 1846 | new AcpRegistryError({ |
| 1794 | new AcpRegistryError({ | 1847 | reason: "install_failed", |
| 1795 | reason: "install_failed", | 1848 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", |
| 1796 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", | 1849 | cause, |
| 1797 | cause, | 1850 | }), |
| 1798 | }), | 1851 | ), |
| 1799 | ), | 1852 | ); |
| 1800 | ); | 1853 | const isReferenced = Object.values(settings.providerInstances).some((instance) => { |
| 1801 | if (yield* isReferenced) { | 1854 | if ( |
| 1802 | yield* consumePreparedBinaryReservation(safeAgentId); | 1855 | instance.driver !== "acpRegistry" || |
| 1803 | return { agentId: safeAgentId, removed: false }; | 1856 | instance.config === null || |
| 1804 | } | 1857 | typeof instance.config !== "object" |
| 1805 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { | 1858 | ) |
| 1806 | return { agentId: safeAgentId, removed: false }; | 1859 | return false; |
| 1807 | } | 1860 | const config = instance.config as Record<string, unknown>; |
| 1808 | const agentRoot = path.join(installsDirectory, safeAgentId); | 1861 | return ( |
| 1809 | const existed = yield* fileSystem.exists(agentRoot).pipe( | 1862 | config.source !== "local" && |
| 1810 | Effect.mapError( | 1863 | typeof config.agentId === "string" && |
| 1811 | (cause) => | 1864 | config.agentId.trim() === safeAgentId |
| 1812 | new AcpRegistryError({ | 1865 | ); |
| 1813 | reason: "install_failed", | 1866 | }); |
| 1814 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, | 1867 | if (isReferenced) { |
| 1815 | cause, | 1868 | yield* consumePreparedBinaryReservation(safeAgentId); |
| 1816 | }), | 1869 | return { agentId: safeAgentId, removed: false }; |
| 1817 | ), | ||
| 1818 | ); | ||
| 1819 | if (!existed) return { agentId: safeAgentId, removed: false }; | ||
| 1820 | |||
| 1821 | let removed = false; | ||
| 1822 | yield* Effect.gen(function* () { | ||
| 1823 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | ||
| 1824 | const versionRoot = path.join(agentRoot, version); | ||
| 1825 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | ||
| 1826 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | ||
| 1827 | yield* fileSystem.remove(path.join(versionRoot, entry), { | ||
| 1828 | recursive: true, | ||
| 1829 | force: true, | ||
| 1830 | }); | ||
| 1831 | removed = true; | ||
| 1832 | } | 1870 | } |
| 1833 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | 1871 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { |
| 1834 | yield* fileSystem.remove(versionRoot, { recursive: true }); | 1872 | return { agentId: safeAgentId, removed: false }; |
| 1835 | } | 1873 | } |
| 1836 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | 1874 | const agentRoot = path.join(installsDirectory, safeAgentId); |
| 1837 | yield* fileSystem.remove(agentRoot, { recursive: true }); | 1875 | const existed = yield* fileSystem.exists(agentRoot).pipe( |
| 1838 | }).pipe( | 1876 | Effect.mapError( |
| 1839 | Effect.mapError( | 1877 | (cause) => |
| 1840 | (cause) => | 1878 | new AcpRegistryError({ |
| 1841 | new AcpRegistryError({ | 1879 | reason: "install_failed", |
| 1842 | reason: "install_failed", | 1880 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, |
| 1843 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | 1881 | cause, |
| 1844 | cause, | 1882 | }), |
| 1845 | }), | 1883 | ), |
| 1846 | ), | 1884 | ); |
| 1847 | ); | 1885 | if (!existed) return { agentId: safeAgentId, removed: false }; |
| 1848 | return { agentId: safeAgentId, removed }; | 1886 | |
| 1849 | }), | 1887 | let removed = false; |
| 1850 | ); | 1888 | yield* Effect.gen(function* () { |
| 1889 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | ||
| 1890 | const versionRoot = path.join(agentRoot, version); | ||
| 1891 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | ||
| 1892 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | ||
| 1893 | yield* fileSystem.remove(path.join(versionRoot, entry), { | ||
| 1894 | recursive: true, | ||
| 1895 | force: true, | ||
| 1896 | }); | ||
| 1897 | removed = true; | ||
| 1898 | } | ||
| 1899 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | ||
| 1900 | yield* fileSystem.remove(versionRoot, { recursive: true }); | ||
| 1901 | } | ||
| 1902 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | ||
| 1903 | yield* fileSystem.remove(agentRoot, { recursive: true }); | ||
| 1904 | }).pipe( | ||
| 1905 | Effect.mapError( | ||
| 1906 | (cause) => | ||
| 1907 | new AcpRegistryError({ | ||
| 1908 | reason: "install_failed", | ||
| 1909 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | ||
| 1910 | cause, | ||
| 1911 | }), | ||
| 1912 | ), | ||
| 1913 | ); | ||
| 1914 | return { agentId: safeAgentId, removed }; | ||
| 1915 | }), | ||
| 1916 | ), | ||
| 1917 | ) | ||
| 1918 | .pipe( | ||
| 1919 | Effect.catchTag( | ||
| 1920 | "ServerSettingsError", | ||
| 1921 | (cause) => | ||
| 1922 | new AcpRegistryError({ | ||
| 1923 | reason: "install_failed", | ||
| 1924 | detail: | ||
| 1925 | "Could not read provider settings while checking managed ACP binary references.", | ||
| 1926 | cause, | ||
| 1927 | }), | ||
| 1928 | ), | ||
| 1929 | ); | ||
| 1851 | 1930 | ||
| 1852 | return AcpRegistryCatalog.of({ | 1931 | return AcpRegistryCatalog.of({ |
| 1853 | search, | 1932 | search, |
apps/server/src/provider/acp/AcpSessionRuntime.ts+8-4| ... | @@ -87,6 +87,7 @@ export interface AcpSpawnInput { | ... | @@ -87,6 +87,7 @@ export interface AcpSpawnInput { |
| 87 | readonly cwd?: string; | 87 | readonly cwd?: string; |
| 88 | readonly env?: NodeJS.ProcessEnv; | 88 | readonly env?: NodeJS.ProcessEnv; |
| 89 | readonly extendEnv?: boolean; | 89 | readonly extendEnv?: boolean; |
| 90 | readonly shell?: false; | ||
| 90 | } | 91 | } |
| 91 | 92 | ||
| 92 | export interface AcpSessionRuntimeOptions { | 93 | export interface AcpSessionRuntimeOptions { |
| ... | @@ -1516,10 +1517,13 @@ export const make = ( | ... | @@ -1516,10 +1517,13 @@ export const make = ( |
| 1516 | ), | 1517 | ), |
| 1517 | ); | 1518 | ); |
| 1518 | 1519 | ||
| 1519 | const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { | 1520 | const spawnCommand = |
| 1520 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | 1521 | options.spawn.shell === false |
| 1521 | extendEnv: options.spawn.extendEnv ?? true, | 1522 | ? { command: options.spawn.command, args: options.spawn.args, shell: false } |
| 1522 | }); | 1523 | : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { |
| 1524 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | ||
| 1525 | extendEnv: options.spawn.extendEnv ?? true, | ||
| 1526 | }); | ||
| 1523 | const linuxCgroupLease = | 1527 | const linuxCgroupLease = |
| 1524 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" | 1528 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" |
| 1525 | ? yield* Effect.sync(() => { | 1529 | ? yield* Effect.sync(() => { |
apps/server/src/server.ts+1-1| ... | @@ -574,7 +574,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( | ... | @@ -574,7 +574,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( |
| 574 | Layer.provideMerge(PtyAdapterLive), | 574 | Layer.provideMerge(PtyAdapterLive), |
| 575 | // Search, prepare, status inspection, and turn launch share one registry | 575 | // Search, prepare, status inspection, and turn launch share one registry |
| 576 | // cache so every client and provider instance sees the same prepared agents. | 576 | // cache so every client and provider instance sees the same prepared agents. |
| 577 | Layer.provideMerge(AcpRegistryCatalogLive), | 577 | Layer.provideMerge(AcpRegistryCatalogLive.pipe(Layer.provide(ServerSettingsLayerLive))), |
| 578 | // Shared native/canonical NDJSON writers used by both the per-instance | 578 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 579 | // V2 drivers and the orchestration runtime. Provide resource attribution so | 579 | // V2 drivers and the orchestration runtime. Provide resource attribution so |
| 580 | // the rewritten telemetry pipeline can account for logical NDJSON writes. | 580 | // the rewritten telemetry pipeline can account for logical NDJSON writes. |
apps/server/src/ws.ts+3-30| ... | @@ -2114,36 +2114,9 @@ const makeWsRpcLayer = ( | ... | @@ -2114,36 +2114,9 @@ const makeWsRpcLayer = ( |
| 2114 | [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => | 2114 | [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => |
| 2115 | observeRpcEffect( | 2115 | observeRpcEffect( |
| 2116 | WS_METHODS.serverUninstallAcpRegistryManagedBinary, | 2116 | WS_METHODS.serverUninstallAcpRegistryManagedBinary, |
| 2117 | serverSettings | 2117 | acpRegistryCatalog |
| 2118 | .withSettingsSnapshot((settings) => | 2118 | .uninstallManagedBinary(input) |
| 2119 | acpRegistryCatalog.uninstallManagedBinary( | 2119 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), |
| 2120 | input, | ||
| 2121 | Effect.succeed( | ||
| 2122 | Object.values(settings.providerInstances).some((instance) => { | ||
| 2123 | if ( | ||
| 2124 | instance.driver !== "acpRegistry" || | ||
| 2125 | instance.config === null || | ||
| 2126 | typeof instance.config !== "object" | ||
| 2127 | ) { | ||
| 2128 | return false; | ||
| 2129 | } | ||
| 2130 | return (instance.config as Record<string, unknown>).agentId === input.agentId; | ||
| 2131 | }), | ||
| 2132 | ), | ||
| 2133 | ), | ||
| 2134 | ) | ||
| 2135 | .pipe( | ||
| 2136 | Effect.mapError((cause) => | ||
| 2137 | AcpRegistrySupport.isAcpRegistryError(cause) | ||
| 2138 | ? cause | ||
| 2139 | : new AcpRegistrySupport.AcpRegistryError({ | ||
| 2140 | reason: "install_failed", | ||
| 2141 | detail: `Could not read provider settings while checking references for ACP Registry agent ${input.agentId}.`, | ||
| 2142 | cause, | ||
| 2143 | }), | ||
| 2144 | ), | ||
| 2145 | Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError), | ||
| 2146 | ), | ||
| 2147 | { | 2120 | { |
| 2148 | "rpc.aggregate": "server", | 2121 | "rpc.aggregate": "server", |
| 2149 | "acp_registry.agent_id": input.agentId, | 2122 | "acp_registry.agent_id": input.agentId, |
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+13| ... | @@ -34,6 +34,7 @@ interface AcpRegistrySearchStepProps { | ... | @@ -34,6 +34,7 @@ interface AcpRegistrySearchStepProps { |
| 34 | readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>; | 34 | readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>; |
| 35 | readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; | 35 | readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; |
| 36 | readonly onManualConfiguration: () => void; | 36 | readonly onManualConfiguration: () => void; |
| 37 | readonly onLocalConfiguration?: () => void; | ||
| 37 | readonly onLoadingChange?: (loading: boolean) => void; | 38 | readonly onLoadingChange?: (loading: boolean) => void; |
| 38 | readonly onPreparingChange?: (preparing: boolean) => void; | 39 | readonly onPreparingChange?: (preparing: boolean) => void; |
| 39 | } | 40 | } |
| ... | @@ -55,6 +56,7 @@ export function AcpRegistrySearchStep({ | ... | @@ -55,6 +56,7 @@ export function AcpRegistrySearchStep({ |
| 55 | providerInstances, | 56 | providerInstances, |
| 56 | onPrepared, | 57 | onPrepared, |
| 57 | onManualConfiguration, | 58 | onManualConfiguration, |
| 59 | onLocalConfiguration, | ||
| 58 | onLoadingChange, | 60 | onLoadingChange, |
| 59 | onPreparingChange, | 61 | onPreparingChange, |
| 60 | }: AcpRegistrySearchStepProps) { | 62 | }: AcpRegistrySearchStepProps) { |
| ... | @@ -169,6 +171,17 @@ export function AcpRegistrySearchStep({ | ... | @@ -169,6 +171,17 @@ export function AcpRegistrySearchStep({ |
| 169 | > | 171 | > |
| 170 | Enter manually | 172 | Enter manually |
| 171 | </Button> | 173 | </Button> |
| 174 | {onLocalConfiguration ? ( | ||
| 175 | <Button | ||
| 176 | disabled={preparingId !== null} | ||
| 177 | onClick={onLocalConfiguration} | ||
| 178 | size="sm" | ||
| 179 | type="button" | ||
| 180 | variant="outline" | ||
| 181 | > | ||
| 182 | Local ACP command | ||
| 183 | </Button> | ||
| 184 | ) : null} | ||
| 172 | </form> | 185 | </form> |
| 173 | 186 | ||
| 174 | <div className="sr-only" role="status"> | 187 | <div className="sr-only" role="status"> |
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+68| ... | @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { | ... | @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { |
| 239 | ).not.toBeNull(); | 239 | ).not.toBeNull(); |
| 240 | }); | 240 | }); |
| 241 | 241 | ||
| 242 | it("creates a local command in the selected environment without a sign-in step", async () => { | ||
| 243 | const onOpenChange = vi.fn(); | ||
| 244 | let tree = render(onOpenChange); | ||
| 245 | const search = visitElements( | ||
| 246 | tree, | ||
| 247 | (element) => | ||
| 248 | typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep", | ||
| 249 | ); | ||
| 250 | (search!.props.onLocalConfiguration as () => void)(); | ||
| 251 | tree = render(onOpenChange); | ||
| 252 | (findByChildren(tree, "Next").props.onClick as () => void)(); | ||
| 253 | tree = render(onOpenChange); | ||
| 254 | expect(findByChildren(tree, "Executable is required.")).not.toBeNull(); | ||
| 255 | expect(settingsHooks.mutate).not.toHaveBeenCalled(); | ||
| 256 | |||
| 257 | const configuration = visitElements( | ||
| 258 | tree, | ||
| 259 | (element) => element.props.idPrefix === "add-provider-acpRegistry-manual", | ||
| 260 | ); | ||
| 261 | const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""]; | ||
| 262 | (configuration!.props.onChange as (value: Record<string, unknown>) => void)({ | ||
| 263 | source: "local", | ||
| 264 | commandPath: "dsh", | ||
| 265 | commandArgs, | ||
| 266 | }); | ||
| 267 | const environmentEditor = visitElements( | ||
| 268 | tree, | ||
| 269 | (element) => | ||
| 270 | typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection", | ||
| 271 | ); | ||
| 272 | const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }]; | ||
| 273 | (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment); | ||
| 274 | tree = render(onOpenChange); | ||
| 275 | (findByChildren(tree, "Next").props.onClick as () => void)(); | ||
| 276 | tree = render(onOpenChange); | ||
| 277 | const label = visitElements(tree, (element) => element.props.id === "add-provider-label"); | ||
| 278 | (label!.props.onChange as (event: { target: { value: string } }) => void)({ | ||
| 279 | target: { value: "Deepseek Harness" }, | ||
| 280 | }); | ||
| 281 | tree = render(onOpenChange); | ||
| 282 | (findByChildren(tree, "Add instance").props.onClick as () => void)(); | ||
| 283 | await Promise.resolve(); | ||
| 284 | await Promise.resolve(); | ||
| 285 | |||
| 286 | expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); | ||
| 287 | expect(settingsHooks.mutate).toHaveBeenCalledWith({ | ||
| 288 | operation: "create", | ||
| 289 | instanceId: "acpRegistry_deepseek_harness", | ||
| 290 | instance: { | ||
| 291 | driver: "acpRegistry", | ||
| 292 | enabled: true, | ||
| 293 | displayName: "Deepseek Harness", | ||
| 294 | config: { source: "local", commandPath: "dsh", commandArgs }, | ||
| 295 | environment, | ||
| 296 | }, | ||
| 297 | }); | ||
| 298 | expect(onOpenChange).toHaveBeenCalledWith(false); | ||
| 299 | tree = render(onOpenChange); | ||
| 300 | expect( | ||
| 301 | visitElements( | ||
| 302 | tree, | ||
| 303 | (element) => | ||
| 304 | typeof element.type === "function" && | ||
| 305 | element.type.name === "ProviderWizardAuthenticationStep", | ||
| 306 | ), | ||
| 307 | ).toBeNull(); | ||
| 308 | }); | ||
| 309 | |||
| 242 | it("keeps the dialog open when the atomic upsert fails", async () => { | 310 | it("keeps the dialog open when the atomic upsert fails", async () => { |
| 243 | settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); | 311 | settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); |
| 244 | const onOpenChange = vi.fn(); | 312 | const onOpenChange = vi.fn(); |
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts+3-1| ... | @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; | ... | @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; |
| 7 | 7 | ||
| 8 | export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; | 8 | export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; |
| 9 | export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; | 9 | export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; |
| 10 | export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const; | ||
| 10 | 11 | ||
| 11 | export interface ProviderIdentityDraft { | 12 | export interface ProviderIdentityDraft { |
| 12 | readonly label: string; | 13 | readonly label: string; |
| ... | @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( | ... | @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( |
| 65 | ) { | 66 | ) { |
| 66 | return false; | 67 | return false; |
| 67 | } | 68 | } |
| 68 | return (instance.config as Record<string, unknown>).agentId === agentId; | 69 | const config = instance.config as Record<string, unknown>; |
| 70 | return config.source !== "local" && config.agentId === agentId; | ||
| 69 | }); | 71 | }); |
| 70 | } | 72 | } |
| 71 | 73 |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+68-13| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | ProviderDriverKind, | 13 | ProviderDriverKind, |
| 14 | type EnvironmentId, | 14 | type EnvironmentId, |
| 15 | type ProviderInstanceConfig, | 15 | type ProviderInstanceConfig, |
| 16 | type ProviderInstanceEnvironmentVariable, | ||
| 16 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 17 | 18 | ||
| 18 | import { | 19 | import { |
| ... | @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza | ... | @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza |
| 39 | import { | 40 | import { |
| 40 | ADD_PROVIDER_WIZARD_STEPS, | 41 | ADD_PROVIDER_WIZARD_STEPS, |
| 41 | ACP_REGISTRY_WIZARD_STEPS, | 42 | ACP_REGISTRY_WIZARD_STEPS, |
| 43 | LOCAL_ACP_WIZARD_STEPS, | ||
| 42 | deriveAvailableInstanceId, | 44 | deriveAvailableInstanceId, |
| 43 | resolveAcpRegistryWizardNavigation, | 45 | resolveAcpRegistryWizardNavigation, |
| 44 | resolveWizardNavigation, | 46 | resolveWizardNavigation, |
| ... | @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; | ... | @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; |
| 51 | import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; | 53 | import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; |
| 52 | import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; | 54 | import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; |
| 53 | import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; | 55 | import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; |
| 56 | import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; | ||
| 54 | 57 | ||
| 55 | /** | 58 | /** |
| 56 | * Normalize a user-provided label into a slug suffix for the instance id. | 59 | * Normalize a user-provided label into a slug suffix for the instance id. |
| ... | @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ | ... | @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ |
| 118 | {}, | 121 | {}, |
| 119 | ); | 122 | ); |
| 120 | const [selectedAcp, setSelectedAcp] = useState<AcpRegistrySearchAgent | null>(null); | 123 | const [selectedAcp, setSelectedAcp] = useState<AcpRegistrySearchAgent | null>(null); |
| 124 | const [localEnvironment, setLocalEnvironment] = useState< | ||
| 125 | ReadonlyArray<ProviderInstanceEnvironmentVariable> | ||
| 126 | >([]); | ||
| 121 | const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); | 127 | const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); |
| 122 | const [isRegistryLoading, setIsRegistryLoading] = useState(false); | 128 | const [isRegistryLoading, setIsRegistryLoading] = useState(false); |
| 123 | const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); | 129 | const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); |
| ... | @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ | ... | @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ |
| 170 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; | 176 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 171 | 177 | ||
| 172 | const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; | 178 | const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; |
| 179 | const isLocalAcp = isAcpRegistry && isManualAcpConfiguration && configDraft.source === "local"; | ||
| 180 | const localCommandPath = | ||
| 181 | typeof configDraft.commandPath === "string" ? configDraft.commandPath.trim() : ""; | ||
| 173 | const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; | 182 | const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; |
| 174 | const acpSelectionError = | 183 | const acpSelectionError = isLocalAcp |
| 175 | selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) | 184 | ? localCommandPath.length > 0 |
| 185 | ? null | ||
| 186 | : "Executable is required." | ||
| 187 | : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) | ||
| 176 | ? null | 188 | ? null |
| 177 | : "Select an ACP or configure one manually."; | 189 | : "Select an ACP or configure one manually."; |
| 178 | const wizardStepSummaries = isAcpRegistry | 190 | const wizardStepSummaries = isAcpRegistry |
| 179 | ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) | 191 | ? ([ |
| 192 | isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), | ||
| 193 | previewLabel, | ||
| 194 | null, | ||
| 195 | ] as const) | ||
| 180 | : ([driverOption.label, previewLabel, null] as const); | 196 | : ([driverOption.label, previewLabel, null] as const); |
| 181 | const setConfigDraft = (config: Record<string, unknown> | undefined) => { | 197 | const setConfigDraft = (config: Record<string, unknown> | undefined) => { |
| 182 | setConfigByDriver((existing) => { | 198 | setConfigByDriver((existing) => { |
| ... | @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ |
| 201 | if (navigation.kind === "blocked") { | 217 | if (navigation.kind === "blocked") { |
| 202 | setHasAttemptedSubmit(true); | 218 | setHasAttemptedSubmit(true); |
| 203 | } | 219 | } |
| 204 | if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { | 220 | if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) { |
| 205 | void handleSave(); | 221 | void handleSave(); |
| 206 | return; | 222 | return; |
| 207 | } | 223 | } |
| ... | @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ | ... | @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ |
| 211 | const navigateToStep = (requestedStep: number) => { | 227 | const navigateToStep = (requestedStep: number) => { |
| 212 | applyWizardNavigation( | 228 | applyWizardNavigation( |
| 213 | isAcpRegistry | 229 | isAcpRegistry |
| 214 | ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { | 230 | ? isLocalAcp |
| 215 | instanceIdError, | 231 | ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, { |
| 216 | selectionError: acpSelectionError, | 232 | instanceIdError, |
| 217 | }) | 233 | prerequisite: { step: 0, error: acpSelectionError }, |
| 234 | }) | ||
| 235 | : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { | ||
| 236 | instanceIdError, | ||
| 237 | selectionError: acpSelectionError, | ||
| 238 | }) | ||
| 218 | : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { | 239 | : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { |
| 219 | instanceIdError, | 240 | instanceIdError, |
| 220 | }), | 241 | }), |
| ... | @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ | ... | @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ |
| 268 | setHasAttemptedSubmit(false); | 289 | setHasAttemptedSubmit(false); |
| 269 | }; | 290 | }; |
| 270 | 291 | ||
| 292 | const handleLocalAcpConfiguration = () => { | ||
| 293 | setDriver(ACP_REGISTRY_DRIVER_KIND); | ||
| 294 | setSelectedAcp(null); | ||
| 295 | setIsManualAcpConfiguration(true); | ||
| 296 | setConfigByDriver((existing) => ({ | ||
| 297 | ...existing, | ||
| 298 | [ACP_REGISTRY_DRIVER_KIND]: { source: "local", commandArgs: [] }, | ||
| 299 | })); | ||
| 300 | setIdentityByDriver((existing) => | ||
| 301 | updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { | ||
| 302 | label: "Local ACP", | ||
| 303 | instanceIdOverride: null, | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | setLocalEnvironment([]); | ||
| 307 | setHasAttemptedSubmit(false); | ||
| 308 | }; | ||
| 309 | |||
| 271 | const handleSave = async () => { | 310 | const handleSave = async () => { |
| 272 | if (isSaving || createdInstanceId) return; | 311 | if (isSaving || createdInstanceId) return; |
| 273 | setHasAttemptedSubmit(true); | 312 | setHasAttemptedSubmit(true); |
| ... | @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ |
| 286 | ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), | 325 | ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), |
| 287 | ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), | 326 | ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), |
| 288 | ...(hasConfig ? { config } : {}), | 327 | ...(hasConfig ? { config } : {}), |
| 328 | ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}), | ||
| 289 | }; | 329 | }; |
| 290 | // `ProviderInstanceId.make` revalidates the slug; we've already checked | 330 | // `ProviderInstanceId.make` revalidates the slug; we've already checked |
| 291 | // it via `validateInstanceId`, but going through the brand constructor | 331 | // it via `validateInstanceId`, but going through the brand constructor |
| ... | @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ |
| 309 | return; | 349 | return; |
| 310 | } | 350 | } |
| 311 | onCreated?.(brandedId); | 351 | onCreated?.(brandedId); |
| 312 | if (isAcpRegistry) { | 352 | if (isAcpRegistry && !isLocalAcp) { |
| 313 | setCreatedInstanceId(brandedId); | 353 | setCreatedInstanceId(brandedId); |
| 314 | setIsSaving(false); | 354 | setIsSaving(false); |
| 315 | setWizardStep(2); | 355 | setWizardStep(2); |
| ... | @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ |
| 344 | currentStep={wizardStep} | 384 | currentStep={wizardStep} |
| 345 | summaries={wizardStepSummaries} | 385 | summaries={wizardStepSummaries} |
| 346 | instanceIdError={instanceIdError} | 386 | instanceIdError={instanceIdError} |
| 347 | steps={ACP_REGISTRY_WIZARD_STEPS} | 387 | steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS} |
| 348 | disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} | 388 | disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} |
| 349 | identityStep={1} | 389 | identityStep={1} |
| 350 | prerequisite={{ step: 0, error: acpSelectionError }} | 390 | prerequisite={{ step: 0, error: acpSelectionError }} |
| ... | @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({ | ... | @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({ |
| 434 | <div className="grid gap-4"> | 474 | <div className="grid gap-4"> |
| 435 | <div className="flex items-start justify-between gap-3"> | 475 | <div className="flex items-start justify-between gap-3"> |
| 436 | <div> | 476 | <div> |
| 437 | <h3 className="text-sm font-medium text-foreground">Enter manually</h3> | 477 | <h3 className="text-sm font-medium text-foreground"> |
| 478 | {isLocalAcp ? "Local ACP command" : "Enter manually"} | ||
| 479 | </h3> | ||
| 438 | <p className="mt-0.5 text-xs text-muted-foreground"> | 480 | <p className="mt-0.5 text-xs text-muted-foreground"> |
| 439 | Enter an official registry ID and any local executable or auth override. | 481 | {isLocalAcp |
| 482 | ? `Run an installed ACP executable on ${environmentLabel}.` | ||
| 483 | : "Enter an official registry ID and any local executable or auth override."} | ||
| 440 | </p> | 484 | </p> |
| 441 | </div> | 485 | </div> |
| 442 | <Button | 486 | <Button |
| ... | @@ -458,6 +502,12 @@ export function AddProviderInstanceDialog({ | ... | @@ -458,6 +502,12 @@ export function AddProviderInstanceDialog({ |
| 458 | variant="settings" | 502 | variant="settings" |
| 459 | onChange={setConfigDraft} | 503 | onChange={setConfigDraft} |
| 460 | /> | 504 | /> |
| 505 | {isLocalAcp ? ( | ||
| 506 | <ProviderEnvironmentSection | ||
| 507 | environment={localEnvironment} | ||
| 508 | onChange={setLocalEnvironment} | ||
| 509 | /> | ||
| 510 | ) : null} | ||
| 461 | </SettingsGroup> | 511 | </SettingsGroup> |
| 462 | {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? ( | 512 | {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? ( |
| 463 | <p className="text-2xs text-destructive">{acpSelectionError}</p> | 513 | <p className="text-2xs text-destructive">{acpSelectionError}</p> |
| ... | @@ -470,6 +520,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -470,6 +520,7 @@ export function AddProviderInstanceDialog({ |
| 470 | providerInstances={settings.providerInstances} | 520 | providerInstances={settings.providerInstances} |
| 471 | onPrepared={handleAcpPrepared} | 521 | onPrepared={handleAcpPrepared} |
| 472 | onManualConfiguration={handleManualAcpConfiguration} | 522 | onManualConfiguration={handleManualAcpConfiguration} |
| 523 | onLocalConfiguration={handleLocalAcpConfiguration} | ||
| 473 | onLoadingChange={setIsRegistryLoading} | 524 | onLoadingChange={setIsRegistryLoading} |
| 474 | onPreparingChange={setIsPreparingRegistryAgent} | 525 | onPreparingChange={setIsPreparingRegistryAgent} |
| 475 | /> | 526 | /> |
| ... | @@ -644,7 +695,11 @@ export function AddProviderInstanceDialog({ | ... | @@ -644,7 +695,11 @@ export function AddProviderInstanceDialog({ |
| 644 | </Button> | 695 | </Button> |
| 645 | ) : ( | 696 | ) : ( |
| 646 | <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> | 697 | <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> |
| 647 | {isSaving ? "Adding..." : isAcpRegistry ? "Continue to sign-in" : "Add instance"} | 698 | {isSaving |
| 699 | ? "Adding..." | ||
| 700 | : isAcpRegistry && !isLocalAcp | ||
| 701 | ? "Continue to sign-in" | ||
| 702 | : "Add instance"} | ||
| 648 | </Button> | 703 | </Button> |
| 649 | )} | 704 | )} |
| 650 | </WizardFooter> | 705 | </WizardFooter> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+11-3| ... | @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { | ... | @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { |
| 296 | ); | 296 | ); |
| 297 | } | 297 | } |
| 298 | 298 | ||
| 299 | function ProviderEnvironmentSection(props: { | 299 | export function ProviderEnvironmentSection(props: { |
| 300 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; | 300 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; |
| 301 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; | 301 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; |
| 302 | }) { | 302 | }) { |
| ... | @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ | ... | @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ |
| 717 | driverKind={driverKind ?? instance.driver} | 717 | driverKind={driverKind ?? instance.driver} |
| 718 | displayName={displayName} | 718 | displayName={displayName} |
| 719 | accentColor={accentColor} | 719 | accentColor={accentColor} |
| 720 | acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined} | 720 | acpRegistryAgentId={ |
| 721 | acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined} | 721 | readConfigString(instance.config, "source") === "local" |
| 722 | ? undefined | ||
| 723 | : (readConfigString(instance.config, "agentId") ?? undefined) | ||
| 724 | } | ||
| 725 | acpRegistryIconUrl={ | ||
| 726 | readConfigString(instance.config, "source") === "local" | ||
| 727 | ? undefined | ||
| 728 | : (readConfigString(instance.config, "registryIconUrl") ?? undefined) | ||
| 729 | } | ||
| 722 | showBadge={Boolean(accentColor)} | 730 | showBadge={Boolean(accentColor)} |
| 723 | className="size-5" | 731 | className="size-5" |
| 724 | iconClassName="size-4 text-foreground/80" | 732 | iconClassName="size-4 text-foreground/80" |
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8| ... | @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { | ... | @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { |
| 57 | expect(acpRegistry).toBeDefined(); | 57 | expect(acpRegistry).toBeDefined(); |
| 58 | expect(acpRegistry?.hasDefaultInstance).toBe(false); | 58 | expect(acpRegistry?.hasDefaultInstance).toBe(false); |
| 59 | expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ | 59 | expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ |
| 60 | "source", | ||
| 60 | "agentId", | 61 | "agentId", |
| 61 | "commandPath", | 62 | "commandPath", |
| 62 | "authMethodId", | 63 | "authMethodId", |
| 63 | ]); | 64 | ]); |
| 64 | }); | 65 | }); |
| 65 | 66 | ||
| 67 | it("shows the local executable without registry identity or authentication fields", () => { | ||
| 68 | const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; | ||
| 69 | expect( | ||
| 70 | deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), | ||
| 71 | ).toEqual(["source", "commandPath"]); | ||
| 72 | }); | ||
| 73 | |||
| 66 | it("derives a select control with its choices for the Antigravity sign-in method", () => { | 74 | it("derives a select control with its choices for the Antigravity sign-in method", () => { |
| 67 | const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; | 75 | const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; |
| 68 | expect(antigravity).toBeDefined(); | 76 | expect(antigravity).toBeDefined(); |
apps/web/src/components/settings/ProviderSettingsForm.tsx+134-7| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo, type ReactNode } from "react"; | 3 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import type { | 6 | import type { |
| ... | @@ -9,8 +9,10 @@ import type { | ... | @@ -9,8 +9,10 @@ import type { |
| 9 | ProviderSettingsFormOption, | 9 | ProviderSettingsFormOption, |
| 10 | ProviderSettingsFormSchemaAnnotation, | 10 | ProviderSettingsFormSchemaAnnotation, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { PlusIcon, XIcon } from "lucide-react"; | ||
| 12 | 13 | ||
| 13 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { Button } from "../ui/button"; | ||
| 14 | import { DraftInput } from "../ui/draft-input"; | 16 | import { DraftInput } from "../ui/draft-input"; |
| 15 | import { Input } from "../ui/input"; | 17 | import { Input } from "../ui/input"; |
| 16 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 18 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | @@ -76,7 +78,10 @@ function readFieldBooleanDefault( | ... | @@ -76,7 +78,10 @@ function readFieldBooleanDefault( |
| 76 | 78 | ||
| 77 | export function deriveProviderSettingsFields( | 79 | export function deriveProviderSettingsFields( |
| 78 | definition: ProviderClientDefinition, | 80 | definition: ProviderClientDefinition, |
| 81 | value?: unknown, | ||
| 79 | ): ReadonlyArray<ProviderSettingsFieldModel> { | 82 | ): ReadonlyArray<ProviderSettingsFieldModel> { |
| 83 | const isLocalAcp = | ||
| 84 | definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; | ||
| 80 | const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); | 85 | const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); |
| 81 | const orderedKeys = new Map( | 86 | const orderedKeys = new Map( |
| 82 | (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), | 87 | (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), |
| ... | @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( | ... | @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( |
| 95 | const fieldSchema = definition.settingsSchema.fields[key]!; | 100 | const fieldSchema = definition.settingsSchema.fields[key]!; |
| 96 | const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); | 101 | const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); |
| 97 | if (formAnnotation.hidden) return []; | 102 | if (formAnnotation.hidden) return []; |
| 103 | if (isLocalAcp && key !== "source" && key !== "commandPath") return []; | ||
| 98 | 104 | ||
| 99 | const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); | 105 | const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); |
| 100 | const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); | 106 | const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); |
| ... | @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( | ... | @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( |
| 102 | { | 108 | { |
| 103 | key, | 109 | key, |
| 104 | control: formAnnotation.control ?? "text", | 110 | control: formAnnotation.control ?? "text", |
| 105 | label: annotatedTitle ?? titleizeFieldKey(key), | 111 | label: |
| 106 | ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), | 112 | isLocalAcp && key === "commandPath" |
| 107 | ...(formAnnotation.placeholder !== undefined | 113 | ? "Executable" |
| 108 | ? { placeholder: formAnnotation.placeholder } | 114 | : (annotatedTitle ?? titleizeFieldKey(key)), |
| 109 | : {}), | 115 | ...(isLocalAcp && key === "commandPath" |
| 116 | ? { description: "Executable name or path on this environment." } | ||
| 117 | : annotatedDescription !== undefined | ||
| 118 | ? { description: annotatedDescription } | ||
| 119 | : {}), | ||
| 120 | ...(isLocalAcp && key === "commandPath" | ||
| 121 | ? { placeholder: "e.g. dsh" } | ||
| 122 | : formAnnotation.placeholder !== undefined | ||
| 123 | ? { placeholder: formAnnotation.placeholder } | ||
| 124 | : {}), | ||
| 110 | clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", | 125 | clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", |
| 111 | ...(formAnnotation.control === "switch" | 126 | ...(formAnnotation.control === "switch" |
| 112 | ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } | 127 | ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } |
| ... | @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( | ... | @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( |
| 119 | }); | 134 | }); |
| 120 | } | 135 | } |
| 121 | 136 | ||
| 137 | let commandArgumentDraftId = 0; | ||
| 138 | const makeCommandArgumentDraftRow = (value: string) => ({ | ||
| 139 | id: `provider-argument-${commandArgumentDraftId++}`, | ||
| 140 | value, | ||
| 141 | }); | ||
| 142 | |||
| 143 | function commandArgumentsEqual(left: ReadonlyArray<string>, right: ReadonlyArray<string>) { | ||
| 144 | return left.length === right.length && left.every((argument, index) => argument === right[index]); | ||
| 145 | } | ||
| 146 | |||
| 147 | function ProviderCommandArguments({ | ||
| 148 | value, | ||
| 149 | onChange, | ||
| 150 | }: Pick<ProviderSettingsFormProps, "value" | "onChange">) { | ||
| 151 | const args = useMemo(() => { | ||
| 152 | const configured = | ||
| 153 | value !== null && typeof value === "object" | ||
| 154 | ? (value as Record<string, unknown>).commandArgs | ||
| 155 | : undefined; | ||
| 156 | return Array.isArray(configured) | ||
| 157 | ? configured.filter((argument): argument is string => typeof argument === "string") | ||
| 158 | : []; | ||
| 159 | }, [value]); | ||
| 160 | const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow)); | ||
| 161 | const rowsRef = useRef(rows); | ||
| 162 | const previousArgsRef = useRef(args); | ||
| 163 | const lastPublishedArgsRef = useRef<ReadonlyArray<string> | undefined>(undefined); | ||
| 164 | |||
| 165 | useEffect(() => { | ||
| 166 | const previousArgs = previousArgsRef.current; | ||
| 167 | const lastPublishedArgs = lastPublishedArgsRef.current; | ||
| 168 | previousArgsRef.current = args; | ||
| 169 | lastPublishedArgsRef.current = undefined; | ||
| 170 | if ( | ||
| 171 | commandArgumentsEqual(previousArgs, args) || | ||
| 172 | (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args)) | ||
| 173 | ) | ||
| 174 | return; | ||
| 175 | const nextRows = args.map(makeCommandArgumentDraftRow); | ||
| 176 | rowsRef.current = nextRows; | ||
| 177 | setRows(nextRows); | ||
| 178 | }, [args]); | ||
| 179 | |||
| 180 | const updateArguments = (nextRows: typeof rows) => { | ||
| 181 | rowsRef.current = nextRows; | ||
| 182 | setRows(nextRows); | ||
| 183 | const next = nextRows.map((row) => row.value); | ||
| 184 | lastPublishedArgsRef.current = next; | ||
| 185 | const config = | ||
| 186 | value !== null && typeof value === "object" ? { ...(value as Record<string, unknown>) } : {}; | ||
| 187 | onChange({ ...config, commandArgs: next }); | ||
| 188 | }; | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <SettingsRow | ||
| 192 | title="Arguments" | ||
| 193 | description="One literal argument per row, in launch order." | ||
| 194 | control={ | ||
| 195 | <Button | ||
| 196 | type="button" | ||
| 197 | size="sm" | ||
| 198 | variant="outline" | ||
| 199 | onClick={() => updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])} | ||
| 200 | > | ||
| 201 | <PlusIcon /> | ||
| 202 | Add argument | ||
| 203 | </Button> | ||
| 204 | } | ||
| 205 | > | ||
| 206 | {rows.length > 0 ? ( | ||
| 207 | <div className="mt-3 min-w-0 space-y-2 pb-2"> | ||
| 208 | {rows.map((argument, index) => ( | ||
| 209 | <div key={argument.id} className="flex min-w-0 items-center gap-1.5"> | ||
| 210 | <DraftInput | ||
| 211 | size="sm" | ||
| 212 | font="mono" | ||
| 213 | value={argument.value} | ||
| 214 | onCommit={(next) => | ||
| 215 | updateArguments( | ||
| 216 | rowsRef.current.map((current) => | ||
| 217 | current.id === argument.id ? { ...current, value: next } : current, | ||
| 218 | ), | ||
| 219 | ) | ||
| 220 | } | ||
| 221 | aria-label={`Argument ${index + 1}`} | ||
| 222 | spellCheck={false} | ||
| 223 | /> | ||
| 224 | <Button | ||
| 225 | type="button" | ||
| 226 | size="icon-micro" | ||
| 227 | variant="ghost-destructive" | ||
| 228 | onClick={() => | ||
| 229 | updateArguments(rowsRef.current.filter((current) => current.id !== argument.id)) | ||
| 230 | } | ||
| 231 | aria-label={`Remove argument ${index + 1}`} | ||
| 232 | > | ||
| 233 | <XIcon /> | ||
| 234 | </Button> | ||
| 235 | </div> | ||
| 236 | ))} | ||
| 237 | </div> | ||
| 238 | ) : null} | ||
| 239 | </SettingsRow> | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | |||
| 122 | function readProviderConfigString(config: unknown, key: string): string { | 243 | function readProviderConfigString(config: unknown, key: string): string { |
| 123 | if (config === null || typeof config !== "object") return ""; | 244 | if (config === null || typeof config !== "object") return ""; |
| 124 | const value = (config as Record<string, unknown>)[key]; | 245 | const value = (config as Record<string, unknown>)[key]; |
| ... | @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ | ... | @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ |
| 411 | variant, | 532 | variant, |
| 412 | onChange, | 533 | onChange, |
| 413 | }: ProviderSettingsFormProps) { | 534 | }: ProviderSettingsFormProps) { |
| 414 | const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]); | 535 | const fields = useMemo( |
| 536 | () => deriveProviderSettingsFields(definition, value), | ||
| 537 | [definition, value], | ||
| 538 | ); | ||
| 539 | const isLocalAcp = | ||
| 540 | definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; | ||
| 415 | 541 | ||
| 416 | if (fields.length === 0) { | 542 | if (fields.length === 0) { |
| 417 | return null; | 543 | return null; |
| ... | @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ | ... | @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ |
| 429 | onChange={onChange} | 555 | onChange={onChange} |
| 430 | /> | 556 | /> |
| 431 | ))} | 557 | ))} |
| 558 | {isLocalAcp ? <ProviderCommandArguments value={value} onChange={onChange} /> : null} | ||
| 432 | </> | 559 | </> |
| 433 | ); | 560 | ); |
| 434 | } | 561 | } |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+1| ... | @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ |
| 922 | } | 922 | } |
| 923 | 923 | ||
| 924 | if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; | 924 | if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; |
| 925 | if (providerConfigString(row.instance.config, "source") === "local") return; | ||
| 925 | const agentId = providerConfigString(row.instance.config, "agentId"); | 926 | const agentId = providerConfigString(row.instance.config, "agentId"); |
| 926 | if (agentId === null) return; | 927 | if (agentId === null) return; |
| 927 | 928 |
apps/web/src/providerInstances.test.ts+39| ... | @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { | ... | @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { |
| 619 | }); | 619 | }); |
| 620 | 620 | ||
| 621 | describe("provider icon metadata", () => { | 621 | describe("provider icon metadata", () => { |
| 622 | it("clears stale registry branding when an instance switches to a local command", () => { | ||
| 623 | const instanceId = ProviderInstanceId.make("custom-acp"); | ||
| 624 | const driver = ProviderDriverKind.make("acpRegistry"); | ||
| 625 | const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; | ||
| 626 | const snapshots = deriveProviderInstanceEntries([ | ||
| 627 | { ...provider({ provider: driver, instanceId }), iconUrl }, | ||
| 628 | ]); | ||
| 629 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; | ||
| 630 | const brandedEntries = applyProviderInstanceSettings(snapshots, { | ||
| 631 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | ||
| 632 | providers: {} as never, | ||
| 633 | }); | ||
| 634 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); | ||
| 635 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); | ||
| 636 | |||
| 637 | const [localEntry] = applyProviderInstanceSettings(brandedEntries, { | ||
| 638 | providerInstances: { | ||
| 639 | [instanceId]: { | ||
| 640 | driver, | ||
| 641 | enabled: false, | ||
| 642 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, | ||
| 643 | }, | ||
| 644 | }, | ||
| 645 | providers: {} as never, | ||
| 646 | }); | ||
| 647 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); | ||
| 648 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); | ||
| 649 | expect(localEntry?.enabled).toBe(false); | ||
| 650 | expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); | ||
| 651 | |||
| 652 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { | ||
| 653 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | ||
| 654 | providers: {} as never, | ||
| 655 | }); | ||
| 656 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); | ||
| 657 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); | ||
| 658 | expect(restoredEntry?.enabled).toBe(true); | ||
| 659 | }); | ||
| 660 | |||
| 622 | it("retains server-published registry icons without local settings", () => { | 661 | it("retains server-published registry icons without local settings", () => { |
| 623 | const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; | 662 | const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; |
| 624 | const [entry] = deriveProviderInstanceEntries([ | 663 | const [entry] = deriveProviderInstanceEntries([ |
apps/web/src/providerInstances.ts+3| ... | @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( | ... | @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( |
| 210 | explicitInstance.config !== null && typeof explicitInstance.config === "object" | 210 | explicitInstance.config !== null && typeof explicitInstance.config === "object" |
| 211 | ? (explicitInstance.config as Readonly<Record<string, unknown>>) | 211 | ? (explicitInstance.config as Readonly<Record<string, unknown>>) |
| 212 | : null; | 212 | : null; |
| 213 | if (config?.source === "local") { | ||
| 214 | return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; | ||
| 215 | } | ||
| 213 | const agentId = config?.agentId; | 216 | const agentId = config?.agentId; |
| 214 | const iconUrl = config?.registryIconUrl; | 217 | const iconUrl = config?.registryIconUrl; |
| 215 | return { | 218 | return { |
docs/user/providers-acp.md+13-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | # ACP Registry | 1 | # ACP providers |
| 2 | 2 | ||
| 3 | T3 Code can run coding agents from the official | 3 | T3 Code can run installed ACP commands or coding agents from the official |
| 4 | [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their | 4 | [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their |
| 5 | own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task | 5 | own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task |
| 6 | delegation. | 6 | delegation. |
| ... | @@ -23,6 +23,17 @@ work through the same generic integration. | ... | @@ -23,6 +23,17 @@ work through the same generic integration. |
| 23 | Search only shows agents that can run on the connected server. Registry agents are third-party | 23 | Search only shows agents that can run on the connected server. Registry agents are third-party |
| 24 | code; review an agent's source and license before adding it. | 24 | code; review an agent's source and license before adding it. |
| 25 | 25 | ||
| 26 | ## Add a local command | ||
| 27 | |||
| 28 | In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable | ||
| 29 | name or path on the selected environment, add one literal argument per row, and choose a display | ||
| 30 | name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`. | ||
| 31 | |||
| 32 | Local commands require no registry entry or download. Environment overrides apply to the command; | ||
| 33 | credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning | ||
| 34 | options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly | ||
| 35 | without expanding shell expressions. | ||
| 36 | |||
| 26 | ## Where agents run | 37 | ## Where agents run |
| 27 | 38 | ||
| 28 | Registry agents always run on the machine that hosts your T3 Code server. That stays true when you | 39 | Registry agents always run on the machine that hosts your T3 Code server. That stays true when you |
packages/contracts/src/settings.ts+19-2| ... | @@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr | ... | @@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr |
| 871 | 871 | ||
| 872 | export const AcpRegistrySettings = makeProviderSettingsSchema( | 872 | export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 873 | { | 873 | { |
| 874 | source: Schema.Literals(["registry", "local"]).pipe( | ||
| 875 | Schema.withDecodingDefault(Effect.succeed("registry")), | ||
| 876 | Schema.annotateKey({ | ||
| 877 | title: "ACP source", | ||
| 878 | providerSettingsForm: { | ||
| 879 | control: "select", | ||
| 880 | options: [ | ||
| 881 | { value: "registry", label: "ACP Registry" }, | ||
| 882 | { value: "local", label: "Local command" }, | ||
| 883 | ], | ||
| 884 | }, | ||
| 885 | }), | ||
| 886 | ), | ||
| 874 | enabled: Schema.Boolean.pipe( | 887 | enabled: Schema.Boolean.pipe( |
| 875 | Schema.withDecodingDefault(Effect.succeed(true)), | 888 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 876 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 889 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| ... | @@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( | ... | @@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 888 | Schema.annotateKey({ | 901 | Schema.annotateKey({ |
| 889 | title: "Executable override", | 902 | title: "Executable override", |
| 890 | description: | 903 | description: |
| 891 | "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", | 904 | "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", |
| 892 | providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, | 905 | providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, |
| 893 | }), | 906 | }), |
| 894 | ), | 907 | ), |
| 908 | commandArgs: Schema.Array(Schema.String).pipe( | ||
| 909 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 910 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | ||
| 911 | ), | ||
| 895 | authMethodId: TrimmedString.pipe( | 912 | authMethodId: TrimmedString.pipe( |
| 896 | Schema.withDecodingDefault(Effect.succeed("")), | 913 | Schema.withDecodingDefault(Effect.succeed("")), |
| 897 | Schema.annotateKey({ | 914 | Schema.annotateKey({ |
| ... | @@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( | ... | @@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 911 | ), | 928 | ), |
| 912 | }, | 929 | }, |
| 913 | { | 930 | { |
| 914 | order: ["agentId", "commandPath", "authMethodId"], | 931 | order: ["source", "agentId", "commandPath", "authMethodId"], |
| 915 | }, | 932 | }, |
| 916 | ); | 933 | ); |
| 917 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; | 934 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; |