authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-05 14:11:25-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 14:11:25-03:00
log9d029a1df25126745dd70ac561ff25d3f8fbac42
tree8aaf43d965842d07272f2e5d9a21c9125a495b94
parent4dbc0129c244d5869d7598a22f8d9386cca17884
signature Signed by PGP key B5690EEEBB952194

feat(acp): support local provider commands (#16021)


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";
15import * as EffectAcpErrors from "effect-acp/errors";15import * as EffectAcpErrors from "effect-acp/errors";
1616
17import * as ServerConfig from "../../config.ts";17import * as ServerConfig from "../../config.ts";
18import * as ServerSettings from "../../serverSettings.ts";
18import type {19import 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);
9193
92describe("AcpRegistryAdapterV2", () => {94describe("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) {
183183
184export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) {184export 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 above194 // 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 ...(isDevin199 ...(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" });
203203
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 });
314327
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 });
345364
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 );
358379
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.probe233 (input.probe
227 ? input.probe.probe.authMethods.length > 0234 ? 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};
5959
60const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) =>60const 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));
213218
219it.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
214it.effect(239it.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 = catalog52 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
139141
140 const methods = Option.isSome(coordinator)142 const methods = Option.isSome(coordinator)
141 ? coordinator.value143 ? 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 all362 // 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 without34 // 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 });
224224
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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { AcpRegistrySettings } from "@t3tools/contracts";3import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
4import {4import {
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";
13import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Ref from "effect/Ref";
18import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
19import { HttpClient, HttpClientResponse } from "effect/unstable/http";17import { HttpClient, HttpClientResponse } from "effect/unstable/http";
20import * as TestClock from "effect/testing/TestClock";18import * 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";
22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2321
24import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";22import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
23import * as ServerSettings from "../../serverSettings.ts";
2524
26const registryUrl = "https://registry.test/registry.json";25const registryUrl = "https://registry.test/registry.json";
27const archiveUrl = "https://registry.test/example-agent.bin";26const 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 });
211211
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 });
11901338
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`;
12051353 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" });
12131360
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 );
12271405
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* resolver1468 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 }));
13001471
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);
13041474
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);
13121482
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";
40import * as NodeCrypto from "node:crypto";40import * as NodeCrypto from "node:crypto";
4141
42import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";42import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
43import * as ServerSettings from "../../serverSettings.ts";
43import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";44import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";
4445
45const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";46const 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}
211212
212export const isAcpRegistryError = Schema.is(AcpRegistryError);213const isAcpRegistryError = Schema.is(AcpRegistryError);
213214
214export type AcpRegistryPlatformTarget =215export 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}
440441
441export interface ResolvedAcpRegistryAgent {442export 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}
446447
...@@ -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 };
475481
...@@ -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.FileSystem600 | FileSystem.FileSystem
596 | HttpClient.HttpClient601 | HttpClient.HttpClient
597 | Path.Path602 | 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 });
16121619
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
17131744
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 });
17841837
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 );
18511930
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}
9192
92export interface AcpSessionRuntimeOptions {93export interface AcpSessionRuntimeOptions {
...@@ -1516,10 +1517,13 @@ export const make = (...@@ -1516,10 +1517,13 @@ export const make = (
1516 ),1517 ),
1517 );1518 );
15181519
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 registry575 // 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-instance578 // Shared native/canonical NDJSON writers used by both the per-instance
579 // V2 drivers and the orchestration runtime. Provide resource attribution so579 // 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 serverSettings2117 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 manually172 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>
173186
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 });
241241
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;
77
8export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const;8export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const;
9export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const;9export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const;
10export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const;
1011
11export interface ProviderIdentityDraft {12export 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}
7173
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";
1718
18import {19import {
...@@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza...@@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza
39import {40import {
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";
51import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep";53import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep";
52import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon";54import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon";
53import { AddManagedCodexAccountDialog } from "./CodexSetupSection";55import { AddManagedCodexAccountDialog } from "./CodexSetupSection";
56import { ProviderEnvironmentSection } from "./ProviderInstanceCard";
5457
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`;
171177
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 ? null188 ? null
177 : "Select an ACP or configure one manually.";189 : "Select an ACP or configure one manually.";
178 const wizardStepSummaries = isAcpRegistry190 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 isAcpRegistry229 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 };
270291
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 checked330 // `ProviderInstanceId.make` revalidates the slug; we've already checked
291 // it via `validateInstanceId`, but going through the brand constructor331 // 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 <Button486 <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}
298298
299function ProviderEnvironmentSection(props: {299export 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 });
6566
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";
22
3import { useMemo, type ReactNode } from "react";3import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import type {6import 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";
12import { PlusIcon, XIcon } from "lucide-react";
1213
13import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
15import { Button } from "../ui/button";
14import { DraftInput } from "../ui/draft-input";16import { DraftInput } from "../ui/draft-input";
15import { Input } from "../ui/input";17import { Input } from "../ui/input";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";18import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
...@@ -76,7 +78,10 @@ function readFieldBooleanDefault(...@@ -76,7 +78,10 @@ function readFieldBooleanDefault(
7678
77export function deriveProviderSettingsFields(79export 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 [];
98104
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 !== undefined113 ? "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}
121136
137let commandArgumentDraftId = 0;
138const makeCommandArgumentDraftRow = (value: string) => ({
139 id: `provider-argument-${commandArgumentDraftId++}`,
140 value,
141});
142
143function commandArgumentsEqual(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {
144 return left.length === right.length && left.every((argument, index) => argument === right[index]);
145}
146
147function 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
122function readProviderConfigString(config: unknown, key: string): string {243function 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";
415541
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 }
923923
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;
927928
apps/web/src/providerInstances.test.ts+39
...@@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => {...@@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => {
619});619});
620620
621describe("provider icon metadata", () => {621describe("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 Registry1# ACP providers
22
3T3 Code can run coding agents from the official3T3 Code can run installed ACP commands or coding agents from the official
4[ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their4[ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their
5own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task5own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task
6delegation.6delegation.
...@@ -23,6 +23,17 @@ work through the same generic integration....@@ -23,6 +23,17 @@ work through the same generic integration.
23Search only shows agents that can run on the connected server. Registry agents are third-party23Search only shows agents that can run on the connected server. Registry agents are third-party
24code; review an agent's source and license before adding it.24code; review an agent's source and license before adding it.
2525
26## Add a local command
27
28In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable
29name or path on the selected environment, add one literal argument per row, and choose a display
30name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`.
31
32Local commands require no registry entry or download. Environment overrides apply to the command;
33credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning
34options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly
35without expanding shell expressions.
36
26## Where agents run37## Where agents run
2738
28Registry agents always run on the machine that hosts your T3 Code server. That stays true when you39Registry 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
871871
872export const AcpRegistrySettings = makeProviderSettingsSchema(872export 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);
917export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;934export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;