Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/server/src/policy/ManagedPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Path from "effect/Path";

import {
DEFAULT_SERVER_SETTINGS,
managedAcpAgentDenial,
ProviderDriverKind,
ProviderInstanceId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -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<unknown>(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<unknown>(shown.providerInstances[instanceId]?.config, { source: "local" });
}),
);

it.effect("refuses provider limits it cannot enforce", () =>
Effect.gen(function* () {
const withProviders = (providers: unknown) =>
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/policy/ManagedPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
}),
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/policy/managedProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
}

const allowed =
limits.allowedDrivers === undefined ? undefined : new Set<string>(limits.allowedDrivers);
const isDriverAllowed = (driver: ProviderDriverKind) => allowed?.has(driver) ?? true;
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/policy/policyTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ const policies = (): ReadonlyArray<Policy> => [
{ 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) =>
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/ProviderHostLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/components/settings/AcpRegistrySearchStep.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -126,7 +131,7 @@ interface AcpRegistrySearchStepProps {
readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>;
readonly onPrepared: (agent: AcpRegistrySearchAgent) => void;
readonly onManualConfiguration: () => void;
readonly onLocalConfiguration?: () => void;
readonly onLocalConfiguration?: (() => void) | undefined;
}

export function AcpRegistrySearchStep({
Expand All @@ -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<HTMLFormElement>) => {
Expand Down Expand Up @@ -224,7 +230,11 @@ export function AcpRegistrySearchStep({
results.length === 0 ? (
<div className="flex min-h-28 flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center">
<p className="text-sm font-medium">No compatible agents found</p>
<p className="mt-1 text-xs text-muted-foreground">Try a broader search.</p>
<p className="mt-1 text-xs text-muted-foreground">
{agentsLimited
? "Only agents your organization allows appear here."
: "Try a broader search."}
</p>
</div>
) : (
<ScrollArea scrollFade className="max-h-64">
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(),
Expand Down Expand Up @@ -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 } };
Expand Down Expand Up @@ -140,6 +152,7 @@ async function prepareCodexInstance() {
}

function setup() {
managed.policy = undefined;
actions.canManageProviders = true;
actions.toast.mockReset();
actions.onOpenChange.mockReset();
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 18 additions & 3 deletions apps/web/src/components/settings/AddProviderInstanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type ProviderInstanceConfig,
type ProviderInstanceEnvironmentVariable,
isManagedDriverAllowed,
managedAcpAgentDenial,
managedAcpAgentsNotice,
} from "@t3tools/contracts";

import {
Expand All @@ -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";
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -510,6 +517,9 @@ export function AddProviderInstanceDialog({
<div aria-hidden className="flex-1 border-t border-border/70" />
</div>
)}
{acpRegistryAllowed && acpAgentsNotice ? (
<ManagedNotice>{acpAgentsNotice}</ManagedNotice>
) : null}
{!acpRegistryAllowed ? null : isAcpRegistry && isManualAcpConfiguration ? (
<div className="grid gap-4">
<div className="flex items-start justify-between gap-3">
Expand Down Expand Up @@ -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 ? (
Expand All @@ -563,7 +576,9 @@ export function AddProviderInstanceDialog({
providerInstances={settings.providerInstances}
onPrepared={handleAcpPrepared}
onManualConfiguration={handleManualAcpConfiguration}
onLocalConfiguration={handleLocalAcpConfiguration}
onLocalConfiguration={
acpAgentsNotice ? undefined : handleLocalAcpConfiguration
}
/>
{isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? (
<p className="mt-2 text-2xs text-destructive">{acpSelectionError}</p>
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1335,6 +1341,11 @@ export function ProviderInstanceCard({
aria-disabled={readOnly || undefined}
className={readOnly ? "opacity-50 select-none" : undefined}
>
{acpAgentsNotice ? (
<div className="px-3 pt-2 sm:px-4">
<ManagedNotice>{acpAgentsNotice}</ManagedNotice>
</div>
) : null}
{runtimeFields}
</SettingsSection>
) : null}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/providerStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/providerStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions docs/user/managed-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading