mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-01 06:51:22 +00:00
### Summary Addresses [IJAI-2956](https://youtrack.jetbrains.com/issue/IJAI-2956). - Recognize Central only when the active key source is `apiKeyHelper` and its effective helper command contains the whole word `central` and the word sequence `proxy start`, case-insensitively, allowing multiple whitespace characters. - Read the effective command through the existing `SettingsManager`/SDK settings resolution, including project/managed overrides, rather than reimplementing configuration merging or executing helpers. - Return `kind: gateway` and the exact label `JetBrains Air Gateway`, without `detail` or invented account identity. URL, flags, routes, and endpoint ownership are intentionally not checked: this is an agreed weak display heuristic, not proof of provenance or entitlement. - Render ordinary helpers as `API key helper` instead of raw `apiKeyHelper`; do not expose unknown key-source identifiers. Preserve external backend and client-owned configuration priority, CLI/SDK merging, stale-probe protection, and duplicate suppression. - Add mapper and ACP-agent regression coverage for positive/negative commands, overrides, CLI/SDK results, and source changes; update `docs/air-extensions.md`. Examples matching the heuristic include `central proxy start` and `/opt/bin/CeNtRaL PrOxY StArT --arbitrary-flag`. `decentralized proxy start`, `central start`, and an inactive helper do not match. ### Sanitized auth status ```json { "kind": "gateway", "label": "JetBrains Air Gateway" } ``` No helper command, key, header, private route, or invented account information is sent. ### Verification Based on fresh upstream `main` at `0724b17`, rechecked immediately before publication. Changes and rebuilt `dist` are available in the main local clone; the user confirmed the local adapters work. ```bash npm run build npm run lint npm run test:run ``` - Build and lint passed. - Full test suite: **2484 passed, 30 existing skipped** across 44 passed test files and 4 skipped files. - No new skip flags or disabled tests were used. ### Scope No AIR UI, provisioning, personal configuration migration, effort/mode changes, or new ACP protocol. Plan files and personal configurations are not committed. --------- Co-authored-by: Junie <junie@jetbrains.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Andrey Bragin <andrey.bragin@jetbrains.com>
243 lines
6.8 KiB
TypeScript
243 lines
6.8 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
|
|
// SDK; API may shift in a future release.
|
|
import {
|
|
filterEscalatingDefaultMode,
|
|
resolveSettings,
|
|
type Settings,
|
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
import { claudeConfigDir } from "./paths.js";
|
|
|
|
/**
|
|
* Permission rule format examples:
|
|
* - "Read" - matches all Read tool calls
|
|
* - "Read(./.env)" - matches specific path
|
|
* - "Read(./.env.*)" - glob pattern
|
|
* - "Read(./secrets/**)" - recursive glob
|
|
* - "Bash(npm run lint)" - exact command prefix
|
|
* - "Bash(npm run:*)" - command prefix with wildcard
|
|
*
|
|
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
|
*/
|
|
|
|
function getManagedSettingsPath(): string {
|
|
switch (process.platform) {
|
|
case "darwin":
|
|
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
case "win32":
|
|
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
default:
|
|
return "/etc/claude-code/managed-settings.json";
|
|
}
|
|
}
|
|
|
|
export interface SettingsManagerOptions {
|
|
onChange?: () => void;
|
|
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
|
}
|
|
|
|
/**
|
|
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
|
* so the values we see match what `query()` would observe.
|
|
*
|
|
* Watches the user/project/local/managed settings files for changes and
|
|
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
|
* values from repo-committed sources are filtered out via
|
|
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
|
*/
|
|
export class SettingsManager {
|
|
private cwd: string;
|
|
private effective: Settings = {};
|
|
private managedDeniedModels: string[] = [];
|
|
private managedApiKeyHelper?: string;
|
|
private watchers: fs.FSWatcher[] = [];
|
|
private onChange?: () => void;
|
|
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
|
private initialized = false;
|
|
private disposed = false;
|
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
constructor(cwd: string, options?: SettingsManagerOptions) {
|
|
this.cwd = cwd;
|
|
this.onChange = options?.onChange;
|
|
this.logger = options?.logger ?? console;
|
|
}
|
|
|
|
/**
|
|
* Initialize the settings manager by loading all settings and setting up file watchers
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
if (this.initPromise) {
|
|
return this.initPromise;
|
|
}
|
|
|
|
this.disposed = false;
|
|
this.initPromise = this.loadAllSettings().then(() => {
|
|
if (!this.disposed) {
|
|
this.setupWatchers();
|
|
this.initialized = true;
|
|
}
|
|
this.initPromise = null;
|
|
});
|
|
return this.initPromise;
|
|
}
|
|
|
|
/**
|
|
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
|
* containing directories means we pick up file creation as well as edits.
|
|
*/
|
|
private getWatchedPaths(): string[] {
|
|
return [
|
|
path.join(claudeConfigDir(), "settings.json"),
|
|
path.join(this.cwd, ".claude", "settings.json"),
|
|
path.join(this.cwd, ".claude", "settings.local.json"),
|
|
getManagedSettingsPath(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolves the effective settings via the SDK and applies the CLI's trust
|
|
* filter for escalating `permissions.defaultMode` values.
|
|
*/
|
|
private async loadAllSettings(): Promise<void> {
|
|
try {
|
|
const resolved = await resolveSettings({ cwd: this.cwd });
|
|
this.effective = filterEscalatingDefaultMode(resolved);
|
|
// The CLI reads `deniedModels` from managed settings only.
|
|
this.managedDeniedModels = resolved.sources
|
|
.filter((entry) => entry.source === "managed")
|
|
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
|
this.managedApiKeyHelper =
|
|
resolved.provenance.apiKeyHelper?.source === "managed"
|
|
? resolved.effective.apiKeyHelper
|
|
: undefined;
|
|
} catch (error) {
|
|
this.logger.error("Failed to resolve settings:", error);
|
|
this.effective = {};
|
|
this.managedDeniedModels = [];
|
|
this.managedApiKeyHelper = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up file watchers for all settings files
|
|
*/
|
|
private setupWatchers(): void {
|
|
for (const filePath of this.getWatchedPaths()) {
|
|
try {
|
|
const dir = path.dirname(filePath);
|
|
const filename = path.basename(filePath);
|
|
|
|
if (fs.existsSync(dir)) {
|
|
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
|
if (changedFilename === filename) {
|
|
this.handleSettingsChange();
|
|
}
|
|
});
|
|
|
|
watcher.on("error", (error) => {
|
|
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
|
});
|
|
|
|
this.watchers.push(watcher);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles settings file changes with debouncing to avoid rapid reloads
|
|
*/
|
|
private handleSettingsChange(): void {
|
|
if (this.debounceTimer) {
|
|
clearTimeout(this.debounceTimer);
|
|
}
|
|
|
|
this.debounceTimer = setTimeout(async () => {
|
|
this.debounceTimer = null;
|
|
if (this.disposed) {
|
|
return;
|
|
}
|
|
try {
|
|
await this.loadAllSettings();
|
|
if (!this.disposed) {
|
|
this.onChange?.();
|
|
}
|
|
} catch (error) {
|
|
this.logger.error("Failed to reload settings:", error);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Returns the current merged settings
|
|
*/
|
|
getSettings(): Settings {
|
|
return this.effective;
|
|
}
|
|
|
|
/**
|
|
* The `apiKeyHelper` the CLI runs: managed settings outrank the `--settings`
|
|
* override, which outranks the user/project/local files.
|
|
*/
|
|
getApiKeyHelper(settingsOverride?: Settings): string | undefined {
|
|
return (
|
|
this.managedApiKeyHelper ?? settingsOverride?.apiKeyHelper ?? this.effective.apiKeyHelper
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `deniedModels` entries from the managed tier, the only tier the CLI
|
|
* honors them from.
|
|
*/
|
|
getManagedDeniedModels(): string[] {
|
|
return this.managedDeniedModels;
|
|
}
|
|
|
|
/**
|
|
* Returns the current working directory
|
|
*/
|
|
getCwd(): string {
|
|
return this.cwd;
|
|
}
|
|
|
|
/**
|
|
* Updates the working directory and reloads project-specific settings
|
|
*/
|
|
async setCwd(cwd: string): Promise<void> {
|
|
if (this.cwd === cwd) {
|
|
return;
|
|
}
|
|
|
|
this.dispose();
|
|
this.cwd = cwd;
|
|
await this.initialize();
|
|
}
|
|
|
|
/**
|
|
* Disposes of file watchers and cleans up resources
|
|
*/
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
this.initialized = false;
|
|
this.initPromise = null;
|
|
|
|
if (this.debounceTimer) {
|
|
clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = null;
|
|
}
|
|
|
|
for (const watcher of this.watchers) {
|
|
watcher.close();
|
|
}
|
|
this.watchers = [];
|
|
}
|
|
}
|