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";
1515import * as EffectAcpErrors from "effect-acp/errors";
1616
1717import * as ServerConfig from "../../config.ts";
18import * as ServerSettings from "../../serverSettings.ts";
1819import type {
1920 AcpRegistryAvailableCommands,
2021 AcpRegistryLiveConfiguration,
......@@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll(
8788 IdAllocator.layer,
8889 serverConfigLayer,
8990 registryLayer,
91 ServerSettings.layerTest(),
9092);
9193
9294describe("AcpRegistryAdapterV2", () => {
......@@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => {
121123 assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER));
122124 assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER);
123125 assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), {
126 source: "registry",
127 commandArgs: [],
124128 enabled: true,
125129 agentId: "",
126130 commandPath: "",
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+7-4
......@@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
183183
184184export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) {
185185 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";
187190 const flavor: AcpAdapterV2Flavor = {
188191 driver: ACP_REGISTRY_PROVIDER,
189192 capabilities: AcpProviderCapabilitiesV2,
190 promptFailure: (cause) => acpRegistryPromptFailure(options.settings.agentId, cause),
193 promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause),
191194 // Per-agent exceptions (Mistral Vibe, Devin): see the note above
192195 // registerMistralVibeAcpExtensions before adding any more.
193 ...(options.settings.agentId === "mistral-vibe"
196 ...(registryAgentId === "mistral-vibe"
194197 ? { registerExtensions: registerMistralVibeAcpExtensions }
195198 : {}),
196199 ...(isDevin
......@@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) {
230233 });
231234 },
232235 withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
233 runtimeCoordinator.withForegroundStartup(options.settings.agentId, effect),
236 runtimeCoordinator.withForegroundStartup(startupKey, effect),
234237 }),
235238 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
236239 };
apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts+63-42
......@@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => {
201201 }),
202202 ).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
204217 expect(
205218 acpRegistrySnapshotReadiness({
206219 status: "unprepared",
......@@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => {
312325 expect(snapshot.models[0]?.isDefault).toBe(true);
313326 });
314327
315 it("reports failed authentication without hiding successful local inspection", () => {
316 const snapshot = buildCheckedAcpRegistrySnapshot({
317 ...identity,
318 settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }),
319 checkedAt: "2026-08-13T10:00:00.000Z",
320 inspection: {
321 status: "ready",
322 agentId: "test-agent",
323 version: "1.0.0",
324 distribution: "binary",
325 },
326 probeError: new AcpRegistryOperationError({
327 reason: "authentication_failed",
328 message: "Login required.",
329 authMethods: [
330 {
331 id: "api-key",
332 name: "API key",
333 description: null,
334 type: "env_var",
335 },
336 {
337 id: "grok-login",
338 name: "Log in with Grok",
339 description: null,
340 type: "agent",
341 },
342 ],
343 }),
344 });
328 it.each(["registry", "local"] as const)(
329 "reports failed authentication after successful %s inspection",
330 (source) => {
331 const snapshot = buildCheckedAcpRegistrySnapshot({
332 ...identity,
333 settings: decodeSettings({
334 source,
335 ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }),
336 authMethodId: "grok-login",
337 }),
338 checkedAt: "2026-08-13T10:00:00.000Z",
339 inspection: {
340 status: "ready",
341 agentId: "test-agent",
342 version: source === "local" ? null : "1.0.0",
343 distribution: source === "local" ? "local" : "binary",
344 },
345 probeError: new AcpRegistryOperationError({
346 reason: "authentication_failed",
347 message: "Login required.",
348 authMethods: [
349 {
350 id: "api-key",
351 name: "API key",
352 description: null,
353 type: "env_var",
354 },
355 {
356 id: "grok-login",
357 name: "Log in with Grok",
358 description: null,
359 type: "agent",
360 },
361 ],
362 }),
363 });
345364
346 expect(snapshot).toMatchObject({
347 installed: true,
348 version: "1.0.0",
349 status: "warning",
350 auth: {
351 status: "unauthenticated",
352 type: "agent",
353 label: "Log in with Grok",
354 },
355 message: 'Sign in in provider settings using "Log in with Grok".',
356 });
357 });
365 expect(snapshot).toMatchObject({
366 installed: true,
367 version: source === "local" ? null : "1.0.0",
368 status: "warning",
369 setup: { canAuthenticate: true },
370 auth: {
371 status: "unauthenticated",
372 type: "agent",
373 label: "Log in with Grok",
374 },
375 message: 'Sign in in provider settings using "Log in with Grok".',
376 });
377 },
378 );
358379
359380 it.effect("runs the disposable probe only after local inspection is ready", () =>
360381 Effect.gen(function* () {
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+17-6
......@@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness(
138138 installed: false,
139139 version: null,
140140 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.",
142143 };
143144 case "not_found":
144145 return {
......@@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness(
159160 installed: false,
160161 version: inspection.version,
161162 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.`,
163167 };
164168 case "unprepared":
165169 return {
......@@ -196,11 +200,14 @@ function baseSnapshot(
196200 readonly documentationUrl?: string;
197201 readonly message?: string;
198202 readonly probe?: AcpRegistryConfigurationProbeResult;
203 readonly probeError?: AcpRegistryOperationError;
199204 },
200205): ServerProvider {
201206 const iconUrl =
202 resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ??
203 officialAcpRegistryIconUrlForAgentId(input.settings.agentId);
207 input.settings.source === "local"
208 ? null
209 : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ??
210 officialAcpRegistryIconUrlForAgentId(input.settings.agentId));
204211 return {
205212 instanceId: input.instanceId,
206213 driver: DRIVER_KIND,
......@@ -225,7 +232,9 @@ function baseSnapshot(
225232 input.installed &&
226233 (input.probe
227234 ? 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),
229238 },
230239 ...(input.message ? { message: input.message } : {}),
231240 models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels),
......@@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
633642 const publishEnrichment = (
634643 Option.isSome(runtimeCoordinator)
635644 ? runtimeCoordinator.value.runBackgroundProbe(
636 effectiveConfig.agentId,
645 effectiveConfig.source === "local"
646 ? `local:${instanceId}`
647 : effectiveConfig.agentId,
637648 enrichProviderCached(snapshot),
638649 )
639650 : 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"] = {
5757 uninstallManagedBinary: () => Effect.die("unused"),
5858};
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) =>
6166 Effect.gen(function* () {
6267 const verify = yield* Deferred.make<void>();
6368 const changed: boolean[] = [];
......@@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) =>
7479 const written: string[] = [];
7580 const sizes: number[][] = [];
7681 const controller = yield* makeAcpRegistryAuth({
77 instanceId,
78 settings: decodeSettings({ agentId: "test-agent" }),
82 instanceId: providerInstanceId,
83 settings,
7984 cwd: "/workspace",
8085 environment: { PATH: "/tools", OVERRIDE: "base" },
8186 onChanged: (value) =>
......@@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) =>
211216 };
212217 }).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
214239it.effect(
215240 "discovers methods without signing in, then waits for browser consent and session verification",
216241 () =>
apps/server/src/provider/acp/AcpRegistryAuth.ts+6-6
......@@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o
4545 const coordinator = yield* Effect.serviceOption(
4646 AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator,
4747 );
48 const agentKey =
49 options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId;
4850 const failure = (operation: string, detail: string, cause?: unknown) =>
4951 new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause });
5052 const resolve = catalog
......@@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o
139141
140142 const methods = Option.isSome(coordinator)
141143 ? coordinator.value
142 .runBackgroundProbe(options.settings.agentId, discoverMethods)
144 .runBackgroundProbe(agentKey, discoverMethods)
143145 .pipe(
144146 Effect.flatMap((result) =>
145147 Option.isSome(result)
......@@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o
327329 }),
328330 );
329331 yield* Option.isSome(coordinator)
330 ? coordinator.value.withForegroundStartup(options.settings.agentId, login)
332 ? coordinator.value.withForegroundStartup(agentKey, login)
331333 : login;
332334 yield* options.onChanged(true);
333335 });
......@@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o
353355 }),
354356 );
355357 const signOut = (
356 Option.isSome(coordinator)
357 ? coordinator.value.withForegroundStartup(options.settings.agentId, logout)
358 : logout
358 Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout
359359 ).pipe(Effect.andThen(options.onChanged(false)));
360360 return yield* ProviderAuthFlow.make({
361361 instanceId: options.instanceId,
362362 // ACP doesn't advertise its credential scope. Conservatively treat all
363363 // 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}` },
365365 methods,
366366 ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}),
367367 authenticate,
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+3
......@@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth
3434 // Cosmetic settings and model discovery can rebuild the driver without
3535 // changing the account. Credential overrides and profile paths cannot.
3636 const binding = hash({
37 ...(input.settings.source === "local"
38 ? { source: "local", commandArgs: input.settings.commandArgs }
39 : {}),
3740 agentId: input.settings.agentId,
3841 commandPath: input.settings.commandPath,
3942 distribution: input.settings.distribution,
apps/server/src/provider/acp/AcpRegistryProbe.test.ts+69
......@@ -222,6 +222,75 @@ describe("ACP Registry probe", () => {
222222 expect(result.currentModelId).toBeNull();
223223 });
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
225294 it("omits a current model that falls outside the bounded model catalog", () => {
226295 const result = acpRegistryProbeResult(instanceId, {
227296 sessionId: "probe-session",
apps/server/src/provider/acp/AcpRegistryProbe.ts+1-1
......@@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo
440440 probe: acpRegistryProbeResult(
441441 input.instanceId,
442442 result.started,
443 result.resolved.agent.icon ?? null,
443 result.resolved.agent?.icon ?? null,
444444 {
445445 command: result.resolved.spawn.command,
446446 args: result.resolved.spawn.args,
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+243-55
......@@ -1,6 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { describe, expect, it } from "@effect/vitest";
3import { AcpRegistrySettings } from "@t3tools/contracts";
3import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
44import {
55 HostProcessArchitecture,
66 HostProcessEnvironment,
......@@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem";
1313import * as Path from "effect/Path";
1414import * as Fiber from "effect/Fiber";
1515import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Ref from "effect/Ref";
1816import * as Schema from "effect/Schema";
1917import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2018import * as TestClock from "effect/testing/TestClock";
......@@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto";
2220import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2321
2422import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
23import * as ServerSettings from "../../serverSettings.ts";
2524
2625const registryUrl = "https://registry.test/registry.json";
2726const archiveUrl = "https://registry.test/example-agent.bin";
......@@ -57,6 +56,7 @@ function resolverLayer(
5756) {
5857 return Layer.mergeAll(
5958 NodeServices.layer,
59 ServerSettings.layerTest(),
6060 Layer.succeed(HostProcessPlatform, "linux"),
6161 Layer.succeed(HostProcessArchitecture, "x64"),
6262 Layer.succeed(HostProcessEnvironment, environment),
......@@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => {
209209 ).toBeUndefined();
210210 });
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
212360 it.effect("resolves command overrides while preserving registry args and environment", () => {
213361 const agent = makeAgent({
214362 binary: {
......@@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => {
619767 );
620768 }).pipe(
621769 Effect.scoped,
622 Effect.provide(NodeServices.layer),
770 Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())),
623771 Effect.provideService(HostProcessPlatform, "linux"),
624772 Effect.provideService(HostProcessArchitecture, "x64"),
625773 );
......@@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => {
11881336 );
11891337 });
11901338
1191 it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => {
1192 return Effect.gen(function* () {
1193 const fileSystem = yield* FileSystem.FileSystem;
1194 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1195 prefix: "t3-acp-registry-uninstall-",
1196 });
1197 const agentRoot = `${cacheDir}/tools/example-agent`;
1198 const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`;
1199 yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true });
1200 yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary");
1201 yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, {
1202 recursive: true,
1203 });
1204 yield* fileSystem.writeFileString(runnerCache, "{}");
1205
1206 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1207 cacheDir,
1208 toolsDir: `${cacheDir}/tools`,
1209 registryUrl,
1210 });
1211 const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1212 const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" });
1339 it.effect.each([
1340 { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true },
1341 { name: "registry", source: "registry", agentId: "example-agent", referenced: true },
1342 { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true },
1343 { name: "local command", source: "local", agentId: "example-agent", referenced: false },
1344 ] as const)(
1345 "checks $name references before uninstalling only managed binaries",
1346 ({ source, agentId, referenced }) => {
1347 return Effect.gen(function* () {
1348 const fileSystem = yield* FileSystem.FileSystem;
1349 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
1350 prefix: "t3-acp-registry-uninstall-",
1351 });
1352 const agentRoot = `${cacheDir}/tools/example-agent`;
1353 const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`;
1354 yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true });
1355 yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary");
1356 yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, {
1357 recursive: true,
1358 });
1359 yield* fileSystem.writeFileString(runnerCache, "{}");
12131360
1214 expect(first).toEqual({ agentId: "example-agent", removed: true });
1215 expect(second).toEqual({ agentId: "example-agent", removed: false });
1216 expect(yield* fileSystem.exists(agentRoot)).toBe(false);
1217 expect(yield* fileSystem.exists(runnerCache)).toBe(true);
1218 }).pipe(
1219 Effect.scoped,
1220 Effect.provide(
1221 resolverLayer((request) =>
1222 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))),
1361 const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({
1362 cacheDir,
1363 toolsDir: `${cacheDir}/tools`,
1364 registryUrl,
1365 });
1366 const serverSettings = yield* ServerSettings.ServerSettingsService;
1367 const instanceId = ProviderInstanceId.make("uninstall-reference");
1368 yield* serverSettings.updateProviderInstance({
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 ),
12231401 ),
1224 ),
1225 );
1226 });
1402 );
1403 },
1404 );
12271405
12281406 it.effect("keeps managed package installs when removing the same agent's binaries", () =>
12291407 Effect.gen(function* () {
......@@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => {
12721450 return Effect.gen(function* () {
12731451 const downloadStarted = yield* Deferred.make<void>();
12741452 const releaseDownload = yield* Deferred.make<void>();
1275 const referenceChecked = yield* Deferred.make<void>();
1276 const isReferenced = yield* Ref.make(false);
12771453 return yield* Effect.gen(function* () {
12781454 const fileSystem = yield* FileSystem.FileSystem;
12791455 const cacheDir = yield* fileSystem.makeTempDirectoryScoped({
......@@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => {
12901466 .pipe(Effect.forkChild({ startImmediately: true }));
12911467 yield* Deferred.await(downloadStarted);
12921468 const uninstallFiber = yield* resolver
1293 .uninstallManagedBinary(
1294 { agentId: agent.id },
1295 Deferred.succeed(referenceChecked, undefined).pipe(
1296 Effect.andThen(Ref.get(isReferenced)),
1297 ),
1298 )
1469 .uninstallManagedBinary({ agentId: agent.id })
12991470 .pipe(Effect.forkChild({ startImmediately: true }));
13001471
1301 expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true);
1302 yield* Ref.set(isReferenced, true);
1472 expect(uninstallFiber.pollUnsafe()).toBeUndefined();
13031473 yield* Deferred.succeed(releaseDownload, undefined);
13041474
13051475 expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true });
......@@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => {
13101480 const agentRoot = `${cacheDir}/tools/${agent.id}`;
13111481 expect(yield* fileSystem.exists(agentRoot)).toBe(true);
13121482
1313 yield* Ref.set(isReferenced, false);
1314 expect(
1315 yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)),
1316 ).toEqual({ agentId: agent.id, removed: true });
1317 expect(
1318 yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)),
1319 ).toEqual({ agentId: agent.id, removed: false });
1483 const serverSettings = yield* ServerSettings.ServerSettingsService;
1484 const instanceId = ProviderInstanceId.make("registry-reference");
1485 yield* serverSettings.updateProviderInstance({
1486 operation: "upsert",
1487 instanceId,
1488 instance: {
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 });
13201508 }).pipe(
13211509 Effect.scoped,
13221510 Effect.provide(
apps/server/src/provider/acp/AcpRegistrySupport.ts+149-70
......@@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4040import * as NodeCrypto from "node:crypto";
4141
4242import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
43import * as ServerSettings from "../../serverSettings.ts";
4344import type { AcpSpawnInput } from "./AcpSessionRuntime.ts";
4445
4546const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
......@@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr
209210 });
210211}
211212
212export const isAcpRegistryError = Schema.is(AcpRegistryError);
213const isAcpRegistryError = Schema.is(AcpRegistryError);
213214
214215export type AcpRegistryPlatformTarget =
215216 | "darwin-aarch64"
......@@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: {
439440}
440441
441442export interface ResolvedAcpRegistryAgent {
442 readonly agent: AcpRegistryAgent;
443 readonly distribution: AcpRegistryDistributionKind;
443 readonly agent?: AcpRegistryAgent;
444 readonly distribution: AcpRegistryDistributionKind | "local";
444445 readonly spawn: AcpSpawnInput;
445446}
446447
......@@ -459,6 +460,11 @@ export type AcpRegistryInspection =
459460 readonly distribution: AcpRegistryDistributionKind;
460461 readonly runner: string;
461462 }
463 | {
464 readonly status: "missing_runner";
465 readonly version: null;
466 readonly distribution: "local";
467 }
462468 | {
463469 readonly status: "unprepared";
464470 readonly agentId: string;
......@@ -469,7 +475,7 @@ export type AcpRegistryInspection =
469475 readonly status: "ready";
470476 readonly agentId: string;
471477 readonly version: string | null;
472 readonly distribution: AcpRegistryDistributionKind;
478 readonly distribution: AcpRegistryDistributionKind | "local";
473479 readonly documentationUrl?: string;
474480 };
475481
......@@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service<
493499 ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>;
494500 readonly uninstallManagedBinary: (
495501 input: AcpRegistryManagedBinaryUninstallInput,
496 isReferenced?: Effect.Effect<boolean, AcpRegistryError>,
497502 ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>;
498503 }
499504>()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") {
......@@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
595600 | FileSystem.FileSystem
596601 | HttpClient.HttpClient
597602 | Path.Path
603 | ServerSettings.ServerSettingsService
598604> {
599605 const fileSystem = yield* FileSystem.FileSystem;
600606 const path = yield* Path.Path;
601607 const httpClient = yield* HttpClient.HttpClient;
602608 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
609 const serverSettings = yield* ServerSettings.ServerSettingsService;
603610 const platform = yield* HostProcessPlatform;
604611 const architecture = yield* HostProcessArchitecture;
605612 const hostEnvironment = yield* HostProcessEnvironment;
......@@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
16101617 } satisfies AcpRegistryPrepareResult;
16111618 });
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
16131631 const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) =>
16141632 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 }
16151646 const agentId = settings.agentId.trim();
16161647 if (agentId.length === 0) return { status: "unconfigured" } as const;
16171648 const registry = yield* loadCachedRegistry();
......@@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
17131744
17141745 const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) =>
17151746 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 }
17161769 const agentId = settings.agentId.trim();
17171770 if (agentId.length === 0) {
17181771 return yield* new AcpRegistryError({
......@@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
17821835 } satisfies ResolvedAcpRegistryAgent;
17831836 });
17841837
1785 const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (
1786 input,
1787 isReferenced = Effect.succeed(false),
1788 ) =>
1789 installSemaphore.withPermits(1)(
1790 Effect.gen(function* () {
1791 const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe(
1792 Effect.mapError(
1793 (cause) =>
1794 new AcpRegistryError({
1795 reason: "install_failed",
1796 detail: "ACP Registry managed binary uninstall received an invalid agent ID.",
1797 cause,
1798 }),
1799 ),
1800 );
1801 if (yield* isReferenced) {
1802 yield* consumePreparedBinaryReservation(safeAgentId);
1803 return { agentId: safeAgentId, removed: false };
1804 }
1805 if (yield* hasPreparedBinaryReservation(safeAgentId)) {
1806 return { agentId: safeAgentId, removed: false };
1807 }
1808 const agentRoot = path.join(installsDirectory, safeAgentId);
1809 const existed = yield* fileSystem.exists(agentRoot).pipe(
1810 Effect.mapError(
1811 (cause) =>
1812 new AcpRegistryError({
1813 reason: "install_failed",
1814 detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`,
1815 cause,
1816 }),
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;
1838 const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) =>
1839 serverSettings
1840 .withSettingsSnapshot((settings) =>
1841 installSemaphore.withPermits(1)(
1842 Effect.gen(function* () {
1843 const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe(
1844 Effect.mapError(
1845 (cause) =>
1846 new AcpRegistryError({
1847 reason: "install_failed",
1848 detail: "ACP Registry managed binary uninstall received an invalid agent ID.",
1849 cause,
1850 }),
1851 ),
1852 );
1853 const isReferenced = Object.values(settings.providerInstances).some((instance) => {
1854 if (
1855 instance.driver !== "acpRegistry" ||
1856 instance.config === null ||
1857 typeof instance.config !== "object"
1858 )
1859 return false;
1860 const config = instance.config as Record<string, unknown>;
1861 return (
1862 config.source !== "local" &&
1863 typeof config.agentId === "string" &&
1864 config.agentId.trim() === safeAgentId
1865 );
1866 });
1867 if (isReferenced) {
1868 yield* consumePreparedBinaryReservation(safeAgentId);
1869 return { agentId: safeAgentId, removed: false };
18321870 }
1833 if ((yield* fileSystem.readDirectory(versionRoot)).length === 0)
1834 yield* fileSystem.remove(versionRoot, { recursive: true });
1835 }
1836 if ((yield* fileSystem.readDirectory(agentRoot)).length === 0)
1837 yield* fileSystem.remove(agentRoot, { recursive: true });
1838 }).pipe(
1839 Effect.mapError(
1840 (cause) =>
1841 new AcpRegistryError({
1842 reason: "install_failed",
1843 detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`,
1844 cause,
1845 }),
1846 ),
1847 );
1848 return { agentId: safeAgentId, removed };
1849 }),
1850 );
1871 if (yield* hasPreparedBinaryReservation(safeAgentId)) {
1872 return { agentId: safeAgentId, removed: false };
1873 }
1874 const agentRoot = path.join(installsDirectory, safeAgentId);
1875 const existed = yield* fileSystem.exists(agentRoot).pipe(
1876 Effect.mapError(
1877 (cause) =>
1878 new AcpRegistryError({
1879 reason: "install_failed",
1880 detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`,
1881 cause,
1882 }),
1883 ),
1884 );
1885 if (!existed) return { agentId: safeAgentId, removed: false };
1886
1887 let removed = false;
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
18521931 return AcpRegistryCatalog.of({
18531932 search,
apps/server/src/provider/acp/AcpSessionRuntime.ts+8-4
......@@ -87,6 +87,7 @@ export interface AcpSpawnInput {
8787 readonly cwd?: string;
8888 readonly env?: NodeJS.ProcessEnv;
8989 readonly extendEnv?: boolean;
90 readonly shell?: false;
9091}
9192
9293export interface AcpSessionRuntimeOptions {
......@@ -1516,10 +1517,13 @@ export const make = (
15161517 ),
15171518 );
15181519
1519 const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, {
1520 ...(options.spawn.env ? { env: options.spawn.env } : {}),
1521 extendEnv: options.spawn.extendEnv ?? true,
1522 });
1520 const spawnCommand =
1521 options.spawn.shell === false
1522 ? { command: options.spawn.command, args: options.spawn.args, shell: false }
1523 : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, {
1524 ...(options.spawn.env ? { env: options.spawn.env } : {}),
1525 extendEnv: options.spawn.extendEnv ?? true,
1526 });
15231527 const linuxCgroupLease =
15241528 options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux"
15251529 ? yield* Effect.sync(() => {
apps/server/src/server.ts+1-1
......@@ -574,7 +574,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe(
574574 Layer.provideMerge(PtyAdapterLive),
575575 // Search, prepare, status inspection, and turn launch share one registry
576576 // cache so every client and provider instance sees the same prepared agents.
577 Layer.provideMerge(AcpRegistryCatalogLive),
577 Layer.provideMerge(AcpRegistryCatalogLive.pipe(Layer.provide(ServerSettingsLayerLive))),
578578 // Shared native/canonical NDJSON writers used by both the per-instance
579579 // V2 drivers and the orchestration runtime. Provide resource attribution so
580580 // the rewritten telemetry pipeline can account for logical NDJSON writes.
apps/server/src/ws.ts+3-30
......@@ -2114,36 +2114,9 @@ const makeWsRpcLayer = (
21142114 [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) =>
21152115 observeRpcEffect(
21162116 WS_METHODS.serverUninstallAcpRegistryManagedBinary,
2117 serverSettings
2118 .withSettingsSnapshot((settings) =>
2119 acpRegistryCatalog.uninstallManagedBinary(
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 ),
2117 acpRegistryCatalog
2118 .uninstallManagedBinary(input)
2119 .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)),
21472120 {
21482121 "rpc.aggregate": "server",
21492122 "acp_registry.agent_id": input.agentId,
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+13
......@@ -34,6 +34,7 @@ interface AcpRegistrySearchStepProps {
3434 readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>;
3535 readonly onPrepared: (agent: AcpRegistrySearchAgent) => void;
3636 readonly onManualConfiguration: () => void;
37 readonly onLocalConfiguration?: () => void;
3738 readonly onLoadingChange?: (loading: boolean) => void;
3839 readonly onPreparingChange?: (preparing: boolean) => void;
3940}
......@@ -55,6 +56,7 @@ export function AcpRegistrySearchStep({
5556 providerInstances,
5657 onPrepared,
5758 onManualConfiguration,
59 onLocalConfiguration,
5860 onLoadingChange,
5961 onPreparingChange,
6062}: AcpRegistrySearchStepProps) {
......@@ -169,6 +171,17 @@ export function AcpRegistrySearchStep({
169171 >
170172 Enter manually
171173 </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}
172185 </form>
173186
174187 <div className="sr-only" role="status">
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+68
......@@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => {
239239 ).not.toBeNull();
240240 });
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
242310 it("keeps the dialog open when the atomic upsert fails", async () => {
243311 settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") });
244312 const onOpenChange = vi.fn();
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts+3-1
......@@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1;
77
88export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const;
99export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const;
10export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const;
1011
1112export interface ProviderIdentityDraft {
1213 readonly label: string;
......@@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent(
6566 ) {
6667 return false;
6768 }
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;
6971 });
7072}
7173
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+68-13
......@@ -13,6 +13,7 @@ import {
1313 ProviderDriverKind,
1414 type EnvironmentId,
1515 type ProviderInstanceConfig,
16 type ProviderInstanceEnvironmentVariable,
1617} from "@t3tools/contracts";
1718
1819import {
......@@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza
3940import {
4041 ADD_PROVIDER_WIZARD_STEPS,
4142 ACP_REGISTRY_WIZARD_STEPS,
43 LOCAL_ACP_WIZARD_STEPS,
4244 deriveAvailableInstanceId,
4345 resolveAcpRegistryWizardNavigation,
4446 resolveWizardNavigation,
......@@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep";
5153import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep";
5254import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon";
5355import { AddManagedCodexAccountDialog } from "./CodexSetupSection";
56import { ProviderEnvironmentSection } from "./ProviderInstanceCard";
5457
5558/**
5659 * Normalize a user-provided label into a slug suffix for the instance id.
......@@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({
118121 {},
119122 );
120123 const [selectedAcp, setSelectedAcp] = useState<AcpRegistrySearchAgent | null>(null);
124 const [localEnvironment, setLocalEnvironment] = useState<
125 ReadonlyArray<ProviderInstanceEnvironmentVariable>
126 >([]);
121127 const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false);
122128 const [isRegistryLoading, setIsRegistryLoading] = useState(false);
123129 const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false);
......@@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({
170176 const previewLabel = label.trim() || `${driverOption.label} Workspace`;
171177
172178 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() : "";
173182 const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : "";
174 const acpSelectionError =
175 selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0)
183 const acpSelectionError = isLocalAcp
184 ? localCommandPath.length > 0
185 ? null
186 : "Executable is required."
187 : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0)
176188 ? null
177189 : "Select an ACP or configure one manually.";
178190 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)
180196 : ([driverOption.label, previewLabel, null] as const);
181197 const setConfigDraft = (config: Record<string, unknown> | undefined) => {
182198 setConfigByDriver((existing) => {
......@@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({
201217 if (navigation.kind === "blocked") {
202218 setHasAttemptedSubmit(true);
203219 }
204 if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) {
220 if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) {
205221 void handleSave();
206222 return;
207223 }
......@@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({
211227 const navigateToStep = (requestedStep: number) => {
212228 applyWizardNavigation(
213229 isAcpRegistry
214 ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, {
215 instanceIdError,
216 selectionError: acpSelectionError,
217 })
230 ? isLocalAcp
231 ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, {
232 instanceIdError,
233 prerequisite: { step: 0, error: acpSelectionError },
234 })
235 : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, {
236 instanceIdError,
237 selectionError: acpSelectionError,
238 })
218239 : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, {
219240 instanceIdError,
220241 }),
......@@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({
268289 setHasAttemptedSubmit(false);
269290 };
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
271310 const handleSave = async () => {
272311 if (isSaving || createdInstanceId) return;
273312 setHasAttemptedSubmit(true);
......@@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({
286325 ...(label.trim().length > 0 ? { displayName: label.trim() } : {}),
287326 ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}),
288327 ...(hasConfig ? { config } : {}),
328 ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}),
289329 };
290330 // `ProviderInstanceId.make` revalidates the slug; we've already checked
291331 // it via `validateInstanceId`, but going through the brand constructor
......@@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({
309349 return;
310350 }
311351 onCreated?.(brandedId);
312 if (isAcpRegistry) {
352 if (isAcpRegistry && !isLocalAcp) {
313353 setCreatedInstanceId(brandedId);
314354 setIsSaving(false);
315355 setWizardStep(2);
......@@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({
344384 currentStep={wizardStep}
345385 summaries={wizardStepSummaries}
346386 instanceIdError={instanceIdError}
347 steps={ACP_REGISTRY_WIZARD_STEPS}
387 steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS}
348388 disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null}
349389 identityStep={1}
350390 prerequisite={{ step: 0, error: acpSelectionError }}
......@@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({
434474 <div className="grid gap-4">
435475 <div className="flex items-start justify-between gap-3">
436476 <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>
438480 <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."}
440484 </p>
441485 </div>
442486 <Button
......@@ -458,6 +502,12 @@ export function AddProviderInstanceDialog({
458502 variant="settings"
459503 onChange={setConfigDraft}
460504 />
505 {isLocalAcp ? (
506 <ProviderEnvironmentSection
507 environment={localEnvironment}
508 onChange={setLocalEnvironment}
509 />
510 ) : null}
461511 </SettingsGroup>
462512 {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? (
463513 <p className="text-2xs text-destructive">{acpSelectionError}</p>
......@@ -470,6 +520,7 @@ export function AddProviderInstanceDialog({
470520 providerInstances={settings.providerInstances}
471521 onPrepared={handleAcpPrepared}
472522 onManualConfiguration={handleManualAcpConfiguration}
523 onLocalConfiguration={handleLocalAcpConfiguration}
473524 onLoadingChange={setIsRegistryLoading}
474525 onPreparingChange={setIsPreparingRegistryAgent}
475526 />
......@@ -644,7 +695,11 @@ export function AddProviderInstanceDialog({
644695 </Button>
645696 ) : (
646697 <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"}
648703 </Button>
649704 )}
650705 </WizardFooter>
apps/web/src/components/settings/ProviderInstanceCard.tsx+11-3
......@@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: {
296296 );
297297}
298298
299function ProviderEnvironmentSection(props: {
299export function ProviderEnvironmentSection(props: {
300300 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
301301 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
302302}) {
......@@ -717,8 +717,16 @@ export function ProviderInstanceCard({
717717 driverKind={driverKind ?? instance.driver}
718718 displayName={displayName}
719719 accentColor={accentColor}
720 acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined}
721 acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined}
720 acpRegistryAgentId={
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 }
722730 showBadge={Boolean(accentColor)}
723731 className="size-5"
724732 iconClassName="size-4 text-foreground/80"
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8
......@@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => {
5757 expect(acpRegistry).toBeDefined();
5858 expect(acpRegistry?.hasDefaultInstance).toBe(false);
5959 expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([
60 "source",
6061 "agentId",
6162 "commandPath",
6263 "authMethodId",
6364 ]);
6465 });
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
6674 it("derives a select control with its choices for the Antigravity sign-in method", () => {
6775 const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")];
6876 expect(antigravity).toBeDefined();
apps/web/src/components/settings/ProviderSettingsForm.tsx+134-7
......@@ -1,6 +1,6 @@
11"use client";
22
3import { useMemo, type ReactNode } from "react";
3import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
44import * as Option from "effect/Option";
55import * as Schema from "effect/Schema";
66import type {
......@@ -9,8 +9,10 @@ import type {
99 ProviderSettingsFormOption,
1010 ProviderSettingsFormSchemaAnnotation,
1111} from "@t3tools/contracts";
12import { PlusIcon, XIcon } from "lucide-react";
1213
1314import { cn } from "../../lib/utils";
15import { Button } from "../ui/button";
1416import { DraftInput } from "../ui/draft-input";
1517import { Input } from "../ui/input";
1618import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
......@@ -76,7 +78,10 @@ function readFieldBooleanDefault(
7678
7779export function deriveProviderSettingsFields(
7880 definition: ProviderClientDefinition,
81 value?: unknown,
7982): ReadonlyArray<ProviderSettingsFieldModel> {
83 const isLocalAcp =
84 definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local";
8085 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
8186 const orderedKeys = new Map(
8287 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
......@@ -95,6 +100,7 @@ export function deriveProviderSettingsFields(
95100 const fieldSchema = definition.settingsSchema.fields[key]!;
96101 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
97102 if (formAnnotation.hidden) return [];
103 if (isLocalAcp && key !== "source" && key !== "commandPath") return [];
98104
99105 const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");
100106 const annotatedDescription = readFieldAnnotationString(fieldSchema, "description");
......@@ -102,11 +108,20 @@ export function deriveProviderSettingsFields(
102108 {
103109 key,
104110 control: formAnnotation.control ?? "text",
105 label: annotatedTitle ?? titleizeFieldKey(key),
106 ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}),
107 ...(formAnnotation.placeholder !== undefined
108 ? { placeholder: formAnnotation.placeholder }
109 : {}),
111 label:
112 isLocalAcp && key === "commandPath"
113 ? "Executable"
114 : (annotatedTitle ?? titleizeFieldKey(key)),
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 : {}),
110125 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
111126 ...(formAnnotation.control === "switch"
112127 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
......@@ -119,6 +134,112 @@ export function deriveProviderSettingsFields(
119134 });
120135}
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
122243function readProviderConfigString(config: unknown, key: string): string {
123244 if (config === null || typeof config !== "object") return "";
124245 const value = (config as Record<string, unknown>)[key];
......@@ -411,7 +532,12 @@ export function ProviderSettingsForm({
411532 variant,
412533 onChange,
413534}: 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
416542 if (fields.length === 0) {
417543 return null;
......@@ -429,6 +555,7 @@ export function ProviderSettingsForm({
429555 onChange={onChange}
430556 />
431557 ))}
558 {isLocalAcp ? <ProviderCommandArguments value={value} onChange={onChange} /> : null}
432559 </>
433560 );
434561}
apps/web/src/components/settings/ProviderSettingsPanel.tsx+1
......@@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({
922922 }
923923
924924 if (row.driver !== ProviderDriverKind.make("acpRegistry")) return;
925 if (providerConfigString(row.instance.config, "source") === "local") return;
925926 const agentId = providerConfigString(row.instance.config, "agentId");
926927 if (agentId === null) return;
927928
apps/web/src/providerInstances.test.ts+39
......@@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => {
619619});
620620
621621describe("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
622661 it("retains server-published registry icons without local settings", () => {
623662 const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg";
624663 const [entry] = deriveProviderInstanceEntries([
apps/web/src/providerInstances.ts+3
......@@ -210,6 +210,9 @@ export function applyProviderInstanceSettings(
210210 explicitInstance.config !== null && typeof explicitInstance.config === "object"
211211 ? (explicitInstance.config as Readonly<Record<string, unknown>>)
212212 : null;
213 if (config?.source === "local") {
214 return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined };
215 }
213216 const agentId = config?.agentId;
214217 const iconUrl = config?.registryIconUrl;
215218 return {
docs/user/providers-acp.md+13-2
......@@ -1,6 +1,6 @@
1# ACP Registry
1# ACP providers
22
3T3 Code can run coding agents from the official
3T3 Code can run installed ACP commands or coding agents from the official
44[ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their
55own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task
66delegation.
......@@ -23,6 +23,17 @@ work through the same generic integration.
2323Search only shows agents that can run on the connected server. Registry agents are third-party
2424code; 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
2637## Where agents run
2738
2839Registry 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
871871
872872export const AcpRegistrySettings = makeProviderSettingsSchema(
873873 {
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 ),
874887 enabled: Schema.Boolean.pipe(
875888 Schema.withDecodingDefault(Effect.succeed(true)),
876889 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
......@@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
888901 Schema.annotateKey({
889902 title: "Executable override",
890903 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.",
892905 providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" },
893906 }),
894907 ),
908 commandArgs: Schema.Array(Schema.String).pipe(
909 Schema.withDecodingDefault(Effect.succeed([])),
910 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
911 ),
895912 authMethodId: TrimmedString.pipe(
896913 Schema.withDecodingDefault(Effect.succeed("")),
897914 Schema.annotateKey({
......@@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
911928 ),
912929 },
913930 {
914 order: ["agentId", "commandPath", "authMethodId"],
931 order: ["source", "agentId", "commandPath", "authMethodId"],
915932 },
916933);
917934export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;