diff --git a/apps/server/src/policy/ManagedPolicy.test.ts b/apps/server/src/policy/ManagedPolicy.test.ts index 26e74f05b0372..21b8f31895266 100644 --- a/apps/server/src/policy/ManagedPolicy.test.ts +++ b/apps/server/src/policy/ManagedPolicy.test.ts @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; import { DEFAULT_SERVER_SETTINGS, + managedAcpAgentDenial, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; @@ -399,6 +400,41 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => { }), ); + it.effect("takes the launch command from users when it limits ACP agents", () => + Effect.gen(function* () { + const policy = yield* loadFrom( + JSON.stringify({ + schemaVersion: 1, + organization: "Example Corp", + limits: { providers: { allowedAcpAgents: ["devin"] } }, + }), + ); + const acpRegistry = ProviderDriverKind.make("acpRegistry"); + assert.deepEqual(policy.summary?.limits?.providers, { + allowedAcpAgents: ["devin"], + lockedInstanceFields: { acpRegistry: ["config.commandPath", "config.commandArgs"] }, + }); + assert.isUndefined(managedAcpAgentDenial(policy.summary, "devin")); + assert.strictEqual( + managedAcpAgentDenial(policy.summary, "other"), + "Example Corp's policy does not allow the ACP agent other.", + ); + assert.isUndefined(managedAcpAgentDenial(undefined, "other")); + + const instanceId = ProviderInstanceId.make("acp_local"); + const shown = policy.providers.applyLimits({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: acpRegistry, + config: { source: "local", commandPath: "/bin/sh", commandArgs: ["-c", "x"] }, + }, + }, + }); + assert.deepEqual(shown.providerInstances[instanceId]?.config, { source: "local" }); + }), + ); + it.effect("refuses provider limits it cannot enforce", () => Effect.gen(function* () { const withProviders = (providers: unknown) => diff --git a/apps/server/src/policy/ManagedPolicy.ts b/apps/server/src/policy/ManagedPolicy.ts index 01651051893c0..cf3830ae2b325 100644 --- a/apps/server/src/policy/ManagedPolicy.ts +++ b/apps/server/src/policy/ManagedPolicy.ts @@ -146,13 +146,18 @@ function summarizeLimits( ...(webhookTasks === undefined ? {} : { webhookTasks }), ...(telemetry === undefined ? {} : { telemetry }), ...(browserCookieImport === undefined ? {} : { browserCookieImport }), - ...(providers?.allowedDrivers === undefined && Object.keys(lockedInstanceFields).length === 0 + ...(providers?.allowedDrivers === undefined && + providers?.allowedAcpAgents === undefined && + Object.keys(lockedInstanceFields).length === 0 ? {} : { providers: { ...(providers?.allowedDrivers === undefined ? {} : { allowedDrivers: providers.allowedDrivers }), + ...(providers?.allowedAcpAgents === undefined + ? {} + : { allowedAcpAgents: providers.allowedAcpAgents }), ...(Object.keys(lockedInstanceFields).length === 0 ? {} : { lockedInstanceFields }), }, }), diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index ac390097e150d..5197e3c32edf5 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -352,6 +352,18 @@ export function makeManagedProviders( } } + // A launch command runs instead of the registry agent, so an allow list owns it. + if (limits.allowedAcpAgents !== undefined) { + const driver = ProviderDriverKind.make("acpRegistry"); + const rule = rules.get(driver); + rules.set(driver, { + pin: rule?.pin, + config: [...new Set([...(rule?.config ?? []), "commandPath", "commandArgs"])], + fill: rule?.fill ?? [], + environment: rule?.environment ?? "fill", + }); + } + const allowed = limits.allowedDrivers === undefined ? undefined : new Set(limits.allowedDrivers); const isDriverAllowed = (driver: ProviderDriverKind) => allowed?.has(driver) ?? true; diff --git a/apps/server/src/policy/policyTemplates.ts b/apps/server/src/policy/policyTemplates.ts index 55c15fb94f56c..9c9a883cb1971 100644 --- a/apps/server/src/policy/policyTemplates.ts +++ b/apps/server/src/policy/policyTemplates.ts @@ -175,6 +175,14 @@ const policies = (): ReadonlyArray => [ { kind: "text", name: "ProviderInstances", label: "Provider settings JSON", json: true }, ], }, + { + id: "AllowedAcpAgents", + title: "Allowed ACP agents (JSON)", + help: 'ACP Registry agents that may run, as a JSON list of IDs such as ["devin"]. Users cannot set their own launch command for an ACP agent.', + elements: [ + { kind: "text", name: "AllowedAcpAgents", label: "Allowed ACP agents JSON", json: true }, + ], + }, ]; const escapeXml = (text: string) => diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 6398210599b61..25f951c9fbf98 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -23,6 +23,7 @@ export const layer = Layer.effect( const digest = yield* Digest.Digest; const fileSystem = yield* FileSystem.FileSystem; return ProviderHost.ProviderHost.of({ + managedPolicy: serverSettings.managedPolicy, paths: { cwd: config.cwd, baseDir: config.baseDir, diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx index a3612a609418c..03dec9b119b93 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx @@ -1,5 +1,9 @@ import { pipe } from "effect/Function"; -import { ACP_REGISTRY_MAX_AGENTS, AuthProvidersManageScope } from "@t3tools/contracts"; +import { + ACP_REGISTRY_MAX_AGENTS, + AuthProvidersManageScope, + managedAcpAgentsNotice, +} from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { isAtomCommandInterrupted, @@ -17,6 +21,7 @@ import { type FormEvent, useState } from "react"; import { useDeadlinePassed } from "../../hooks/useDeadline"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentQuery } from "../../state/query"; +import { useManagedPolicy } from "../../state/managedPolicy"; import { useAtomCommand } from "../../state/use-atom-command"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; @@ -126,7 +131,7 @@ interface AcpRegistrySearchStepProps { readonly providerInstances: Readonly>; readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; readonly onManualConfiguration: () => void; - readonly onLocalConfiguration?: () => void; + readonly onLocalConfiguration?: (() => void) | undefined; } export function AcpRegistrySearchStep({ @@ -138,6 +143,7 @@ export function AcpRegistrySearchStep({ onLocalConfiguration, }: AcpRegistrySearchStepProps) { const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); + const agentsLimited = managedAcpAgentsNotice(useManagedPolicy(environmentId)) !== undefined; const { query, search, preparingId, prepareError } = registry; const handleSearch = (event: FormEvent) => { @@ -224,7 +230,11 @@ export function AcpRegistrySearchStep({ results.length === 0 ? (

No compatible agents found

-

Try a broader search.

+

+ {agentsLimited + ? "Only agents your organization allows appear here." + : "Try a broader search."} +

) : ( diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx index 568c6a953ffc7..1251da39af887 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx @@ -1,4 +1,9 @@ -import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + type AcpRegistrySearchAgent, + type ManagedPolicySummary, +} from "@t3tools/contracts"; import { userEvent } from "vite-plus/test/browser"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -9,6 +14,9 @@ const actions = vi.hoisted(() => ({ onOpenChange: vi.fn(), canManageProviders: true, })); +const managed = vi.hoisted(() => ({ + policy: undefined as ManagedPolicySummary | undefined, +})); const settingsHooks = vi.hoisted(() => ({ read: vi.fn(() => ({ providerInstances: {} })), mutate: vi.fn(), @@ -46,6 +54,10 @@ vi.mock(import("../../state/session"), async (original) => { readEnvironmentScope: hasScope as never, }; }); +vi.mock(import("../../state/managedPolicy"), async (original) => ({ + ...(await original()), + useManagedPolicy: () => managed.policy, +})); vi.mock(import("../ui/toast"), async (original) => { const actual = await original(); return { ...actual, toastManager: { ...actual.toastManager, add: actions.toast } }; @@ -140,6 +152,7 @@ async function prepareCodexInstance() { } function setup() { + managed.policy = undefined; actions.canManageProviders = true; actions.toast.mockReset(); actions.onOpenChange.mockReset(); @@ -291,6 +304,23 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(signInStep()).toBeNull(); }); + it("refuses a manually entered agent the policy leaves out", async () => { + setup(); + managed.policy = { + organization: "Example Corp", + lockedSettings: [], + limits: { providers: { allowedAcpAgents: ["devin"] } }, + }; + await using _screen = await render(dialog()); + await click("Show more"); + await click("Enter registry agent"); + await type("#add-provider-acpRegistry-manual-agentId", "cline"); + await click("Next"); + expect(document.body.textContent).toContain( + "Example Corp's policy does not allow the ACP agent cline.", + ); + }); + it("rejects a queued save after the provider grant is revoked", async () => { setup(); await using screen = await prepareCodexInstance(); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 9b1829879405f..318067c5c2f74 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -18,6 +18,8 @@ import { type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, isManagedDriverAllowed, + managedAcpAgentDenial, + managedAcpAgentsNotice, } from "@t3tools/contracts"; import { @@ -28,6 +30,7 @@ import { import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { managedInstanceLocks, useManagedPolicy } from "../../state/managedPolicy"; +import { ManagedNotice } from "../ManagedPolicy"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { Button } from "../ui/button"; import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; @@ -236,9 +239,11 @@ export function AddProviderInstanceDialog({ ? localCommandPath.length > 0 ? null : "Executable is required." - : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) + : selectedAcp !== null ? null - : "Select an ACP or configure one manually."; + : isManualAcpConfiguration && manualAgentId.length > 0 + ? (managedAcpAgentDenial(managedPolicy, manualAgentId) ?? null) + : "Select an ACP or configure one manually."; const wizardStepSummaries = isAcpRegistry ? ([ isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), @@ -366,6 +371,8 @@ export function AddProviderInstanceDialog({ // A policy that owns this driver's launch settings also rules out ChatGPT's own runtime. const driverLocks = managedInstanceLocks(managedPolicy, driver); + const acpLocks = managedInstanceLocks(managedPolicy, ACP_REGISTRY_DRIVER_KIND); + const acpAgentsNotice = managedAcpAgentsNotice(managedPolicy); const handleSave = async () => { if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; @@ -510,6 +517,9 @@ export function AddProviderInstanceDialog({
)} + {acpRegistryAllowed && acpAgentsNotice ? ( + {acpAgentsNotice} + ) : null} {!acpRegistryAllowed ? null : isAcpRegistry && isManualAcpConfiguration ? (
@@ -541,6 +551,9 @@ export function AddProviderInstanceDialog({ idPrefix="add-provider-acpRegistry-manual" variant="settings" onChange={setConfigDraft} + managed={ + acpLocks && { keys: acpLocks.configKeys, reason: acpLocks.reason } + } environmentId={environmentId} /> {isLocalAcp ? ( @@ -563,7 +576,9 @@ export function AddProviderInstanceDialog({ providerInstances={settings.providerInstances} onPrepared={handleAcpPrepared} onManualConfiguration={handleManualAcpConfiguration} - onLocalConfiguration={handleLocalAcpConfiguration} + onLocalConfiguration={ + acpAgentsNotice ? undefined : handleLocalAcpConfiguration + } /> {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? (

{acpSelectionError}

diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index b676e64399d97..ff984ad5d9fb7 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -49,7 +49,11 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { ManagedControl, ManagedNotice } from "../ManagedPolicy"; import { managedInstanceLocks, useManagedPolicy } from "../../state/managedPolicy"; -import { disallowedProviderMessage, isManagedDriverAllowed } from "@t3tools/contracts"; +import { + disallowedProviderMessage, + isManagedDriverAllowed, + managedAcpAgentsNotice, +} from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { @@ -772,6 +776,8 @@ export function ProviderInstanceCard({ const accentColor = normalizeProviderAccentColor(instance.accentColor); const managedPolicy = useManagedPolicy(environmentId); const instanceLocks = managedInstanceLocks(managedPolicy, instance.driver); + const acpAgentsNotice = + instance.driver === "acpRegistry" ? managedAcpAgentsNotice(managedPolicy) : undefined; const managedDriverReason = isManagedDriverAllowed(managedPolicy, instance.driver) ? undefined : disallowedProviderMessage( @@ -1335,6 +1341,11 @@ export function ProviderInstanceCard({ aria-disabled={readOnly || undefined} className={readOnly ? "opacity-50 select-none" : undefined} > + {acpAgentsNotice ? ( +
+ {acpAgentsNotice} +
+ ) : null} {runtimeFields} ) : null} diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts index bc4b9674b674f..6bf078a7d4cc0 100644 --- a/apps/web/src/components/settings/providerStatus.test.ts +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -98,6 +98,12 @@ describe("Antigravity with unchecked sign-in", () => { headline: "Not found", status: "warning", }, + { + name: "refused by policy", + change: { installed: false, status: "error", availability: "unavailable" }, + headline: "Unavailable", + status: "error", + }, { name: "API key authentication", change: { auth: { status: "unknown", type: "gemini-api-key" } }, diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index a4db548906549..de7cb7736ee32 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -58,6 +58,12 @@ export function getProviderSummary(provider: ServerProvider | undefined) { provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", }; } + if (provider.availability === "unavailable") { + return { + headline: "Unavailable", + detail: provider.unavailableReason ?? provider.message ?? null, + }; + } if (!provider.installed) { return { headline: "Not found", diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index c18bb6aa93939..8682c6c7dfaed 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -204,8 +204,8 @@ own `claude` earlier on their `PATH`. Pin an absolute `binaryPath` in `providers provider you allow. ACP agents' own mode menus have no effect under `maxRuntimeMode`, except Antigravity's, whose modes -T3 Code can place under the limit. Users still choose which ACP Registry agent runs; leave -`acpRegistry` out of `allowedDrivers` to stop that. +T3 Code can place under the limit. Users still choose which ACP Registry agent runs; list the ones +they may use in `providers.allowedAcpAgents`, or leave `acpRegistry` out of `allowedDrivers`. The limit decides which modes T3 Code asks the provider for. It is not a sandbox; see [What a policy does not cover](#what-a-policy-does-not-cover). @@ -270,6 +270,17 @@ empty. A `codex` entry that is locked, or any `codex` entry under `maxRuntimeMod your fields. In the same way, an `opencode` entry like that rules out a server URL, so OpenCode starts the binary you set. +`providers.allowedAcpAgents` lists the ACP Registry agents that may run, by registry ID, such as +`["devin"]`. Other agents disappear from the add dialog, and instances already using them show as +unavailable with the policy as the reason. Users also cannot run a local command or override an +agent's executable, since either would run something other than the listed agent, so instances +that use a local command stop too. T3 Code does not check the IDs against the registry, so a +misspelled ID matches nothing. + +This limit picks the agent, not everything around it. A user can still set variables on the +provider, and `PATH` decides which `npm` or `uv` installs an agent. `maxRuntimeMode` locks those +variables too. + ### Updates - `mode`: `auto` keeps the normal update flow. `off` stops T3 Code from checking for updates, for diff --git a/packages/contracts/src/managedPolicy.ts b/packages/contracts/src/managedPolicy.ts index 7d1c7ed8b8aae..6e156a89fca01 100644 --- a/packages/contracts/src/managedPolicy.ts +++ b/packages/contracts/src/managedPolicy.ts @@ -37,6 +37,8 @@ export const ManagedProviderLimits = Schema.Struct({ /** Only these drivers run; instances of any other driver show as unavailable. */ allowedDrivers: Schema.optionalKey(Schema.NonEmptyArray(ProviderDriverKind)), instances: Schema.optionalKey(Schema.Record(ProviderDriverKind, ManagedProviderInstance)), + /** Only these ACP Registry agents run, and users cannot set their own launch command. */ + allowedAcpAgents: Schema.optionalKey(Schema.NonEmptyArray(TrimmedNonEmptyString)), }); export type ManagedProviderLimits = typeof ManagedProviderLimits.Type; @@ -99,6 +101,8 @@ export const ManagedPolicySummaryLimits = Schema.Struct({ Schema.Struct({ /** Absent means every driver is allowed. */ allowedDrivers: Schema.optionalKey(Schema.NonEmptyArray(ProviderDriverKind)), + /** Absent means every ACP Registry agent is allowed. */ + allowedAcpAgents: Schema.optionalKey(Schema.NonEmptyArray(TrimmedNonEmptyString)), /** * Per driver, the instance fields users cannot change, as dot paths into the instance * envelope: `config.binaryPath`, `config.launchArgs`, `environment.`. A bare @@ -236,6 +240,28 @@ export function isManagedDriverAllowed( return allowed === undefined || allowed.includes(driver); } +/** Why `agentId` may not run from the ACP Registry on an environment, or undefined when it may. */ +export function managedAcpAgentDenial(summary: ManagedPolicySummary | undefined, agentId: string) { + const allowed = summary?.limits?.providers?.allowedAcpAgents; + return summary === undefined || allowed === undefined || allowed.includes(agentId) + ? undefined + : managedPolicyDeniedMessage(summary.organization, `the ACP agent ${agentId}`); +} + +/** Why local ACP commands may not run on an environment, or undefined when they may. */ +export function managedLocalAcpCommandDenial(summary: ManagedPolicySummary | undefined) { + return summary?.limits?.providers?.allowedAcpAgents === undefined + ? undefined + : managedPolicyDeniedMessage(summary.organization, "local ACP commands"); +} + +/** What ACP settings show while the policy limits agents, or undefined when it does not. */ +export function managedAcpAgentsNotice(summary: ManagedPolicySummary | undefined) { + return summary?.limits?.providers?.allowedAcpAgents === undefined + ? undefined + : `${summary.organization} chooses which ACP agents run here, so local commands and executable overrides are turned off.`; +} + /** Why updates are unavailable on an environment with `summary`, or undefined when allowed. */ export function managedUpdatesDenial(summary: ManagedPolicySummary | undefined) { return summary?.limits?.updates?.mode === "off" diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 02a32ac40250d..1c29eab6ae750 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -841,6 +841,76 @@ describe("AcpRegistrySupport", () => { ); }); + it.effect("hides and refuses agents the policy leaves out", () => { + const allowed = makeAgent({ npx: { package: "@example/acp@V1.2.3" } }); + const refused = { ...allowed, id: "refused-agent", name: "Refused Agent" }; + const denial = "Example Corp's policy does not allow the ACP agent refused-agent."; + const localDenial = "Example Corp's policy does not allow local ACP commands."; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-allowed-agents-", + }); + const toolchain = yield* makeFakeNpmToolchain(cacheDir); + const catalog = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + agentDenial: (agentId) => (agentId === allowed.id ? undefined : denial), + localCommandDenial: localDenial, + }).pipe(Effect.provideService(HostProcess.Environment, toolchain.environment)); + + const found = yield* catalog.search({ query: "agent" }); + expect(found.agents.map((agent) => agent.id)).toEqual([allowed.id]); + + const blocked = settings({ agentId: refused.id }); + const errors = [ + yield* Effect.flip(catalog.prepare({ agentId: refused.id })), + yield* Effect.flip(catalog.resolve(blocked, "/workspace", toolchain.environment)), + ]; + expect(errors.map((error) => error.detail)).toEqual([denial, denial]); + expect(yield* catalog.inspect(blocked, toolchain.environment)).toEqual({ + status: "denied", + message: denial, + }); + for (const decoded of [ + decodeAcpRegistrySettings({ source: "local" }), + decodeAcpRegistrySettings({ source: "local", commandPath: toolchain.executablePath }), + settings({ commandPath: toolchain.executablePath }), + ]) { + expect(yield* catalog.inspect(decoded, toolchain.environment)).toEqual({ + status: "denied", + message: localDenial, + }); + const error = yield* Effect.flip( + catalog.resolve(decoded, "/workspace", toolchain.environment), + ); + expect(error.detail).toBe(localDenial); + } + const commands = yield* fileSystem + .readFileString(toolchain.logPath) + .pipe(Effect.orElseSucceed(() => "")); + expect(commands).not.toContain("install --global"); + + const prepared = yield* catalog.prepare({ agentId: allowed.id }); + expect(prepared.agentId).toBe(allowed.id); + const launched = yield* catalog.resolve(settings(), "/workspace", toolchain.environment); + expect(launched.spawn.command).toBe(toolchain.executablePath); + }).pipe( + Effect.scoped, + Effect.provide( + layerResolver((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version: "1.0.0", agents: [allowed, refused] })), + ), + ), + ), + ), + ); + }); + it.effect("searches reuse an index fetched in the last five minutes", () => { const agent = makeAgent({ npx: { package: "@example/acp@1.2.3" } }); let requests = 0; diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index 99b1cfdc9f958..c614ad6913557 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -2,6 +2,8 @@ import { ACP_REGISTRY_MAX_AGENTS, AcpRegistryOperationError, AcpRegistryOperationErrorReason, + managedAcpAgentDenial, + managedLocalAcpCommandDenial, TrimmedNonEmptyString, type AcpRegistryManagedBinaryUninstallInput, type AcpRegistryManagedBinaryUninstallResult, @@ -469,6 +471,7 @@ export interface ResolvedAcpRegistryAgent { export type AcpRegistryInspection = | { readonly status: "unconfigured" } + | { readonly status: "denied"; readonly message: string } | { readonly status: "not_found"; readonly agentId: string } | { readonly status: "unsupported"; @@ -531,11 +534,13 @@ const layer = (options: AcpRegistryCatalogOptions) => export const layerFromHost = Layer.merge( Layer.unwrap( Effect.gen(function* () { - const { paths } = yield* ProviderHost.ProviderHost; + const { paths, managedPolicy } = yield* ProviderHost.ProviderHost; const path = yield* Path.Path; return layer({ cacheDir: paths.providerStatusCacheDir, toolsDir: path.join(paths.baseDir, "tools"), + agentDenial: (agentId) => managedAcpAgentDenial(managedPolicy, agentId), + localCommandDenial: managedLocalAcpCommandDenial(managedPolicy), }); }), ), @@ -546,6 +551,10 @@ export interface AcpRegistryCatalogOptions { readonly cacheDir: string; readonly toolsDir: string; readonly registryUrl?: string; + /** Why policy keeps an agent from running, or undefined when it may. */ + readonly agentDenial?: (agentId: string) => string | undefined; + /** Why policy keeps a local command from running, or undefined when it may. */ + readonly localCommandDenial?: string | undefined; } const INSTALL_LOCK_RETRY_COUNT = 300; @@ -654,6 +663,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const resolveExecutable = yield* SpawnExecutableResolution; const platformTarget = resolveAcpRegistryPlatformTarget(platform, architecture); const registryUrl = input.registryUrl ?? ACP_REGISTRY_URL; + const { agentDenial, localCommandDenial } = input; const registryDirectory = path.join(input.cacheDir, "acp-registry"); const registryCachePath = path.join(registryDirectory, "registry.json"); const installsDirectory = input.toolsDir; @@ -1594,6 +1604,18 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ); }); + const refuseUnderPolicy = (agentId: string) => { + const detail = agentDenial?.(agentId); + return detail === undefined + ? Effect.void + : Effect.fail(new AcpRegistryError({ reason: "agent_not_found", detail })); + }; + + const commandDenial = (settings: AcpRegistrySettings) => + settings.source === "local" || settings.commandPath.trim().length > 0 + ? localCommandDenial + : undefined; + const findAgent = Effect.fn("AcpRegistryCatalog.findAgent")(function* ( registry: AcpRegistryIndex, agentId: string, @@ -1641,7 +1663,10 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const packageManagerAvailable = packageManager === undefined || resolveExecutable(packageManager, platform, hostEnvironment) !== undefined; - return distribution === undefined || rank === undefined || !packageManagerAvailable + return distribution === undefined || + rank === undefined || + !packageManagerAvailable || + agentDenial?.(agent.id) !== undefined ? [] : [{ agent, distribution, rank }]; }); @@ -1675,6 +1700,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const prepare: AcpRegistryCatalog["Service"]["prepare"] = Effect.fn("AcpRegistryCatalog.prepare")( function* (input) { + yield* refuseUnderPolicy(input.agentId.trim()); const registry = yield* refreshRegistry(); const agent = yield* findAgent(registry, input.agentId); const distribution = yield* compatibleDistribution(agent, "auto"); @@ -1712,6 +1738,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const inspect: AcpRegistryCatalog["Service"]["inspect"] = Effect.fn("AcpRegistryCatalog.inspect")( function* (settings, environment) { + const refusal = commandDenial(settings); + if (refusal !== undefined) return { status: "denied", message: refusal } as const; if (settings.source === "local") { const command = settings.commandPath.trim(); if (!command) return { status: "unconfigured" } as const; @@ -1727,6 +1755,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct } const agentId = settings.agentId.trim(); if (agentId.length === 0) return { status: "unconfigured" } as const; + const denial = agentDenial?.(agentId); + if (denial !== undefined) return { status: "denied", message: denial } as const; const registry = yield* loadCachedRegistry(); const agent = registry.agents.find((candidate) => candidate.id === agentId); if (agent === undefined) return { status: "not_found", agentId } as const; @@ -1826,6 +1856,10 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const resolve: AcpRegistryCatalog["Service"]["resolve"] = Effect.fn("AcpRegistryCatalog.resolve")( function* (settings, cwd, environment) { + const refusal = commandDenial(settings); + if (refusal !== undefined) { + return yield* new AcpRegistryError({ reason: "agent_not_found", detail: refusal }); + } if (settings.source === "local") { const executable = settings.commandPath.trim(); if (!executable) { @@ -1855,6 +1889,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct detail: "ACP Registry provider requires a registry agent ID.", }); } + yield* refuseUnderPolicy(agentId); const registry = yield* loadRegistry(); const agent = yield* findAgent(registry, agentId); const distribution = yield* compatibleDistribution(agent, settings.distribution); diff --git a/packages/provider-acp-registry/src/server/auth.ts b/packages/provider-acp-registry/src/server/auth.ts index 240bca67cea57..02ea59e4ab49b 100644 --- a/packages/provider-acp-registry/src/server/auth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -101,6 +101,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o failure("methods", "Could not inspect the selected ACP agent.", cause), ), ); + if (inspected.status === "denied") return yield* failure("methods", inspected.message); if (inspected.status !== "ready") return yield* failure("methods", "Prepare this ACP agent before signing in."); // Path overrides can change in place; cache discovery only for registry versions. diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index 7fba45fd6bec0..da372fb55b26f 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -43,6 +43,23 @@ function catalogWithInspection( } describe("acpRegistrySnapshotReadiness", () => { + it("reports a policy refusal as unavailable, not as a missing agent", () => { + const message = "Example Corp's policy does not allow the ACP agent test-agent."; + const provider = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { status: "denied", message }, + }); + expect(provider).toMatchObject({ + installed: false, + status: "error", + message, + availability: "unavailable", + unavailableReason: message, + }); + }); + it("treats a live empty command advertisement as an authoritative replacement", () => { const provider = buildCheckedAcpRegistrySnapshot({ ...identity, diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index c154b7a2bc6fd..153ef1a1d05da 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -133,6 +133,8 @@ function acpRegistrySnapshotReadiness( message: "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", }; + case "denied": + return { installed: false, version: null, status: "error", message: inspection.message }; case "not_found": return { installed: false, @@ -342,7 +344,7 @@ export function buildCheckedAcpRegistrySnapshot( ? `Set ${advertisedAuthMethod.envVarNames!.join(", ")} under this instance's environment variables in provider settings. T3 Code will detect it on the next provider refresh.` : `Sign in in provider settings using "${advertisedAuthMethod.name}".` : undefined; - return baseSnapshot({ + const snapshot = baseSnapshot({ ...input, ...(input.inspection.status === "ready" && input.inspection.documentationUrl ? { documentationUrl: input.inspection.documentationUrl } @@ -374,6 +376,9 @@ export function buildCheckedAcpRegistrySnapshot( ? { message: readiness.message } : {}), }); + return input.inspection.status === "denied" + ? { ...snapshot, availability: "unavailable", unavailableReason: input.inspection.message } + : snapshot; } export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.checkProviderStatus")( diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 298613bf13c91..ab12b19451a1b 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -1,6 +1,7 @@ import type { BackgroundScope, ChatAttachment, + ManagedPolicySummary, ServerSettings, ServerSettingsError, } from "@t3tools/contracts"; @@ -33,6 +34,7 @@ export class ProviderHost extends Context.Service< ProviderHost, { readonly paths: ProviderHostPaths; + readonly managedPolicy?: ManagedPolicySummary | undefined; readonly settings: { readonly get: Effect.Effect; /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */ diff --git a/packages/shared/src/managedPolicyFile.ts b/packages/shared/src/managedPolicyFile.ts index 2f775abb8a02a..b4f268661ab06 100644 --- a/packages/shared/src/managedPolicyFile.ts +++ b/packages/shared/src/managedPolicyFile.ts @@ -115,6 +115,7 @@ export const WINDOWS_POLICY_VALUES = { Telemetry: { type: "boolean", path: ["limits", "telemetry"] }, BrowserCookieImport: { type: "boolean", path: ["limits", "browserCookieImport"] }, ProviderInstances: { type: "json", path: ["limits", "providers", "instances"] }, + AllowedAcpAgents: { type: "json", path: ["limits", "providers", "allowedAcpAgents"] }, UpdatesMode: { type: "string", path: ["limits", "updates", "mode"] }, UpdatesFeedUrl: { type: "string", path: ["limits", "updates", "feedUrl"] }, AllowNightly: { type: "boolean", path: ["limits", "updates", "allowNightly"] },