diff --git a/doc/Migration.md b/doc/Migration.md index 8cd6098cb1dfd..96fe70af99795 100644 --- a/doc/Migration.md +++ b/doc/Migration.md @@ -110,7 +110,7 @@ Custom agents are no longer stored in a single `customAgents.yml` per scope. Eac **End-user-facing:** - When an existing `customAgents.yml` is found, Theia asks before migrating it to the new layout: a notification offers **Migrate** or **Don't Show Again**. Nothing is written until you choose **Migrate**, which avoids unexpected file changes (for example in a workspace under version control). Declining is safe: legacy `customAgents.yml` files keep being loaded so agents continue to work, and you are asked again next session until you either migrate or dismiss the prompt for good with **Don't Show Again** (remembered in local storage, not a setting). After a successful migration the `customAgents.yml` is renamed to `customAgents.yml.bak` (never deleted); restoring it is a matter of renaming the `.bak` back by hand. The migration can also be triggered at any time from the command palette via `AI: Re-run custom-agent migration`. -- Prompts stored as a YAML *folded* block scalar (`prompt: >-`) keep their markdown heading structure: the folded scalar would otherwise merge each heading into the following paragraph (e.g. `## Task Your task is ...`), so migration and runtime loading preserve the original line breaks instead. If an earlier Theia version already migrated such an agent with merged headings, the generated `agent.md` is corrected automatically on the next migration, but only when you have not edited it since (the corrected content is rewritten from `customAgents.yml.bak`; user-modified files are left untouched). +- Prompts stored as a YAML _folded_ block scalar (`prompt: >-`) keep their markdown heading structure: the folded scalar would otherwise merge each heading into the following paragraph (e.g. `## Task Your task is ...`), so migration and runtime loading preserve the original line breaks instead. If an earlier Theia version already migrated such an agent with merged headings, the generated `agent.md` is corrected automatically on the next migration, but only when you have not edited it since (the corrected content is rewritten from `customAgents.yml.bak`; user-modified files are left untouched). - The default prompt-override file created by "Edit prompt" changed from `_prompt.prompttemplate` to `prompt.prompttemplate` inside the agent folder. Existing sibling `_prompt*.prompttemplate` files are moved into the agent folder during migration. **Adopter-facing:** @@ -130,6 +130,15 @@ Custom agents are now scanned from both the `.agents/` and `.prompts/` folders o - `PromptFragmentCustomizationProperties` gained an optional `agentDirectoryPaths` field carrying the absolute parent directories scanned for custom agents. The `.agents`/`.prompts` parents are exported as `CUSTOM_AGENT_WORKSPACE_DIRECTORIES`. +#### `AiConfigurationService` for reading/writing AI preferences + +A new framework API, `AiConfigurationService` (`@theia/ai-core`), wraps `PreferenceService` for `ai-features.*` preferences and is the intended extension point for reading/writing AI configuration. + +**Adopter-facing:** + +- Prefer `AiConfigurationService` over `PreferenceService` for `ai-features.*` keys in frontend code. Its `get`/`inspect` are workspace-trust-aware (workspace/folder values are suppressed while the workspace is untrusted); writes (`set`/`update`) are never gated by trust. +- **Behavior change:** the AI terminal's shell-command allowlist/denylist (`ai-features.terminal.shellCommand{Allowlist,Denylist}`) are now read trust-aware via `AiConfigurationService`. Previously they were read with a raw `PreferenceService.get`, so an untrusted workspace could contribute allowlist entries. Now workspace/folder-scoped entries are suppressed until the workspace is trusted (an untrusted workspace can no longer widen the shell allowlist). User- and default-scoped entries are unaffected. + ### v1.70.0 #### Removal of deprecated @theia/git extension from Theia codebase [#17148](https://github.com/eclipse-theia/theia/pull/17148) diff --git a/packages/ai-chat/src/browser/chat-tool-preference-bindings.spec.ts b/packages/ai-chat/src/browser/chat-tool-preference-bindings.spec.ts index 5d448ff37763f..92014f2a425ec 100644 --- a/packages/ai-chat/src/browser/chat-tool-preference-bindings.spec.ts +++ b/packages/ai-chat/src/browser/chat-tool-preference-bindings.spec.ts @@ -23,9 +23,7 @@ import { TOOL_CONFIRMATION_PREFERENCE, ToolConfirmationMode } from '../common/chat-tool-preferences'; -import { ToolRequest } from '@theia/ai-core'; -import { PreferenceService } from '@theia/core/lib/common/preferences'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService, ToolRequest } from '@theia/ai-core'; interface InspectResult { defaultValue?: T; @@ -35,8 +33,7 @@ interface InspectResult { describe('ToolConfirmationManager', () => { let manager: ToolConfirmationManager; - let preferenceServiceMock: sinon.SinonStubbedInstance; - let trustAwareReaderMock: sinon.SinonStubbedInstance; + let aiConfigurationServiceMock: sinon.SinonStubbedInstance; let storedPerToolPreferences: { [toolId: string]: ToolConfirmationMode }; let storedDefaultMode: ToolConfirmationMode | undefined; let trusted: boolean; @@ -58,27 +55,9 @@ describe('ToolConfirmationManager', () => { perToolInspectResult = undefined; defaultInspectResult = undefined; - preferenceServiceMock = { - updateValue: sinon.stub().callsFake((key: string, value: unknown) => { - if (key === TOOL_CONFIRMATION_PREFERENCE) { - storedPerToolPreferences = value as { [toolId: string]: ToolConfirmationMode }; - } else if (key === DEFAULT_TOOL_CONFIRMATION_PREFERENCE) { - storedDefaultMode = value as ToolConfirmationMode; - } - return Promise.resolve(); - }), - inspect: sinon.stub().callsFake((name: string) => { - if (name === TOOL_CONFIRMATION_PREFERENCE) { - return perToolInspectResult; - } - if (name === DEFAULT_TOOL_CONFIRMATION_PREFERENCE) { - return defaultInspectResult; - } - return undefined; - }) - } as unknown as sinon.SinonStubbedInstance; - - trustAwareReaderMock = { + // The manager talks only to AiConfigurationService. `get` mirrors the trust-aware read + // semantics, `update` the smart write, and `inspect` exposes schema defaults. + aiConfigurationServiceMock = { get: sinon.stub().callsFake((name: string, fallback?: T): T | undefined => { if (name === TOOL_CONFIRMATION_PREFERENCE) { if (trusted) { @@ -95,13 +74,29 @@ describe('ToolConfirmationManager', () => { return ((value as unknown as T) ?? fallback); } return fallback; + }), + update: sinon.stub().callsFake((key: string, value: unknown) => { + if (key === TOOL_CONFIRMATION_PREFERENCE) { + storedPerToolPreferences = value as { [toolId: string]: ToolConfirmationMode }; + } else if (key === DEFAULT_TOOL_CONFIRMATION_PREFERENCE) { + storedDefaultMode = value as ToolConfirmationMode; + } + return Promise.resolve(); + }), + inspect: sinon.stub().callsFake((name: string) => { + if (name === TOOL_CONFIRMATION_PREFERENCE) { + return perToolInspectResult; + } + if (name === DEFAULT_TOOL_CONFIRMATION_PREFERENCE) { + return defaultInspectResult; + } + return undefined; }) - } as unknown as sinon.SinonStubbedInstance; + } as unknown as sinon.SinonStubbedInstance; const container = new Container(); container.bind(ToolConfirmationManager).toSelf().inSingletonScope(); - container.bind(PreferenceService).toConstantValue(preferenceServiceMock as unknown as PreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReaderMock as unknown as TrustAwarePreferenceReader); + container.bind(AiConfigurationService).toConstantValue(aiConfigurationServiceMock as unknown as AiConfigurationService); manager = container.get(ToolConfirmationManager); }); @@ -124,9 +119,9 @@ describe('ToolConfirmationManager', () => { describe('setDefaultConfirmationMode', () => { it('persists the new default through the preference service', () => { manager.setDefaultConfirmationMode(ToolConfirmationMode.ALWAYS_ALLOW); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; - expect(preferenceServiceMock.updateValue.firstCall.args[0]).to.equal(DEFAULT_TOOL_CONFIRMATION_PREFERENCE); - expect(preferenceServiceMock.updateValue.firstCall.args[1]).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.firstCall.args[0]).to.equal(DEFAULT_TOOL_CONFIRMATION_PREFERENCE); + expect(aiConfigurationServiceMock.update.firstCall.args[1]).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); expect(storedDefaultMode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); }); }); @@ -223,31 +218,31 @@ describe('ToolConfirmationManager', () => { describe('setConfirmationMode', () => { it('persists ALWAYS_ALLOW for a regular tool when default is CONFIRM', () => { manager.setConfirmationMode('regularTool', ToolConfirmationMode.ALWAYS_ALLOW); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['regularTool']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); }); it('persists ALWAYS_ALLOW for confirmAlwaysAllow tools', () => { const toolRequest = createToolRequest('dangerousTool', true); manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['dangerousTool']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); }); it('does not persist when mode matches the global default', () => { storedDefaultMode = ToolConfirmationMode.ALWAYS_ALLOW; manager.setConfirmationMode('regularTool', ToolConfirmationMode.ALWAYS_ALLOW); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('does not persist CONFIRM for a regular tool when default is CONFIRM', () => { manager.setConfirmationMode('regularTool', ToolConfirmationMode.CONFIRM); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('persists DISABLED when default is CONFIRM', () => { manager.setConfirmationMode('regularTool', ToolConfirmationMode.DISABLED); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['regularTool']).to.equal(ToolConfirmationMode.DISABLED); }); @@ -256,7 +251,7 @@ describe('ToolConfirmationManager', () => { defaultValue: { 'myTool': ToolConfirmationMode.DISABLED } }; manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('removes an existing entry when mode matches the tool-specific schema default', () => { @@ -265,14 +260,14 @@ describe('ToolConfirmationManager', () => { }; storedPerToolPreferences['myTool'] = ToolConfirmationMode.ALWAYS_ALLOW; manager.setConfirmationMode('myTool', ToolConfirmationMode.DISABLED); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['myTool']).to.be.undefined; }); it('does not persist CONFIRM for confirmAlwaysAllow tools (matches effective default)', () => { const toolRequest = createToolRequest('dangerousTool', true); manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.CONFIRM, toolRequest); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('persists ALWAYS_ALLOW for a confirmAlwaysAllow tool when global default is ALWAYS_ALLOW', () => { @@ -281,7 +276,7 @@ describe('ToolConfirmationManager', () => { storedDefaultMode = ToolConfirmationMode.ALWAYS_ALLOW; const toolRequest = createToolRequest('dangerousTool', true); manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['dangerousTool']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); }); @@ -289,13 +284,13 @@ describe('ToolConfirmationManager', () => { const toolRequest = createToolRequest('dangerousTool', true); storedPerToolPreferences['dangerousTool'] = ToolConfirmationMode.ALWAYS_ALLOW; manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.CONFIRM, toolRequest); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['dangerousTool']).to.be.undefined; }); it('persists DISABLED for any tool when default is CONFIRM', () => { manager.setConfirmationMode('anyTool', ToolConfirmationMode.DISABLED); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['anyTool']).to.equal(ToolConfirmationMode.DISABLED); }); }); @@ -362,9 +357,9 @@ describe('ToolConfirmationManager', () => { manager.resetAllConfirmationModeSettings(); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; - expect(preferenceServiceMock.updateValue.firstCall.args[0]).to.equal(TOOL_CONFIRMATION_PREFERENCE); - expect(preferenceServiceMock.updateValue.firstCall.args[1]).to.deep.equal({}); + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.firstCall.args[0]).to.equal(TOOL_CONFIRMATION_PREFERENCE); + expect(aiConfigurationServiceMock.update.firstCall.args[1]).to.deep.equal({}); }); it('does not modify the default-confirmation preference', () => { @@ -386,7 +381,7 @@ describe('ToolConfirmationManager', () => { manager.setConfirmationMode('shellExecute', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['shellExecute']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW); mode = manager.getConfirmationMode('shellExecute', 'chat-1', toolRequest); @@ -398,7 +393,7 @@ describe('ToolConfirmationManager', () => { manager.setConfirmationMode('shellExecute', ToolConfirmationMode.DISABLED, toolRequest); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPerToolPreferences['shellExecute']).to.equal(ToolConfirmationMode.DISABLED); const mode = manager.getConfirmationMode('shellExecute', 'chat-1', toolRequest); diff --git a/packages/ai-chat/src/browser/chat-tool-preference-bindings.ts b/packages/ai-chat/src/browser/chat-tool-preference-bindings.ts index 69b1935d4f7b4..4cf8d852fcffa 100644 --- a/packages/ai-chat/src/browser/chat-tool-preference-bindings.ts +++ b/packages/ai-chat/src/browser/chat-tool-preference-bindings.ts @@ -15,27 +15,30 @@ // ***************************************************************************** import { injectable, inject } from '@theia/core/shared/inversify'; -import { - PreferenceService, -} from '@theia/core/lib/common/preferences'; import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE, DEFAULT_TOOL_CONFIRMATION_PREFERENCE } from '../common/chat-tool-preferences'; -import { ToolRequest } from '@theia/ai-core'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService, ToolRequest } from '@theia/ai-core'; + +/** + * The loop-invariant inputs to {@link ToolConfirmationManager.computeEffectiveDefaultForTool}: + * the product-shipped per-tool schema defaults and the effective global default. Reading these + * once lets bulk operations avoid re-inspecting the preference schema per tool. + */ +interface ToolConfirmationDefaults { + perToolDefaults?: { [toolId: string]: ToolConfirmationMode }; + globalDefault: ToolConfirmationMode; +} /** * Utility class to manage tool confirmation settings */ @injectable() export class ToolConfirmationManager { - @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; - - @inject(TrustAwarePreferenceReader) - protected readonly trustAwareReader: TrustAwarePreferenceReader; + @inject(AiConfigurationService) + protected readonly aiConfigurationService: AiConfigurationService; // In-memory session overrides (not persisted), per chat protected sessionOverrides: Map> = new Map(); @@ -47,7 +50,7 @@ export class ToolConfirmationManager { * the default to a more permissive value. */ getDefaultConfirmationMode(): ToolConfirmationMode { - const value = this.trustAwareReader.get(DEFAULT_TOOL_CONFIRMATION_PREFERENCE); + const value = this.aiConfigurationService.get(DEFAULT_TOOL_CONFIRMATION_PREFERENCE); return value ?? this.getDefaultPreferenceSchemaDefault(); } @@ -58,7 +61,7 @@ export class ToolConfirmationManager { * `await` completion and react to errors (e.g. show a notification on failure). */ setDefaultConfirmationMode(mode: ToolConfirmationMode): Promise { - return this.preferenceService.updateValue(DEFAULT_TOOL_CONFIRMATION_PREFERENCE, mode); + return this.aiConfigurationService.update(DEFAULT_TOOL_CONFIRMATION_PREFERENCE, mode); } /** @@ -77,7 +80,7 @@ export class ToolConfirmationManager { if (chatMap && chatMap.has(toolId)) { return chatMap.get(toolId)!; } - const toolConfirmation = this.trustAwareReader.get>( + const toolConfirmation = this.aiConfigurationService.get>( TOOL_CONFIRMATION_PREFERENCE, {} ) ?? {}; if (toolId in toolConfirmation) { @@ -99,19 +102,19 @@ export class ToolConfirmationManager { * @param toolRequest - Optional ToolRequest to check for confirmAlwaysAllow flag */ setConfirmationMode(toolId: string, mode: ToolConfirmationMode, toolRequest?: ToolRequest): Promise { - const current = this.trustAwareReader.get>( + const current = this.aiConfigurationService.get>( TOOL_CONFIRMATION_PREFERENCE, {} ) ?? {}; const effectiveDefault = this.computeEffectiveDefaultForTool(toolId, toolRequest); if (mode === effectiveDefault) { if (toolId in current) { const { [toolId]: _, ...rest } = current; - return this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, rest); + return this.aiConfigurationService.update(TOOL_CONFIRMATION_PREFERENCE, rest); } return Promise.resolve(); } const updated = { ...current, [toolId]: mode }; - return this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, updated); + return this.aiConfigurationService.update(TOOL_CONFIRMATION_PREFERENCE, updated); } /** @@ -122,13 +125,15 @@ export class ToolConfirmationManager { * round-trip per tool. */ setConfirmationModes(updates: Iterable<{ toolId: string; mode: ToolConfirmationMode; toolRequest?: ToolRequest }>): Promise { - const current = this.trustAwareReader.get>( + const current = this.aiConfigurationService.get>( TOOL_CONFIRMATION_PREFERENCE, {} ) ?? {}; const next: Record = { ...current }; + // Read the loop-invariant schema/global defaults once, not once per tool. + const defaults = this.readConfirmationDefaults(); let changed = false; for (const { toolId, mode, toolRequest } of updates) { - const effectiveDefault = this.computeEffectiveDefaultForTool(toolId, toolRequest); + const effectiveDefault = this.computeEffectiveDefaultForTool(toolId, toolRequest, defaults); if (mode === effectiveDefault) { if (toolId in next) { delete next[toolId]; @@ -143,7 +148,7 @@ export class ToolConfirmationManager { if (!changed) { return Promise.resolve(); } - return this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, next); + return this.aiConfigurationService.update(TOOL_CONFIRMATION_PREFERENCE, next); } /** @@ -173,32 +178,39 @@ export class ToolConfirmationManager { * Get all tool confirmation settings */ getAllConfirmationSettings(): { [toolId: string]: ToolConfirmationMode } { - return this.trustAwareReader.get>( + return this.aiConfigurationService.get>( TOOL_CONFIRMATION_PREFERENCE, {} ) ?? {}; } resetAllConfirmationModeSettings(): Promise { - return this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, {}); + return this.aiConfigurationService.update(TOOL_CONFIRMATION_PREFERENCE, {}); } /** - * Compute the effective default for a given tool, taking the schema-level default, - * any product-shipped per-tool default, and the confirmAlwaysAllow flag into account. + * Read the loop-invariant inputs for {@link computeEffectiveDefaultForTool}: the product-shipped + * per-tool schema defaults and the effective global default. */ - protected computeEffectiveDefaultForTool(toolId: string, toolRequest?: ToolRequest): ToolConfirmationMode { - const perToolDefaults = this.preferenceService.inspect(TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as + protected readConfirmationDefaults(): ToolConfirmationDefaults { + const perToolDefaults = this.aiConfigurationService.inspect(TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as | { [toolId: string]: ToolConfirmationMode } | undefined; - const perToolDefault = perToolDefaults?.[toolId]; + return { perToolDefaults, globalDefault: this.getDefaultConfirmationMode() }; + } + + protected computeEffectiveDefaultForTool( + toolId: string, + toolRequest?: ToolRequest, + defaults: ToolConfirmationDefaults = this.readConfirmationDefaults() + ): ToolConfirmationMode { + const perToolDefault = defaults.perToolDefaults?.[toolId]; if (perToolDefault) { return perToolDefault; } - const globalDefault = this.getDefaultConfirmationMode(); - if (toolRequest?.confirmAlwaysAllow && globalDefault === ToolConfirmationMode.ALWAYS_ALLOW) { + if (toolRequest?.confirmAlwaysAllow && defaults.globalDefault === ToolConfirmationMode.ALWAYS_ALLOW) { return ToolConfirmationMode.CONFIRM; } - return globalDefault; + return defaults.globalDefault; } /** @@ -206,7 +218,7 @@ export class ToolConfirmationManager { * Falls back to CONFIRM if the preference service has not registered the schema yet. */ protected getDefaultPreferenceSchemaDefault(): ToolConfirmationMode { - const schemaDefault = this.preferenceService.inspect(DEFAULT_TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as + const schemaDefault = this.aiConfigurationService.inspect(DEFAULT_TOOL_CONFIRMATION_PREFERENCE)?.defaultValue as | ToolConfirmationMode | undefined; return schemaDefault ?? ToolConfirmationMode.CONFIRM; diff --git a/packages/ai-core/src/browser/ai-configuration-service-impl.spec.ts b/packages/ai-core/src/browser/ai-configuration-service-impl.spec.ts new file mode 100644 index 0000000000000..4d79fce5a6378 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration-service-impl.spec.ts @@ -0,0 +1,254 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +const disableJSDOM = enableJSDOM(); +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +FrontendApplicationConfigProvider.set({}); + +import { expect } from 'chai'; +import { Container } from '@theia/core/shared/inversify'; +import { Emitter, Event } from '@theia/core'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { PreferenceChange, PreferenceInspection, PreferenceScope, PreferenceService } from '@theia/core/lib/common/preferences'; +import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service'; +import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; +import { AiConfigurationChange } from '../common/ai-configuration-service'; +import { AiConfigurationServiceImpl } from './ai-configuration-service-impl'; + +disableJSDOM(); + +const KEY = 'ai-features.someKey'; + +/** + * Minimal in-memory `PreferenceService` stub that stores values per scope and derives the + * effective value with the same precedence as the real service. + */ +class StubPreferenceService { + readonly ready = Promise.resolve(); + readonly isReady = true; + + protected readonly values = new Map>(); + + protected readonly onPreferenceChangedEmitter = new Emitter(); + readonly onPreferenceChanged: Event = this.onPreferenceChangedEmitter.event; + + protected scopeMap(key: string): Map { + let map = this.values.get(key); + if (!map) { + map = new Map(); + this.values.set(key, map); + } + return map; + } + + setScopeValue(key: string, scope: PreferenceScope, value: unknown): void { + this.scopeMap(key).set(scope, value); + } + + get(preferenceName: string, defaultValue?: T): T | undefined { + const inspection = this.inspect(preferenceName); + return (inspection?.value as T | undefined) ?? defaultValue; + } + + set(preferenceName: string, value: unknown, scope: PreferenceScope = PreferenceScope.User): Promise { + this.scopeMap(preferenceName).set(scope, value); + this.fire(preferenceName, scope); + return Promise.resolve(); + } + + updateValue(preferenceName: string, value: unknown): Promise { + return this.set(preferenceName, value, PreferenceScope.User); + } + + inspect(preferenceName: string): PreferenceInspection | undefined { + const map = this.values.get(preferenceName); + const defaultValue = map?.get(PreferenceScope.Default) as T | undefined; + const globalValue = map?.get(PreferenceScope.User) as T | undefined; + const workspaceValue = map?.get(PreferenceScope.Workspace) as T | undefined; + const workspaceFolderValue = map?.get(PreferenceScope.Folder) as T | undefined; + const value = workspaceFolderValue ?? workspaceValue ?? globalValue ?? defaultValue; + return { preferenceName, defaultValue, globalValue, workspaceValue, workspaceFolderValue, value }; + } + + protected fire(preferenceName: string, scope: PreferenceScope): void { + this.onPreferenceChangedEmitter.fire({ + preferenceName, + scope, + domain: undefined, + affects: () => true + } as unknown as PreferenceChange); + } +} + +class StubWorkspaceTrustService { + readonly trustDeferred = new Deferred(); + protected readonly emitter = new Emitter(); + readonly onDidChangeWorkspaceTrust: Event = this.emitter.event; + + getWorkspaceTrust(): Promise { + return this.trustDeferred.promise; + } + + fireTrustChange(trusted: boolean): void { + this.emitter.fire(trusted); + } +} + +describe('AiConfigurationServiceImpl', () => { + let preferences: StubPreferenceService; + let trust: StubWorkspaceTrustService; + let service: AiConfigurationServiceImpl; + + const createService = async (trusted: boolean): Promise => { + preferences = new StubPreferenceService(); + trust = new StubWorkspaceTrustService(); + + const container = new Container(); + container.bind(PreferenceService).toConstantValue(preferences as unknown as PreferenceService); + container.bind(WorkspaceTrustService).toConstantValue(trust as unknown as WorkspaceTrustService); + container.bind(TrustAwarePreferenceReader).toSelf().inSingletonScope(); + container.bind(AiConfigurationServiceImpl).toSelf().inSingletonScope(); + + service = container.get(AiConfigurationServiceImpl); + trust.trustDeferred.resolve(trusted); + await service.ready; + }; + + describe('acceptance scenario', () => { + beforeEach(() => createService(true)); + + it('writes at User scope, reads back, reports User as source scope, and fires onDidChange', async () => { + const changes: AiConfigurationChange[] = []; + service.onDidChange(change => changes.push(change)); + + await service.set(KEY, true, PreferenceScope.User); + + expect(service.get(KEY)).to.equal(true); + expect(service.inspect(KEY)?.sourceScope).to.equal(PreferenceScope.User); + expect(changes.some(change => change.preferenceName === KEY)).to.equal(true); + }); + }); + + describe('inspect source-scope derivation (trusted)', () => { + beforeEach(() => createService(true)); + + it('reports undefined source scope when only the default applies', () => { + preferences.setScopeValue(KEY, PreferenceScope.Default, 'default'); + const inspection = service.inspect(KEY); + expect(inspection?.sourceScope).to.equal(undefined); + expect(inspection?.value).to.equal('default'); + }); + + it('reports User when a user value is set', () => { + preferences.setScopeValue(KEY, PreferenceScope.Default, 'default'); + preferences.setScopeValue(KEY, PreferenceScope.User, 'user'); + const inspection = service.inspect(KEY); + expect(inspection?.sourceScope).to.equal(PreferenceScope.User); + expect(inspection?.value).to.equal('user'); + }); + + it('reports Workspace when a workspace value wins', () => { + preferences.setScopeValue(KEY, PreferenceScope.User, 'user'); + preferences.setScopeValue(KEY, PreferenceScope.Workspace, 'workspace'); + const inspection = service.inspect(KEY); + expect(inspection?.sourceScope).to.equal(PreferenceScope.Workspace); + expect(inspection?.value).to.equal('workspace'); + }); + }); + + describe('trust-aware reads', () => { + it('ignores the workspace value when untrusted', async () => { + await createService(false); + preferences.setScopeValue(KEY, PreferenceScope.Default, 'default'); + preferences.setScopeValue(KEY, PreferenceScope.User, 'user'); + preferences.setScopeValue(KEY, PreferenceScope.Workspace, 'workspace'); + + expect(service.get(KEY)).to.equal('user'); + const inspection = service.inspect(KEY); + expect(inspection?.value).to.equal('user'); + expect(inspection?.sourceScope).to.equal(PreferenceScope.User); + // The suppressed workspace/folder values are cleared, not just excluded from `value`. + expect(inspection?.workspaceValue).to.equal(undefined); + expect(inspection?.workspaceFolderValue).to.equal(undefined); + }); + + it('honors the workspace value when trusted', async () => { + await createService(true); + preferences.setScopeValue(KEY, PreferenceScope.User, 'user'); + preferences.setScopeValue(KEY, PreferenceScope.Workspace, 'workspace'); + + expect(service.get(KEY)).to.equal('workspace'); + expect(service.inspect(KEY)?.value).to.equal('workspace'); + }); + + it('fires onDidChange (without a preference name) on a trust transition', async () => { + await createService(false); + const changes: AiConfigurationChange[] = []; + service.onDidChange(change => changes.push(change)); + + trust.fireTrustChange(true); + + expect(changes).to.have.lengthOf(1); + expect(changes[0].preferenceName).to.equal(undefined); + expect(changes[0].affects()).to.equal(true); + // A trust transition affects every trust-gated (ai-features.*) key, but not unrelated ones. + expect(changes[0].affectsPreference(KEY)).to.equal(true); + expect(changes[0].affectsPreference('ai-features.anythingElse')).to.equal(true); + expect(changes[0].affectsPreference('editor.fontSize')).to.equal(false); + }); + }); + + describe('set vs update', () => { + beforeEach(() => createService(true)); + + it('set writes the exact scope requested', async () => { + await service.set(KEY, 'workspace', PreferenceScope.Workspace); + expect(preferences.inspect(KEY)?.workspaceValue).to.equal('workspace'); + expect(preferences.inspect(KEY)?.globalValue).to.equal(undefined); + }); + + it('update maps to updateValue (User scope)', async () => { + await service.update(KEY, 'user'); + expect(preferences.inspect(KEY)?.globalValue).to.equal('user'); + }); + }); + + describe('onDidChange filtering', () => { + beforeEach(() => createService(true)); + + it('only fires for ai-features.* keys', async () => { + const changes: AiConfigurationChange[] = []; + service.onDidChange(change => changes.push(change)); + + await service.set('editor.fontSize', 12, PreferenceScope.User); + await service.set(KEY, true, PreferenceScope.User); + + expect(changes.map(change => change.preferenceName)).to.deep.equal([KEY]); + }); + + it('affectsPreference matches the changed key on a keyed change, not unrelated keys', async () => { + const changes: AiConfigurationChange[] = []; + service.onDidChange(change => changes.push(change)); + + await service.set(KEY, true, PreferenceScope.User); + + expect(changes).to.have.lengthOf(1); + expect(changes[0].affectsPreference(KEY)).to.equal(true); + expect(changes[0].affectsPreference('ai-features.other')).to.equal(false); + }); + }); +}); diff --git a/packages/ai-core/src/browser/ai-configuration-service-impl.ts b/packages/ai-core/src/browser/ai-configuration-service-impl.ts new file mode 100644 index 0000000000000..33dc7c52dce5b --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration-service-impl.ts @@ -0,0 +1,120 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { DisposableCollection, Emitter, Event } from '@theia/core'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { JSONValue } from '@theia/core/shared/@lumino/coreutils'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { PreferenceInspection, PreferenceScope, PreferenceService } from '@theia/core/lib/common/preferences'; +import { AiConfigurationChange, AiConfigurationInspection, AiConfigurationService } from '../common/ai-configuration-service'; +import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; + +/** + * Prefix shared by all AI-related preference keys. Only changes to keys under this namespace are + * surfaced through {@link AiConfigurationService.onDidChange}. + */ +const AI_PREFERENCE_PREFIX = 'ai-features.'; + +@injectable() +export class AiConfigurationServiceImpl implements AiConfigurationService { + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(TrustAwarePreferenceReader) + protected readonly trustAwareReader: TrustAwarePreferenceReader; + + protected readonly toDispose = new DisposableCollection(); + + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + protected readonly _ready = new Deferred(); + get ready(): Promise { + return this._ready.promise; + } + + @postConstruct() + protected init(): void { + this._ready.resolve(Promise.all([this.preferenceService.ready, this.trustAwareReader.ready]).then(() => undefined)); + this.toDispose.push(this.onDidChangeEmitter); + this.toDispose.push( + this.preferenceService.onPreferenceChanged(change => { + if (change.preferenceName.startsWith(AI_PREFERENCE_PREFIX)) { + this.onDidChangeEmitter.fire({ + preferenceName: change.preferenceName, + affects: resourceUri => change.affects(resourceUri), + affectsPreference: preferenceName => preferenceName === change.preferenceName + }); + } + }) + ); + this.toDispose.push( + // A trust transition can change the effective value of any trust-gated key. Emit a + // single change with no `preferenceName` and a sentinel `affects()` so listeners + // re-query, mirroring how trust-aware services already react to trust changes. + this.trustAwareReader.onDidChangeTrust(() => this.onDidChangeEmitter.fire({ + preferenceName: undefined, + affects: () => true, + affectsPreference: preferenceName => preferenceName.startsWith(AI_PREFERENCE_PREFIX) + })) + ); + } + + get(key: string, defaultValue?: T, resourceUri?: string): T | undefined { + return this.trustAwareReader.get(key, defaultValue, resourceUri); + } + + set(key: string, value: unknown, scope: PreferenceScope, resourceUri?: string): Promise { + return this.preferenceService.set(key, value, scope, resourceUri); + } + + update(key: string, value: unknown, resourceUri?: string): Promise { + return this.preferenceService.updateValue(key, value, resourceUri); + } + + inspect(key: string, resourceUri?: string): AiConfigurationInspection | undefined { + const inspection = this.preferenceService.inspect(key, resourceUri); + if (!inspection) { + return undefined; + } + return this.enrichInspection(inspection); + } + + /** + * Derives the trust-aware {@link AiConfigurationInspection.sourceScope} and effective `value` + * from a raw {@link PreferenceInspection}. The workspace-trust rule is applied once, via + * {@link TrustAwarePreferenceReader.suppressUntrusted} (the single owner of that rule): when + * untrusted, folder and workspace scope values are cleared, so the scope walk below naturally + * resolves to the user/default scope and the whole inspection stays consistent with {@link get}. + * + * Note: `value` reflects the narrowest defined scope, matching `PreferenceService.inspect`. For + * object-valued preferences this can differ from {@link get}, which deep-merges across scopes. + */ + protected enrichInspection(inspection: PreferenceInspection): AiConfigurationInspection { + const suppressed = this.trustAwareReader.suppressUntrusted(inspection); + if (suppressed.workspaceFolderValue !== undefined) { + return { ...suppressed, sourceScope: PreferenceScope.Folder, value: suppressed.workspaceFolderValue }; + } + if (suppressed.workspaceValue !== undefined) { + return { ...suppressed, sourceScope: PreferenceScope.Workspace, value: suppressed.workspaceValue }; + } + if (suppressed.globalValue !== undefined) { + return { ...suppressed, sourceScope: PreferenceScope.User, value: suppressed.globalValue }; + } + return { ...suppressed, sourceScope: undefined, value: suppressed.defaultValue }; + } +} diff --git a/packages/ai-core/src/browser/ai-core-frontend-module.ts b/packages/ai-core/src/browser/ai-core-frontend-module.ts index 268b70b038038..561633b865681 100644 --- a/packages/ai-core/src/browser/ai-core-frontend-module.ts +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -22,6 +22,7 @@ import { import { ContainerModule } from '@theia/core/shared/inversify'; import { DefaultLanguageModelAliasRegistry } from './frontend-language-model-alias-registry'; import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; +import { AiConfigurationServiceImpl } from './ai-configuration-service-impl'; import { LanguageModelAliasRegistry } from '../common/language-model-alias'; import { AIVariableContribution, @@ -46,7 +47,8 @@ import { AIVariableResourceResolver, ConfigurableInMemoryResources, Agent, - FrontendLanguageModelRegistry + FrontendLanguageModelRegistry, + AiConfigurationService } from '../common'; import { FrontendLanguageModelRegistryImpl, @@ -196,8 +198,12 @@ export default new ContainerModule(bind => { bind(DefaultLanguageModelAliasRegistry).toSelf().inSingletonScope(); bind(LanguageModelAliasRegistry).toService(DefaultLanguageModelAliasRegistry); + // Internal implementation detail of AiConfigurationService; consumers inject AiConfigurationService. bind(TrustAwarePreferenceReader).toSelf().inSingletonScope(); + bind(AiConfigurationServiceImpl).toSelf().inSingletonScope(); + bind(AiConfigurationService).toService(AiConfigurationServiceImpl); + bind(TokenUsageService).toDynamicValue(ctx => { const connection = ctx.container.get(RemoteConnectionProvider); const client = ctx.container.get(TokenUsageServiceClient); diff --git a/packages/ai-core/src/browser/ai-settings-service.ts b/packages/ai-core/src/browser/ai-settings-service.ts index c16daf0e412e5..ab5fa2a26fa3a 100644 --- a/packages/ai-core/src/browser/ai-settings-service.ts +++ b/packages/ai-core/src/browser/ai-settings-service.ts @@ -15,9 +15,7 @@ // ***************************************************************************** import { DisposableCollection, Emitter, Event, ILogger, RecursiveReadonly } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; -import { PreferenceService } from '@theia/core/lib/common'; -import { AISettings, AISettingsService, AgentSettings } from '../common'; -import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; +import { AiConfigurationService, AISettings, AISettingsService, AgentSettings } from '../common'; @injectable() export class AISettingsServiceImpl implements AISettingsService { @@ -25,10 +23,8 @@ export class AISettingsServiceImpl implements AISettingsService { @inject(ILogger) protected readonly logger: ILogger; - @inject(PreferenceService) protected preferenceService: PreferenceService; - - @inject(TrustAwarePreferenceReader) - protected readonly trustAwareReader: TrustAwarePreferenceReader; + @inject(AiConfigurationService) + protected readonly aiConfigurationService: AiConfigurationService; static readonly PREFERENCE_NAME = 'ai-features.agentSettings'; @@ -40,22 +36,19 @@ export class AISettingsServiceImpl implements AISettingsService { @postConstruct() protected init(): void { this.toDispose.push( - this.preferenceService.onPreferenceChanged(event => { - if (event.preferenceName === AISettingsServiceImpl.PREFERENCE_NAME) { + this.aiConfigurationService.onDidChange(change => { + if (change.affectsPreference(AISettingsServiceImpl.PREFERENCE_NAME)) { this.onDidChangeEmitter.fire(); } }) ); - this.toDispose.push( - this.trustAwareReader.onDidChangeTrust(() => this.onDidChangeEmitter.fire()) - ); } async updateAgentSettings(agent: string, agentSettings: Partial): Promise { const settings = await this.getSettings(); const toSet = { ...settings, [agent]: { ...settings[agent], ...agentSettings } }; try { - await this.preferenceService.updateValue(AISettingsServiceImpl.PREFERENCE_NAME, toSet); + await this.aiConfigurationService.update(AISettingsServiceImpl.PREFERENCE_NAME, toSet); } catch (e) { this.onDidChangeEmitter.fire(); this.logger.warn('Updating the preferences was unsuccessful: ' + e); @@ -68,8 +61,7 @@ export class AISettingsServiceImpl implements AISettingsService { } async getSettings(): Promise> { - await this.preferenceService.ready; - await this.trustAwareReader.ready; - return this.trustAwareReader.get(AISettingsServiceImpl.PREFERENCE_NAME, {}) ?? {}; + await this.aiConfigurationService.ready; + return this.aiConfigurationService.get(AISettingsServiceImpl.PREFERENCE_NAME, {}) ?? {}; } } diff --git a/packages/ai-core/src/browser/frontend-language-model-alias-registry.ts b/packages/ai-core/src/browser/frontend-language-model-alias-registry.ts index 8906c00f02983..f6a860681f781 100644 --- a/packages/ai-core/src/browser/frontend-language-model-alias-registry.ts +++ b/packages/ai-core/src/browser/frontend-language-model-alias-registry.ts @@ -17,10 +17,10 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { Emitter, Event, nls } from '@theia/core'; import { LanguageModelAlias, LanguageModelAliasRegistry } from '../common/language-model-alias'; -import { PreferenceScope, PreferenceService } from '@theia/core/lib/common'; +import { PreferenceScope } from '@theia/core/lib/common'; import { LANGUAGE_MODEL_ALIASES_PREFERENCE } from '../common/ai-core-preferences'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; +import { AiConfigurationService } from '../common/ai-configuration-service'; @injectable() export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegistry { @@ -76,11 +76,8 @@ export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegi protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange: Event = this.onDidChangeEmitter.event; - @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; - - @inject(TrustAwarePreferenceReader) - protected readonly trustAwareReader: TrustAwarePreferenceReader; + @inject(AiConfigurationService) + protected readonly aiConfigurationService: AiConfigurationService; protected readonly _ready = new Deferred(); get ready(): Promise { @@ -89,17 +86,16 @@ export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegi @postConstruct() protected init(): void { - Promise.all([this.preferenceService.ready, this.trustAwareReader.ready]).then(() => { + this.aiConfigurationService.ready.then(() => { this.loadFromPreference(); - this.preferenceService.onPreferenceChanged(ev => { - if (ev.preferenceName === LANGUAGE_MODEL_ALIASES_PREFERENCE) { + this.aiConfigurationService.onDidChange(change => { + if (change.affectsPreference(LANGUAGE_MODEL_ALIASES_PREFERENCE)) { this.loadFromPreference(); + if (!change.preferenceName) { + this.onDidChangeEmitter.fire(); + } } }); - this.trustAwareReader.onDidChangeTrust(() => { - this.loadFromPreference(); - this.onDidChangeEmitter.fire(); - }); this._ready.resolve(); }, err => { this._ready.reject(err); @@ -158,7 +154,7 @@ export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegi * Load aliases from the persisted setting */ protected loadFromPreference(): void { - const stored = this.trustAwareReader.get<{ [name: string]: { selectedModel: string } }>(LANGUAGE_MODEL_ALIASES_PREFERENCE) || {}; + const stored = this.aiConfigurationService.get<{ [name: string]: { selectedModel: string } }>(LANGUAGE_MODEL_ALIASES_PREFERENCE) || {}; this.aliases.forEach(alias => { if (stored[alias.id] && stored[alias.id].selectedModel) { alias.selectedModelId = stored[alias.id].selectedModel; @@ -178,6 +174,6 @@ export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegi map[alias.id] = { selectedModel: alias.selectedModelId }; } } - this.preferenceService.set(LANGUAGE_MODEL_ALIASES_PREFERENCE, map, PreferenceScope.User); + this.aiConfigurationService.set(LANGUAGE_MODEL_ALIASES_PREFERENCE, map, PreferenceScope.User); } } diff --git a/packages/ai-core/src/browser/frontend-language-model-service.ts b/packages/ai-core/src/browser/frontend-language-model-service.ts index ba6240a36fce5..5e8f091f8350c 100644 --- a/packages/ai-core/src/browser/frontend-language-model-service.ts +++ b/packages/ai-core/src/browser/frontend-language-model-service.ts @@ -18,7 +18,7 @@ import { nls } from '@theia/core/lib/common/nls'; import { inject, injectable } from '@theia/core/shared/inversify'; import { Prioritizeable } from '@theia/core/lib/common/prioritizeable'; import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service'; -import { LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common'; +import { AiConfigurationService, LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common'; import { LanguageModelServiceImpl } from '../common/language-model-service'; import { PREFERENCE_NAME_REQUEST_SETTINGS, @@ -27,7 +27,6 @@ import { ReasoningPreferenceEntry, getRequestSettingSpecificity } from '../common/ai-core-preferences'; -import { TrustAwarePreferenceReader } from './trust-aware-preference-reader'; @injectable() export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl { @@ -35,15 +34,15 @@ export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl { @inject(WorkspaceTrustService) protected readonly workspaceTrustService: WorkspaceTrustService; - @inject(TrustAwarePreferenceReader) - protected readonly trustAwareReader: TrustAwarePreferenceReader; + @inject(AiConfigurationService) + protected readonly aiConfiguration: AiConfigurationService; override async sendRequest( languageModel: LanguageModel, languageModelRequest: UserRequest ): Promise { - const requestSettings = this.trustAwareReader.get(PREFERENCE_NAME_REQUEST_SETTINGS, []) ?? []; - const reasoningEntries = this.trustAwareReader.get(PREFERENCE_NAME_REASONING, []) ?? []; + const requestSettings = this.aiConfiguration.get(PREFERENCE_NAME_REQUEST_SETTINGS, []) ?? []; + const reasoningEntries = this.aiConfiguration.get(PREFERENCE_NAME_REASONING, []) ?? []; const trusted = await this.workspaceTrustService.getWorkspaceTrust(); if (!trusted) { throw new Error(nls.localize('theia/ai-core/aiDisabledInRestrictedMode', 'AI features are not available in untrusted workspaces.')); diff --git a/packages/ai-core/src/browser/index.ts b/packages/ai-core/src/browser/index.ts index a727de1a045b8..5409e131c2b8c 100644 --- a/packages/ai-core/src/browser/index.ts +++ b/packages/ai-core/src/browser/index.ts @@ -23,6 +23,7 @@ export * from './ai-core-frontend-application-contribution'; export * from './ai-core-frontend-module'; export * from '../common/ai-core-preferences'; export * from './ai-settings-service'; +export * from './ai-configuration-service-impl'; export * from './ai-view-contribution'; export * from './frontend-language-model-registry'; export * from './frontend-language-model-alias-registry'; diff --git a/packages/ai-core/src/browser/trust-aware-preference-reader.spec.ts b/packages/ai-core/src/browser/trust-aware-preference-reader.spec.ts index 35189cd6fd29e..8a41ded6c7ac6 100644 --- a/packages/ai-core/src/browser/trust-aware-preference-reader.spec.ts +++ b/packages/ai-core/src/browser/trust-aware-preference-reader.spec.ts @@ -165,6 +165,35 @@ describe('TrustAwarePreferenceReader', () => { }); }); + describe('suppressUntrusted', () => { + const inspection = { + preferenceName: PREFERENCE_NAME, + defaultValue: 'default', + globalValue: 'user', + workspaceValue: 'workspace', + workspaceFolderValue: 'folder', + value: 'folder' + }; + + it('clears the workspace and folder values when untrusted', async () => { + trust.trustDeferred.resolve(false); + await reader.ready; + const result = reader.suppressUntrusted({ ...inspection }); + expect(result.workspaceValue).to.equal(undefined); + expect(result.workspaceFolderValue).to.equal(undefined); + expect(result.globalValue).to.equal('user'); + expect(result.defaultValue).to.equal('default'); + }); + + it('returns the inspection unchanged when trusted', async () => { + trust.trustDeferred.resolve(true); + await reader.ready; + const result = reader.suppressUntrusted({ ...inspection }); + expect(result.workspaceValue).to.equal('workspace'); + expect(result.workspaceFolderValue).to.equal('folder'); + }); + }); + describe('onDidChangeTrust', () => { beforeEach(async () => { trust.trustDeferred.resolve(false); diff --git a/packages/ai-core/src/browser/trust-aware-preference-reader.ts b/packages/ai-core/src/browser/trust-aware-preference-reader.ts index 2c0f6c9f17296..495cf49e31893 100644 --- a/packages/ai-core/src/browser/trust-aware-preference-reader.ts +++ b/packages/ai-core/src/browser/trust-aware-preference-reader.ts @@ -18,7 +18,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import { JSONValue } from '@theia/core/shared/@lumino/coreutils'; import { Emitter, Event } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { PreferenceService } from '@theia/core/lib/common/preferences'; +import { PreferenceInspection, PreferenceService } from '@theia/core/lib/common/preferences'; import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service'; /** @@ -27,6 +27,9 @@ import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-tr * * Writes should continue to go through `PreferenceService` directly; trust only * affects reads. + * + * @internal This is an implementation detail of {@link AiConfigurationService}. AI configuration + * consumers should go through `AiConfigurationService` rather than injecting this reader directly. */ @injectable() export class TrustAwarePreferenceReader { @@ -102,7 +105,24 @@ export class TrustAwarePreferenceReader { return this.preferences.get(preferenceName, fallback, resourceUri); } const inspection = this.preferences.inspect(preferenceName, resourceUri); - const value = inspection?.globalValue ?? inspection?.defaultValue; - return (value as T | undefined) ?? fallback; + if (!inspection) { + return fallback; + } + const suppressed = this.suppressUntrusted(inspection); + return ((suppressed.globalValue ?? suppressed.defaultValue) as T | undefined) ?? fallback; + } + + /** + * Narrows a raw {@link PreferenceInspection} according to workspace trust: when the workspace is + * untrusted the folder and workspace scope values are dropped so that only the user and default + * scopes contribute (failing closed until the trust state resolves). A trusted inspection is + * returned unchanged. This is the single definition of the trust-suppression rule, shared by + * {@link get} and by inspection-based consumers, so value reads and inspections cannot drift. + */ + suppressUntrusted(inspection: PreferenceInspection): PreferenceInspection { + if (this.trusted) { + return inspection; + } + return { ...inspection, workspaceValue: undefined, workspaceFolderValue: undefined }; } } diff --git a/packages/ai-core/src/common/ai-configuration-service.ts b/packages/ai-core/src/common/ai-configuration-service.ts new file mode 100644 index 0000000000000..b0574c41e272c --- /dev/null +++ b/packages/ai-core/src/common/ai-configuration-service.ts @@ -0,0 +1,128 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Event } from '@theia/core'; +import { JSONValue } from '@theia/core/shared/@lumino/coreutils'; +import { PreferenceInspection, PreferenceScope } from '@theia/core/lib/common/preferences'; + +export const AiConfigurationService = Symbol('AiConfigurationService'); + +/** + * A {@link PreferenceInspection} augmented with a derived, workspace-trust-aware source scope. + */ +export interface AiConfigurationInspection extends PreferenceInspection { + /** + * The narrowest scope (honoring workspace trust) in which a value is explicitly set, i.e. the + * scope that determines the effective {@link PreferenceInspection.value}. Walked in precedence + * order `Folder -> Workspace -> User`; `undefined` when only the default value applies. In an + * untrusted workspace the folder/workspace scopes are ignored, so `sourceScope` is either + * `User` or `undefined`. + */ + sourceScope?: PreferenceScope; +} + +/** + * Payload of {@link AiConfigurationService.onDidChange}. + */ +export interface AiConfigurationChange { + /** + * The preference key that changed. When the change is caused by a workspace-trust transition + * (which can change the effective value of any trust-gated key) this is `undefined` and + * {@link affects} returns `true` for every resource. Listeners tracking a specific key should + * re-query the value when `preferenceName` is `undefined` or equals their key. + */ + readonly preferenceName?: string; + /** + * Tests whether the given resource is affected by the change. + * @param resourceUri the uri of the resource to test. + */ + affects(resourceUri?: string): boolean; + /** + * Tests whether the given preference key is affected by this change, treating a workspace-trust + * transition (where {@link preferenceName} is `undefined`) as affecting every trust-gated key. + * Prefer this over comparing {@link preferenceName} directly so listeners do not silently miss + * trust transitions. + * @param preferenceName the preference key to test. + */ + affectsPreference(preferenceName: string): boolean; +} + +/** + * Framework API for reading and writing AI-related (`ai-features.*`) preferences. + * + * `AiConfigurationService` wraps the core `PreferenceService` and is the intended extension point + * for adopters and extensions that configure AI features: prefer it over talking to + * `PreferenceService` directly for `ai-features.*` keys. Routing all AI configuration through this + * seam keeps consumers insulated from future changes to how AI preferences are stored. + * + * Reads ({@link get}, {@link inspect}) are **workspace-trust-aware**: workspace and folder scope + * values are suppressed while the workspace is untrusted (failing closed until the trust state + * resolves), matching how AI features read preferences today. Writes ({@link set}, {@link update}) + * are never gated by trust. + */ +export interface AiConfigurationService { + /** + * Resolves once the underlying preference service and the initial workspace-trust state are + * ready. Await this before the first read for a deterministic, trust-aware result. + */ + readonly ready: Promise; + + /** + * Retrieves the effective, trust-aware value for the given preference. + * + * @param key the preference identifier. + * @param defaultValue the value to return when no value is stored. + * @param resourceUri the uri of the resource for which the preference is read. + */ + get(key: string, defaultValue?: T, resourceUri?: string): T | undefined; + + /** + * Writes `value` to the given scope. Maps to `PreferenceService.set`. + * + * @param key the preference identifier. + * @param value the new value (must be JSON-serializable). `undefined` clears the value in the given scope. + * @param scope the scope to write to. For {@link PreferenceScope.Folder} a `resourceUri` is required. + * @param resourceUri the uri of the resource for which the preference is stored. + */ + set(key: string, value: unknown, scope: PreferenceScope, resourceUri?: string): Promise; + + /** + * "Smart write": picks the scope so that the effective value becomes `value`. Maps to + * `PreferenceService.updateValue`. + * + * @param key the preference identifier. + * @param value the value to apply (must be JSON-serializable). `undefined` resets the preference to its default value. + * @param resourceUri the uri of the resource to which the change applies. + */ + update(key: string, value: unknown, resourceUri?: string): Promise; + + /** + * Retrieves the per-scope values for the given preference, enriched with a derived, + * trust-aware {@link AiConfigurationInspection.sourceScope} and effective + * {@link PreferenceInspection.value}. + * + * @param key the preference identifier. + * @param resourceUri the uri of the resource for which the preference is inspected. + */ + inspect(key: string, resourceUri?: string): AiConfigurationInspection | undefined; + + /** + * Fires when a `ai-features.*` preference changes or when the workspace-trust state transitions + * (which can change the effective value of trust-gated keys). The effective value is not carried + * on the event; listeners re-query it via {@link get}/{@link inspect}. + */ + onDidChange: Event; +} diff --git a/packages/ai-core/src/common/index.ts b/packages/ai-core/src/common/index.ts index 722810bfa0e7b..664e8c2a75c42 100644 --- a/packages/ai-core/src/common/index.ts +++ b/packages/ai-core/src/common/index.ts @@ -31,6 +31,7 @@ export * from './protocol'; export * from './today-variable-contribution'; export * from './variable-service'; export * from './settings-service'; +export * from './ai-configuration-service'; export * from './language-model-service'; export * from './token-usage-service'; export * from './ai-variable-resource'; diff --git a/packages/ai-ide/src/browser/ai-configuration/tools-configuration-widget.tsx b/packages/ai-ide/src/browser/ai-configuration/tools-configuration-widget.tsx index 4768675b0c5ff..c3b4e9e8c920c 100644 --- a/packages/ai-ide/src/browser/ai-configuration/tools-configuration-widget.tsx +++ b/packages/ai-ide/src/browser/ai-configuration/tools-configuration-widget.tsx @@ -17,8 +17,8 @@ import { ConfirmDialog } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; -import { ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; -import { nls, PreferenceService } from '@theia/core'; +import { AiConfigurationService, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; +import { nls } from '@theia/core'; import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings'; import { ShellCommandPermissionService } from '@theia/ai-terminal/lib/browser/shell-command-permission-service'; import { @@ -47,8 +47,8 @@ export class AIToolsConfigurationWidget extends AITableConfigurationWidget this.update()); this.toDispose.pushAll([ - this.preferenceService.onPreferenceChanged(async e => { - if (e.preferenceName === TOOL_CONFIRMATION_PREFERENCE - || e.preferenceName === DEFAULT_TOOL_CONFIRMATION_PREFERENCE) { + this.aiConfigurationService.onDidChange(async e => { + if (e.affectsPreference(TOOL_CONFIRMATION_PREFERENCE) + || e.affectsPreference(DEFAULT_TOOL_CONFIRMATION_PREFERENCE)) { this.defaultState = await this.loadDefaultConfirmation(); this.toolConfirmationModes = await this.loadToolConfigurationModes(); this.update(); } - if (e.preferenceName === SHELL_COMMAND_ALLOWLIST_PREFERENCE) { + if (e.affectsPreference(SHELL_COMMAND_ALLOWLIST_PREFERENCE)) { this.allowlistPatterns = this.shellCommandPermissionService.getAllowlistPatterns(); this.update(); } - if (e.preferenceName === SHELL_COMMAND_DENYLIST_PREFERENCE) { + if (e.affectsPreference(SHELL_COMMAND_DENYLIST_PREFERENCE)) { this.denylistPatterns = this.shellCommandPermissionService.getDenylistPatterns(); this.update(); } @@ -98,6 +98,7 @@ export class AIToolsConfigurationWidget extends AITableConfigurationWidget { + await this.aiConfigurationService.ready; await this.loadItems(); this.defaultState = await this.loadDefaultConfirmation(); this.toolConfirmationModes = await this.loadToolConfigurationModes(); @@ -374,7 +375,8 @@ export class AIToolsConfigurationWidget extends AITableConfigurationWidget console.error('Failed to remove allowlist pattern:', error)); } protected handleAddDenylistPattern(): void { @@ -388,12 +390,13 @@ export class AIToolsConfigurationWidget extends AITableConfigurationWidget console.error('Failed to remove denylist pattern:', error)); } protected handleAddPatternToList( inputRef: React.RefObject, - addFn: (pattern: string) => void, + addFn: (pattern: string) => Promise, getFn: () => string[], setPatterns: (patterns: string[]) => void, setError: (error: string | undefined) => void @@ -409,7 +412,11 @@ export class AIToolsConfigurationWidget extends AITableConfigurationWidget { + setError(error instanceof Error ? error.message : 'Failed to save pattern'); + this.update(); + }); input.value = ''; setError(undefined); setPatterns(getFn()); diff --git a/packages/ai-ide/src/browser/context-file-validation-service-impl.spec.ts b/packages/ai-ide/src/browser/context-file-validation-service-impl.spec.ts index 3735b2a09ebb0..e05d069b8631d 100644 --- a/packages/ai-ide/src/browser/context-file-validation-service-impl.spec.ts +++ b/packages/ai-ide/src/browser/context-file-validation-service-impl.spec.ts @@ -28,7 +28,7 @@ import { FileStat } from '@theia/filesystem/lib/common/files'; import { ContextFileValidationService, FileValidationState } from '@theia/ai-chat/lib/browser/context-file-validation-service'; import { ContextFileValidationServiceImpl } from './context-file-validation-service-impl'; import { WorkspaceFunctionScope } from './workspace-functions'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService } from '@theia/ai-core'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; disableJSDOM(); @@ -113,11 +113,11 @@ describe('ContextFileValidationService', () => { container.bind(FileService).toConstantValue(mockFileService); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue({ + container.bind(AiConfigurationService).toConstantValue({ get: (_name: string, fallback?: T) => fallback, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader); + } as unknown as AiConfigurationService); container.bind(EnvVariablesServer).toConstantValue({ getHomeDirUri: async () => 'file:///home/user', getExecPath: async () => '', diff --git a/packages/ai-ide/src/browser/workspace-functions.spec.ts b/packages/ai-ide/src/browser/workspace-functions.spec.ts index b8b803b73466a..adc684069bd31 100644 --- a/packages/ai-ide/src/browser/workspace-functions.spec.ts +++ b/packages/ai-ide/src/browser/workspace-functions.spec.ts @@ -29,8 +29,7 @@ import { WorkspaceFunctionScope, FindFilesByPattern } from './workspace-functions'; -import { ToolInvocationContext } from '@theia/ai-core'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService, ToolInvocationContext } from '@theia/ai-core'; import { Container } from '@theia/core/shared/inversify'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; @@ -78,11 +77,11 @@ const makeRipgrepLikeSearchService = (filesByRoot: Record) => }); }); -const makeTrustAwareReader = (overrides: { [pref: string]: unknown } = {}): TrustAwarePreferenceReader => ({ +const makeTrustAwareReader = (overrides: { [pref: string]: unknown } = {}): AiConfigurationService => ({ get: (name: string, fallback?: T) => (name in overrides ? overrides[name] as T : fallback), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) -} as unknown as TrustAwarePreferenceReader); +} as unknown as AiConfigurationService); const makeEnvVariablesServer = (homeDirUri: string = 'file:///home/test'): EnvVariablesServer => ({ getHomeDirUri: async () => homeDirUri, @@ -171,7 +170,7 @@ describe('Workspace Functions Cancellation Tests', () => { container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(ProblemManager).toConstantValue(mockProblemManager); container.bind(MonacoTextModelService).toConstantValue(mockMonacoTextModelService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeFileSearchService()); container.bind(WorkspaceFunctionScope).toSelf(); @@ -294,7 +293,7 @@ describe('FileContentFunction.getArgumentsShortLabel', () => { container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); @@ -412,7 +411,7 @@ describe('FileContentFunction handler', () => { container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); @@ -776,7 +775,7 @@ describe('FindFilesByPattern.getArgumentsShortLabel', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeFileSearchService()); container.bind(WorkspaceFunctionScope).toSelf(); @@ -852,14 +851,14 @@ describe('FindFilesByPattern.findFiles', () => { (name === 'ai-features.workspaceFunctions.allowedExternalPaths' ? (allowedExternal as unknown as T) : fallback), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader; + } as unknown as AiConfigurationService; fileSearchService = makeFileSearchService(async () => searchResults); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReader); + container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(fileSearchService); container.bind(WorkspaceFunctionScope).toSelf(); @@ -996,7 +995,7 @@ describe('WorkspaceFunctionScope gitignore caching', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); @@ -1065,7 +1064,7 @@ describe('GetWorkspaceFileList resolves the target directory once', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceFileList).toSelf(); @@ -1128,7 +1127,7 @@ describe('GetWorkspaceDirectoryStructure preserves empty folders', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceDirectoryStructure).toSelf(); @@ -1194,13 +1193,13 @@ describe('FileContentFunction external paths', () => { get: (_name: string, fallback?: T) => (trustedScopeOnly ? fallback : (allowedPaths as unknown as T)), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader; + } as unknown as AiConfigurationService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReader); + container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test')); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); @@ -1362,12 +1361,12 @@ describe('GetWorkspaceFileList / GetWorkspaceDirectoryStructure with external pa get: (_name: string, _fallback?: T) => allowedPaths as unknown as T, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader; + } as unknown as AiConfigurationService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReader); + container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceFileList).toSelf(); @@ -1452,12 +1451,12 @@ describe('FindFilesByPattern with searchRoot', () => { get: (_name: string, _fallback?: T) => allowedPaths as unknown as T, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader; + } as unknown as AiConfigurationService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReader); + container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeRipgrepLikeSearchService(FILES_BY_ROOT)); container.bind(WorkspaceFunctionScope).toSelf(); @@ -1547,13 +1546,13 @@ describe('WorkspaceFunctionScope path-traversal hardening', () => { get: (name: string, fallback?: T) => trustAwareGet(name, fallback), get ready(): Promise { return trustReady; }, onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) - } as unknown as TrustAwarePreferenceReader; + } as unknown as AiConfigurationService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); - container.bind(TrustAwarePreferenceReader).toConstantValue(trustAwareReader); + container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test')); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); @@ -1617,9 +1616,9 @@ describe('WorkspaceFunctionScope path-traversal hardening', () => { expect(parsed.error).to.include('not allowed'); }); - // Item 7 — getAllowedExternalUris must await TrustAwarePreferenceReader.ready + // Item 7 — getAllowedExternalUris must await AiConfigurationService.ready // so that preference reads see the resolved trust state. - it('awaits TrustAwarePreferenceReader.ready before reading the allow-list', async () => { + it('awaits AiConfigurationService.ready before reading the allow-list', async () => { let preferenceVisible = false; let resolveReady: () => void = () => { /* noop */ }; trustReady = new Promise(r => { resolveReady = r; }); @@ -1727,7 +1726,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1750,7 +1749,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1774,7 +1773,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1797,7 +1796,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1819,7 +1818,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1840,7 +1839,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1859,7 +1858,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -1881,7 +1880,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); return container.get(WorkspaceFunctionScope); @@ -2053,7 +2052,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceA); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); const scopeA = container.get(WorkspaceFunctionScope); @@ -2072,7 +2071,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container2.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceB); container2.bind(FileService).toConstantValue({} as FileService); container2.bind(PreferenceService).toConstantValue({ get: () => false }); - container2.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container2.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container2.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container2.bind(WorkspaceFunctionScope).toSelf(); const scopeB = container2.get(WorkspaceFunctionScope); @@ -2100,7 +2099,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -2127,7 +2126,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -2151,7 +2150,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); @@ -2175,7 +2174,7 @@ describe('WorkspaceFunctionScope Multi-Root Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); diff --git a/packages/ai-ide/src/browser/workspace-functions.ts b/packages/ai-ide/src/browser/workspace-functions.ts index ccf67fef1d1d6..677307f3fec31 100644 --- a/packages/ai-ide/src/browser/workspace-functions.ts +++ b/packages/ai-ide/src/browser/workspace-functions.ts @@ -13,8 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService, ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core'; import { CancellationToken, Disposable, OS, PreferenceService, URI, Path } from '@theia/core'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; @@ -54,8 +53,8 @@ export class WorkspaceFunctionScope { @inject(PreferenceService) protected readonly preferences: PreferenceService; - @inject(TrustAwarePreferenceReader) - protected readonly trustAwarePreferences: TrustAwarePreferenceReader; + @inject(AiConfigurationService) + protected readonly aiConfiguration: AiConfigurationService; @inject(EnvVariablesServer) protected readonly envVariablesServer: EnvVariablesServer; @@ -379,15 +378,15 @@ export class WorkspaceFunctionScope { /** * Resolves the configured external allow-list to URIs. Reads via the - * trust-aware preference reader so workspace-scoped overrides are dropped - * when the workspace is untrusted. Awaits the reader's `ready` promise so - * that the trust state is resolved before the first preference read. + * trust-aware {@link AiConfigurationService} so workspace-scoped overrides are + * dropped when the workspace is untrusted. Awaits the service's `ready` promise + * so that the trust state is resolved before the first preference read. * Non-string entries, blanks, and entries that don't parse to a `file://` * URI are filtered out; URIs are returned in normalized form. */ async getAllowedExternalUris(resourceUri?: string): Promise { - await this.trustAwarePreferences.ready; - const raw = this.trustAwarePreferences.get(ALLOWED_EXTERNAL_PATHS_PREF, [], resourceUri) ?? []; + await this.aiConfiguration.ready; + const raw = this.aiConfiguration.get(ALLOWED_EXTERNAL_PATHS_PREF, [], resourceUri) ?? []; const result: URI[] = []; for (const entry of raw) { if (typeof entry !== 'string') { diff --git a/packages/ai-ide/src/browser/workspace-launch-provider.spec.ts b/packages/ai-ide/src/browser/workspace-launch-provider.spec.ts index 4c1b5d73ea05c..75ff079a3db3b 100644 --- a/packages/ai-ide/src/browser/workspace-launch-provider.spec.ts +++ b/packages/ai-ide/src/browser/workspace-launch-provider.spec.ts @@ -35,16 +35,16 @@ import { DebugConfiguration } from '@theia/debug/lib/common/debug-common'; import { DebugCompound } from '@theia/debug/lib/common/debug-compound'; import { DebugSession } from '@theia/debug/lib/browser/debug-session'; import { WorkspaceFunctionScope } from './workspace-functions'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; +import { AiConfigurationService } from '@theia/ai-core'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; -const makeTrustAwareReader = (): TrustAwarePreferenceReader => ({ +const makeTrustAwareReader = (): AiConfigurationService => ({ get: (_name: string, fallback?: T) => fallback, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { } }) -} as unknown as TrustAwarePreferenceReader); +} as unknown as AiConfigurationService); const makeEnvVariablesServer = (): EnvVariablesServer => ({ getHomeDirUri: async () => 'file:///home/test', @@ -88,7 +88,7 @@ describe('Launch Management Tool Providers', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); @@ -396,7 +396,7 @@ describe('Launch Management Tool Providers', () => { multiRootContainer.bind(WorkspaceService).toConstantValue(multiRootWorkspaceService); multiRootContainer.bind(FileService).toConstantValue({} as FileService); multiRootContainer.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); - multiRootContainer.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + multiRootContainer.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); multiRootContainer.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); multiRootContainer.bind(WorkspaceFunctionScope).toSelf(); diff --git a/packages/ai-ide/src/browser/workspace-task-provider.spec.ts b/packages/ai-ide/src/browser/workspace-task-provider.spec.ts index bf1b31c069f74..3e22f70f9b3b3 100644 --- a/packages/ai-ide/src/browser/workspace-task-provider.spec.ts +++ b/packages/ai-ide/src/browser/workspace-task-provider.spec.ts @@ -18,23 +18,22 @@ import { expect } from 'chai'; import { CancellationTokenSource, PreferenceService } from '@theia/core'; import URI from '@theia/core/lib/common/uri'; import { GLOBAL_SCOPE_TOKEN, TaskListProvider, TaskRunnerProvider, WORKSPACE_SCOPE_TOKEN } from './workspace-task-provider'; -import { ToolInvocationContext } from '@theia/ai-core'; +import { AiConfigurationService, ToolInvocationContext } from '@theia/ai-core'; import { Container } from '@theia/core/shared/inversify'; import { TaskService } from '@theia/task/lib/browser/task-service'; import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; import { TaskConfiguration, TaskInfo, TaskScope } from '@theia/task/lib/common'; import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { WorkspaceFunctionScope } from './workspace-functions'; -import { TrustAwarePreferenceReader } from '@theia/ai-core/lib/browser/trust-aware-preference-reader'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; -const makeTrustAwareReader = (): TrustAwarePreferenceReader => ({ +const makeTrustAwareReader = (): AiConfigurationService => ({ get: (_name: string, fallback?: T) => fallback, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { } }) -} as unknown as TrustAwarePreferenceReader); +} as unknown as AiConfigurationService); const makeEnvVariablesServer = (): EnvVariablesServer => ({ getHomeDirUri: async () => 'file:///home/test', @@ -129,7 +128,7 @@ describe('Workspace Task Provider Cancellation Tests', () => { container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); - container.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(TaskListProvider).toSelf(); @@ -362,7 +361,7 @@ describe('Workspace Task Provider Cancellation Tests', () => { multiRootContainer.bind(WorkspaceService).toConstantValue(multiRootWorkspaceService); multiRootContainer.bind(FileService).toConstantValue({} as FileService); multiRootContainer.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); - multiRootContainer.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + multiRootContainer.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); multiRootContainer.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); multiRootContainer.bind(WorkspaceFunctionScope).toSelf(); multiRootContainer.bind(TaskRunnerProvider).toSelf(); @@ -409,7 +408,7 @@ describe('Workspace Task Provider Cancellation Tests', () => { multiRootContainer.bind(WorkspaceService).toConstantValue(multiRootWorkspaceService); multiRootContainer.bind(FileService).toConstantValue({} as FileService); multiRootContainer.bind(PreferenceService).toConstantValue({ get: () => false } as unknown as PreferenceService); - multiRootContainer.bind(TrustAwarePreferenceReader).toConstantValue(makeTrustAwareReader()); + multiRootContainer.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); multiRootContainer.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); multiRootContainer.bind(WorkspaceFunctionScope).toSelf(); multiRootContainer.bind(TaskRunnerProvider).toSelf(); diff --git a/packages/ai-terminal/src/browser/shell-command-permission-service.spec.ts b/packages/ai-terminal/src/browser/shell-command-permission-service.spec.ts index 808d1811aaa7a..4176b76e3e9ed 100644 --- a/packages/ai-terminal/src/browser/shell-command-permission-service.spec.ts +++ b/packages/ai-terminal/src/browser/shell-command-permission-service.spec.ts @@ -18,12 +18,12 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { ShellCommandPermissionService } from './shell-command-permission-service'; import { SHELL_COMMAND_ALLOWLIST_PREFERENCE, SHELL_COMMAND_DENYLIST_PREFERENCE } from '../common/shell-command-preferences'; -import { PreferenceService } from '@theia/core/lib/common/preferences'; +import { AiConfigurationService } from '@theia/ai-core'; import { DefaultShellCommandAnalyzer, ShellCommandAnalyzer } from '../common/shell-command-analyzer'; describe('ShellCommandPermissionService', () => { let service: ShellCommandPermissionService; - let preferenceServiceMock: sinon.SinonStubbedInstance; + let aiConfigurationServiceMock: sinon.SinonStubbedInstance; let storedPatterns: string[]; let storedDenylistPatterns: string[]; @@ -31,7 +31,7 @@ describe('ShellCommandPermissionService', () => { storedPatterns = []; storedDenylistPatterns = []; - preferenceServiceMock = { + aiConfigurationServiceMock = { get: sinon.stub().callsFake((key: string, defaultValue: string[]) => { if (key === SHELL_COMMAND_ALLOWLIST_PREFERENCE) { return storedPatterns; @@ -41,7 +41,7 @@ describe('ShellCommandPermissionService', () => { } return defaultValue; }), - updateValue: sinon.stub().callsFake((key: string, value: string[]) => { + update: sinon.stub().callsFake((key: string, value: string[]) => { if (key === SHELL_COMMAND_ALLOWLIST_PREFERENCE) { storedPatterns = value; } else if (key === SHELL_COMMAND_DENYLIST_PREFERENCE) { @@ -49,10 +49,10 @@ describe('ShellCommandPermissionService', () => { } return Promise.resolve(); }) - } as unknown as sinon.SinonStubbedInstance; + } as unknown as sinon.SinonStubbedInstance; service = new ShellCommandPermissionService(); - (service as unknown as { preferenceService: PreferenceService }).preferenceService = preferenceServiceMock; + (service as unknown as { aiConfigurationService: AiConfigurationService }).aiConfigurationService = aiConfigurationServiceMock; (service as unknown as { shellCommandAnalyzer: ShellCommandAnalyzer }).shellCommandAnalyzer = new DefaultShellCommandAnalyzer(); }); @@ -91,7 +91,7 @@ describe('ShellCommandPermissionService', () => { it('does not add duplicate pattern', () => { storedPatterns = ['git log']; service.addAllowlistPatterns('git log'); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); }); @@ -412,7 +412,7 @@ describe('ShellCommandPermissionService', () => { it('adds multiple patterns in a single update', () => { service.addAllowlistPatterns('find *', 'head *'); expect(storedPatterns).to.deep.equal(['find *', 'head *']); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; }); it('skips patterns already in the allowlist', () => { @@ -424,7 +424,7 @@ describe('ShellCommandPermissionService', () => { it('does not call updateValue when all patterns already exist', () => { storedPatterns = ['find *', 'head *']; service.addAllowlistPatterns('find *', 'head *'); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('validates all patterns before adding', () => { @@ -439,7 +439,7 @@ describe('ShellCommandPermissionService', () => { it('handles no arguments without calling updateValue', () => { service.addAllowlistPatterns(); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); }); @@ -447,14 +447,14 @@ describe('ShellCommandPermissionService', () => { it('removes existing pattern', () => { storedPatterns = ['git log', 'npm test']; service.removeAllowlistPattern('git log'); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; expect(storedPatterns).to.deep.equal(['npm test']); }); it('does not call updateValue when pattern does not exist', () => { storedPatterns = ['git log']; service.removeAllowlistPattern('npm test'); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); }); @@ -528,7 +528,7 @@ describe('ShellCommandPermissionService', () => { it('adds multiple patterns in a single update', () => { service.addDenylistPatterns('git push *', 'rm -rf /'); expect(storedDenylistPatterns).to.deep.equal(['git push *', 'rm -rf /']); - expect(preferenceServiceMock.updateValue.calledOnce).to.be.true; + expect(aiConfigurationServiceMock.update.calledOnce).to.be.true; }); it('skips patterns already in the denylist', () => { @@ -540,7 +540,7 @@ describe('ShellCommandPermissionService', () => { it('does not call updateValue when all patterns already exist', () => { storedDenylistPatterns = ['git push *', 'rm -rf /']; service.addDenylistPatterns('git push *', 'rm -rf /'); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); it('validates all patterns before adding', () => { @@ -555,7 +555,7 @@ describe('ShellCommandPermissionService', () => { it('handles no arguments without calling updateValue', () => { service.addDenylistPatterns(); - expect(preferenceServiceMock.updateValue.called).to.be.false; + expect(aiConfigurationServiceMock.update.called).to.be.false; }); }); diff --git a/packages/ai-terminal/src/browser/shell-command-permission-service.ts b/packages/ai-terminal/src/browser/shell-command-permission-service.ts index 5252e2f046f6e..f9ffd51daf45b 100644 --- a/packages/ai-terminal/src/browser/shell-command-permission-service.ts +++ b/packages/ai-terminal/src/browser/shell-command-permission-service.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { PreferenceService } from '@theia/core/lib/common'; +import { AiConfigurationService } from '@theia/ai-core'; import { SHELL_COMMAND_ALLOWLIST_PREFERENCE, SHELL_COMMAND_DENYLIST_PREFERENCE } from '../common/shell-command-preferences'; import { ShellCommandAnalyzer } from '../common/shell-command-analyzer'; @@ -41,8 +41,8 @@ export interface CommandAnalysis { @injectable() export class ShellCommandPermissionService { - @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; + @inject(AiConfigurationService) + protected readonly aiConfigurationService: AiConfigurationService; @inject(ShellCommandAnalyzer) protected readonly shellCommandAnalyzer: ShellCommandAnalyzer; @@ -147,36 +147,42 @@ export class ShellCommandPermissionService { } getAllowlistPatterns(): string[] { - return this.preferenceService.get(SHELL_COMMAND_ALLOWLIST_PREFERENCE, []); + return this.aiConfigurationService.get(SHELL_COMMAND_ALLOWLIST_PREFERENCE, []) ?? []; } /** * Adds one or more patterns to the allowlist in a single update. * Rejects empty or whitespace-only patterns, "*" alone, and invalid wildcard positions. * Trims patterns before adding and avoids duplicates. + * + * Throws synchronously if any pattern is invalid. The returned promise resolves once the + * preference write completes and rejects if the write fails. */ - addAllowlistPatterns(...patterns: string[]): void { - this.addPatternsToList(patterns, SHELL_COMMAND_ALLOWLIST_PREFERENCE, () => this.getAllowlistPatterns()); + addAllowlistPatterns(...patterns: string[]): Promise { + return this.addPatternsToList(patterns, SHELL_COMMAND_ALLOWLIST_PREFERENCE, () => this.getAllowlistPatterns()); } - removeAllowlistPattern(pattern: string): void { - this.removePatternFromList(pattern, SHELL_COMMAND_ALLOWLIST_PREFERENCE, () => this.getAllowlistPatterns()); + removeAllowlistPattern(pattern: string): Promise { + return this.removePatternFromList(pattern, SHELL_COMMAND_ALLOWLIST_PREFERENCE, () => this.getAllowlistPatterns()); } getDenylistPatterns(): string[] { - return this.preferenceService.get(SHELL_COMMAND_DENYLIST_PREFERENCE, []); + return this.aiConfigurationService.get(SHELL_COMMAND_DENYLIST_PREFERENCE, []) ?? []; } /** * Adds one or more patterns to the denylist in a single update. * Uses the same validation as allowlist patterns. + * + * Throws synchronously if any pattern is invalid. The returned promise resolves once the + * preference write completes and rejects if the write fails. */ - addDenylistPatterns(...patterns: string[]): void { - this.addPatternsToList(patterns, SHELL_COMMAND_DENYLIST_PREFERENCE, () => this.getDenylistPatterns()); + addDenylistPatterns(...patterns: string[]): Promise { + return this.addPatternsToList(patterns, SHELL_COMMAND_DENYLIST_PREFERENCE, () => this.getDenylistPatterns()); } - removeDenylistPattern(pattern: string): void { - this.removePatternFromList(pattern, SHELL_COMMAND_DENYLIST_PREFERENCE, () => this.getDenylistPatterns()); + removeDenylistPattern(pattern: string): Promise { + return this.removePatternFromList(pattern, SHELL_COMMAND_DENYLIST_PREFERENCE, () => this.getDenylistPatterns()); } /** @@ -199,26 +205,28 @@ export class ShellCommandPermissionService { return trimmed; } - protected addPatternsToList(patterns: string[], preferenceKey: string, getCurrentPatterns: () => string[]): void { + protected addPatternsToList(patterns: string[], preferenceKey: string, getCurrentPatterns: () => string[]): Promise { const validated = patterns.map(p => this.validatePattern(p)); const currentPatterns = getCurrentPatterns(); const newPatterns = validated.filter(p => !currentPatterns.includes(p)); if (newPatterns.length > 0) { - this.preferenceService.updateValue( + return this.aiConfigurationService.update( preferenceKey, [...currentPatterns, ...newPatterns] ); } + return Promise.resolve(); } - protected removePatternFromList(pattern: string, preferenceKey: string, getCurrentPatterns: () => string[]): void { + protected removePatternFromList(pattern: string, preferenceKey: string, getCurrentPatterns: () => string[]): Promise { const currentPatterns = getCurrentPatterns(); const filtered = currentPatterns.filter(p => p !== pattern); if (filtered.length !== currentPatterns.length) { - this.preferenceService.updateValue( + return this.aiConfigurationService.update( preferenceKey, filtered ); } + return Promise.resolve(); } } diff --git a/packages/ai-terminal/src/browser/shell-execution-tool-renderer.tsx b/packages/ai-terminal/src/browser/shell-execution-tool-renderer.tsx index 5c33732ad4082..790dad961ff5e 100644 --- a/packages/ai-terminal/src/browser/shell-execution-tool-renderer.tsx +++ b/packages/ai-terminal/src/browser/shell-execution-tool-renderer.tsx @@ -78,7 +78,8 @@ export class ShellExecutionToolRenderer implements ChatResponsePartRenderer { if (patterns && patterns.length > 0) { try { - this.shellCommandPermissionService.addAllowlistPatterns(...patterns); + this.shellCommandPermissionService.addAllowlistPatterns(...patterns) + .catch(err => console.warn('Failed to add allowlist patterns:', err)); } catch (err) { console.warn('Failed to add allowlist patterns:', err); } @@ -88,7 +89,8 @@ export class ShellExecutionToolRenderer implements ChatResponsePartRenderer { if (options?.patterns && options.patterns.length > 0) { try { - this.shellCommandPermissionService.addDenylistPatterns(...options.patterns); + this.shellCommandPermissionService.addDenylistPatterns(...options.patterns) + .catch(err => console.warn('Failed to add denylist patterns:', err)); } catch (err) { console.warn('Failed to add denylist patterns:', err); } @@ -211,7 +213,8 @@ const ShellExecutionToolComponent: React.FC = const handleAllow = React.useCallback((patterns?: string[]) => { if (patterns && patterns.length > 0) { try { - shellCommandPermissionService.addAllowlistPatterns(...patterns); + shellCommandPermissionService.addAllowlistPatterns(...patterns) + .catch(err => console.warn('Failed to add allowlist patterns:', err)); } catch (err) { console.warn('Failed to add allowlist patterns:', err); } @@ -222,7 +225,8 @@ const ShellExecutionToolComponent: React.FC = const handleDeny = React.useCallback((options?: { patterns?: string[]; reason?: string }) => { if (options?.patterns && options.patterns.length > 0) { try { - shellCommandPermissionService.addDenylistPatterns(...options.patterns); + shellCommandPermissionService.addDenylistPatterns(...options.patterns) + .catch(err => console.warn('Failed to add denylist patterns:', err)); } catch (err) { console.warn('Failed to add denylist patterns:', err); }