diff --git a/src/main/ai-vault-search/session-search-enablement.ts b/src/main/ai-vault-search/session-search-enablement.ts index e292e4a8551..0912ec9d498 100644 --- a/src/main/ai-vault-search/session-search-enablement.ts +++ b/src/main/ai-vault-search/session-search-enablement.ts @@ -1,7 +1,4 @@ -import { - resolveAiVaultSearchSettings, - sameAiVaultSearchSettings -} from '../../shared/ai-vault-search-settings' +import { changedAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import type { GlobalSettings } from '../../shared/global-settings-types' import { updateSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' import { createChildSessionSearchService } from './session-search-child-service' @@ -49,12 +46,7 @@ export function applySessionSearchSettingsChange( before: Pick, after: Pick ): void { - if ( - sameAiVaultSearchSettings( - resolveAiVaultSearchSettings(before), - resolveAiVaultSearchSettings(after) - ) - ) { + if (!changedAiVaultSearchSettings(before, after)) { return } if (installed) { diff --git a/src/main/ai-vault-search/session-search-host-registration.test.ts b/src/main/ai-vault-search/session-search-host-registration.test.ts index 0e42ac68cde..e2a31ec4f21 100644 --- a/src/main/ai-vault-search/session-search-host-registration.test.ts +++ b/src/main/ai-vault-search/session-search-host-registration.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import { installInProcessSessionSearchService } from './session-search-in-process-service' import { openSessionSearchIndexerHarness, @@ -35,7 +36,7 @@ vi.mock('../ai-vault/cached-session-list', async (importOriginal) => ({ const ROOT = join(import.meta.dirname, '..', '..', '..') let harness: SessionSearchIndexerHarness -let installed: { dispose(): void } | null +let installed: { apply?(settings: AiVaultSearchSettings): void; dispose(): void } | null beforeEach(async () => { resetSessionParseCacheForTests() @@ -204,3 +205,37 @@ it('orcad resolves no roots while disabled and discovers late roots when enabled expect(response.hits.map((hit) => hit.sessionId)).toEqual([id]) } }) + +// A host with no scanner child has nothing to forward a policy to, so the installed +// service is itself how a settings write reaches the index. +it('re-applies consent on an in-process host without reinstalling the service', async () => { + installed = installInProcessSessionSearchService({ + dataRoot: harness.root, + roots: harness.roots, + settings: { enabled: false, historyDays: null } + }) + expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({ + kind: 'unavailable', + reason: 'disabled' + }) + + installed?.apply?.({ enabled: true, historyDays: null }) + expect(await searchSessionService({ query: 'ledger' }, 'relay')).not.toMatchObject({ + kind: 'unavailable', + reason: 'disabled' + }) + + installed?.apply?.({ enabled: false, historyDays: null }) + expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({ + kind: 'unavailable', + reason: 'disabled' + }) +}) + +// orcad reaches the index through the deps hook the runtime RPC calls; the wiring is +// what no unit of either module can show. +it('wires orcad consent from the runtime hook to the installed service', () => { + const source = readFileSync(join(ROOT, 'src/main/orcad/orcad-entry.ts'), 'utf8') + expect(source).toContain('applySessionSearchSettings:') + expect(source).toContain('sessionSearch?.apply(next)') +}) diff --git a/src/main/ai-vault-search/session-search-in-process-service.ts b/src/main/ai-vault-search/session-search-in-process-service.ts index 536ad742a98..186f8f74a0b 100644 --- a/src/main/ai-vault-search/session-search-in-process-service.ts +++ b/src/main/ai-vault-search/session-search-in-process-service.ts @@ -28,7 +28,7 @@ export function installInProcessSessionSearchService(args: { resolveRoots?: SessionSearchIndexerOptions['resolveRoots'] settings: AiVaultSearchSettings onError?: (error: unknown) => void -}): { dispose(): void } | null { +}): { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null { if (!sessionSearchSqliteAvailable()) { return null } @@ -45,6 +45,9 @@ export function installInProcessSessionSearchService(args: { reconcile: () => instance.reconcile() }) return { + // Why exposed: on these hosts a settings write reaches the index through this + // object, there being no scanner child to forward a policy to. + apply: (settings) => instance.apply(settings), dispose: () => { setSessionSearchService(null) instance.close() diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index d1de6ea2b5c..be4028460a0 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -124,6 +124,8 @@ describe('desktop IPC and preload search boundary', () => { kind: 'unavailable', reason: 'no-service' }) + // Status is the one read where "no such method" must not collapse into "off". + await expect(aiVaultApi.searchStatus('runtime:env-1')).rejects.toThrow('host-too-old') runtimeSearch.mockRejectedValue( Object.assign(new Error('runtime disconnected'), { code: 'connection_lost' }) ) @@ -187,4 +189,48 @@ describe('desktop IPC and preload search boundary', () => { 'not available for this execution host' ) }) + + it('turns a paired runtime host on and answers with the status it reported', async () => { + const local = fakeSearchService() + setSessionSearchService(local) + const enabled = { ...unavailableSessionSearchStatus(), enabled: true, generation: 4 } + runtimeSearch.mockResolvedValue(enabled) + + expect(await aiVaultApi.setSearchEnabled('runtime:env-1', true)).toEqual(enabled) + expect(runtimeSearch).toHaveBeenCalledExactlyOnceWith('env-1', 'aiVault.setSearchEnabled', { + enabled: true + }) + // The desktop's own index is never a side effect of enabling a remote one. + expect(local.status).not.toHaveBeenCalled() + }) + it('maps an unknown-method refusal to host-too-old and keeps every other failure', async () => { + runtimeSearch.mockRejectedValue( + Object.assign(new Error('Unknown method: aiVault.setSearchEnabled'), { code: -32601 }) + ) + await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old') + + runtimeSearch.mockRejectedValue(Object.assign(new Error('not paired'), { code: 'forbidden' })) + await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('not paired') + }) + it('rejects a host answer that is not a status rather than reporting success', async () => { + runtimeSearch.mockResolvedValue({ enabled: true }) + await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow() + }) + it('refuses local, SSH, unroutable hosts and a non-boolean', async () => { + await expect(aiVaultApi.setSearchEnabled('local', true)).rejects.toThrow('through Settings') + await expect(aiVaultApi.setSearchEnabled('ssh:box', true)).rejects.toThrow('unsupported') + await expect(handlers.get('aiVault:setSearchEnabled')!(null, 'nope', true)).rejects.toThrow( + 'not available for this execution host' + ) + await expect( + handlers.get('aiVault:setSearchEnabled')!(null, 'runtime:env-1', 'yes') + ).rejects.toThrow() + expect(runtimeSearch).not.toHaveBeenCalled() + expect(sshSearch).not.toHaveBeenCalled() + }) + it('reports host-too-old when this desktop has no runtime transport injected', async () => { + handlers.clear() + registerAiVaultSearchHandlers() + await expect(aiVaultApi.setSearchEnabled('runtime:env-1', true)).rejects.toThrow('host-too-old') + }) }) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 44b32db50fb..75ce9fd8725 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -6,9 +6,14 @@ import { } from '../ai-vault-search/session-search-service-registry' import { createSessionSearchClient, + isUnknownSessionSearchMethod, unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' -import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contract' +import { + AiVaultSearchRequestSchema, + AiVaultSearchStatusSchema, + AiVaultSetSearchEnabledParamsSchema +} from '../../shared/ai-vault-search-contract' import type { AiVaultSearchRequest, AiVaultSearchResponse, @@ -21,6 +26,7 @@ import { toSshExecutionHostId, type ParsedExecutionHost } from '../../shared/execution-host' +import { redactStatusForTransport } from '../../shared/ai-vault-search-transport' import { requestActiveSshSessionSearch } from './ssh' import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' @@ -42,6 +48,12 @@ export type AiVaultSearchHandlerOptions = { // One wording with the session list, which refuses the same unroutable scope. const UNROUTABLE_HOST_MESSAGE = 'Agent Session History is not available for this execution host.' +// Consent is written where the index lives: locally through settings, never here. +const LOCAL_ENABLE_MESSAGE = + 'Local Agent Session History indexing is changed through Settings, not this channel.' +const SSH_ENABLE_MESSAGE = 'unsupported' +/** Exact text, not a class: the renderer maps this one message to its own copy. */ +const HOST_TOO_OLD_MESSAGE = 'host-too-old' const scopeSchema = z.string().min(1).optional() let handlerOptions: AiVaultSearchHandlerOptions = {} @@ -61,9 +73,48 @@ export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptio const scope = requestedSearchScope(rawScope) return statusByExecutionHost(scope) }) + ipcMain.handle( + 'aiVault:setSearchEnabled', + async (_event, rawScope: unknown, rawEnabled: unknown) => { + const { enabled } = AiVaultSetSearchEnabledParamsSchema.parse({ enabled: rawEnabled }) + return setSearchEnabledByExecutionHost(requestedSearchScope(rawScope), enabled) + } + ) ipcMain.handle('aiVault:clearSearchIndex', () => clearSessionSearchInService()) } +/** + * Only a paired runtime host can be toggled from here. The local index answers to this + * desktop's own settings write, and an SSH host has no method to carry the change. + */ +async function setSearchEnabledByExecutionHost( + scope: ParsedExecutionHost, + enabled: boolean +): Promise { + if (scope.kind === 'local') { + throw new Error(LOCAL_ENABLE_MESSAGE) + } + if (scope.kind === 'ssh') { + throw new Error(SSH_ENABLE_MESSAGE) + } + const call = handlerOptions.callRuntimeSearch + if (!call) { + throw new Error(HOST_TOO_OLD_MESSAGE) + } + const { environmentId } = scope + try { + return AiVaultSearchStatusSchema.parse( + await call(environmentId, 'aiVault.setSearchEnabled', { enabled }) + ) + } catch (error) { + // An old host has no such method; every other refusal is the host's own answer. + if (isUnknownSessionSearchMethod(error)) { + throw new Error(HOST_TOO_OLD_MESSAGE) + } + throw error + } +} + /** * Why not the list's `requestedExecutionHostScope`: it normalizes an unparseable * id to `all`, which would answer an unroutable request by searching every host. @@ -134,12 +185,37 @@ function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { } } -function statusByExecutionHost(scope: ParsedExecutionHost): Promise { +async function statusByExecutionHost(scope: ParsedExecutionHost): Promise { if (scope.kind === 'local') { return sessionSearchServiceStatus({}, 'ipc') } + if (scope.kind === 'runtime') { + return runtimeHostStatus(scope.environmentId) + } const client = remoteSearchClient(scope, handlerOptions.callRuntimeSearch) - return client ? client.searchStatus() : Promise.resolve(unavailableSessionSearchStatus()) + return client ? client.searchStatus() : unavailableSessionSearchStatus() +} + +/** + * Not through the shared client: it answers an unknown method with `unavailable`, which + * the settings pane cannot tell from a current server that is switched off. + */ +async function runtimeHostStatus(environmentId: string): Promise { + const call = handlerOptions.callRuntimeSearch + if (!call) { + return unavailableSessionSearchStatus() + } + try { + return redactStatusForTransport( + AiVaultSearchStatusSchema.parse(await call(environmentId, 'aiVault.searchStatus', {})), + 'relay' + ) + } catch (error) { + if (isUnknownSessionSearchMethod(error)) { + throw new Error(HOST_TOO_OLD_MESSAGE) + } + throw error + } } // Null for the local host and for a runtime environment with no injected transport. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index c736f4f4b4c..fb22f92c392 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -26,6 +26,10 @@ import { import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' import { startOrcadWithLifecycle } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' +import { + changedAiVaultSearchSettings, + type AiVaultSearchSettings +} from '../../shared/ai-vault-search-settings' export { parseArgs } @@ -205,6 +209,10 @@ async function startOrcadRuntime( // registerPtyHandlers so the IPC layer routes through the daemon from the first call. await startOrcadDaemon() + // Why a holder and not a direct reference: the index is installed after the runtime is + // constructed, and the deps hook is only ever called later, from an RPC. + let sessionSearch: { apply(settings: AiVaultSearchSettings): void; dispose(): void } | null = null + const runtime = new OrcaRuntimeService(store, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. @@ -242,11 +250,19 @@ async function startOrcadRuntime( reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {} + isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + // Why the dedupe here and not in the instance: `apply` closes and reconstructs + // unconditionally, so an unchanged value would restart a healthy index. + applySessionSearchSettings: (before, after) => { + const next = changedAiVaultSearchSettings(before, after) + if (next) { + sessionSearch?.apply(next) + } + } }) const { installOrcadSessionSearchService } = await import('./orcad-session-search') - const sessionSearch = await installOrcadSessionSearchService({ + sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, getSettings: () => store.getSettings() }) diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 7f5d67010a8..32d32272066 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -1,4 +1,5 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import type { GlobalSettings } from '../../shared/global-settings-types' import { localAiVaultScanRoots } from '../ai-vault/cached-session-list' @@ -15,7 +16,7 @@ import { installInProcessSessionSearchService } from '../ai-vault-search/session export async function installOrcadSessionSearchService(args: { userDataPath: string getSettings: () => Pick -}): Promise<{ dispose(): void } | null> { +}): Promise<{ apply(settings: AiVaultSearchSettings): void; dispose(): void } | null> { return installInProcessSessionSearchService({ dataRoot: args.userDataPath, roots: { executionHostId: LOCAL_EXECUTION_HOST_ID }, diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 876de03fe76..781f6075be1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -28,6 +28,10 @@ import { } from './runtime-skill-command-surface' import { getAppEnvironment } from '../../shared/app-environment' import { RuntimeClientSettingsController } from './runtime-client-settings' +import { + RuntimeSessionSearchSettingsController, + type SessionSearchSettingsApply +} from './runtime-session-search-settings' import { RuntimeAutomationController } from './runtime-automation-controller' import { RuntimeOrchestrationFederation } from './runtime-orchestration-federation' import { configureAiVaultSessionSources } from '../ai-vault/cached-session-list' @@ -90,6 +94,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner skillTransactionRecovery?: Promise + // Why a host hook and not a direct call: the process that owns this runtime's index + // differs per host (scanner child on the desktop, in-process on orcad), and on orcad + // it is installed after construction, so the closure has to resolve it at call time. + applySessionSearchSettings?: SessionSearchSettingsApply orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport } ) { @@ -128,6 +136,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { }) installRuntimeServiceCommandSurface(runtime, { aiVault: this.aiVault, + sessionSearchSettings: new RuntimeSessionSearchSettingsController( + store, + deps?.applySessionSearchSettings ?? null + ), clientEvents: this.clientEvents, nativeChatDraftResolutions: this.nativeChatDraftResolutions, subscriptions: this.subscriptions, diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 5081236f09a..df246112b32 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -130,6 +130,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ // Why (#19334): "your archive hook failed, nothing was deleted" is a distinct decision — retry, // waive, or skip the hook. Flattened to runtime_error a caller can only pattern-match the text. ARCHIVE_HOOK_FAILED_REMOVAL_CODE, + // Why here and not only on the transport: a method that admits paired clients only refuses + // with the same code the mobile-allowlist check does, so a caller reads one answer either way. + 'forbidden', NESTED_WORKER_DEPTH_EXCEEDED_CODE, GIT_DIFF_TOO_LARGE_CODE, ARTIFACT_SHARING_DISABLED_CODE, diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index d3a149e38ad..13c80b544b6 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import { OrcaRuntimeService } from '../../orca-runtime' import { AI_VAULT_METHODS } from './ai-vault' @@ -76,3 +76,106 @@ describe('session search runtime RPC', () => { await expect(broken.searchSessions({ query: 'needle' })).rejects.toThrow() }) }) + +describe('session search consent over the runtime RPC', () => { + const enableRequest = (params: unknown) => ({ + id: 'enable-1', + authToken: 'test', + method: 'aiVault.setSearchEnabled', + params + }) + + function consentDispatcher(runtime = new OrcaRuntimeService()) { + const setSessionSearchEnabled = vi.fn(async () => {}) + // Overrides the surface-installed method, which proves it is there to override. + Object.assign(runtime, { setSessionSearchEnabled }) + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + // Why the streaming entry point: `pairedDeviceId` only reaches a handler through it, and + // it is the one the WebSocket transport a paired client connects over actually calls. + const call = async ( + params: unknown, + options?: { pairedDeviceId?: string; clientKind?: 'runtime' | 'mobile' } + ): Promise<{ ok: boolean; result?: unknown; error?: { code: string } }> => { + let raw = '' + await dispatcher.dispatchStreaming( + enableRequest(params), + (response) => { + raw = response + }, + options + ) + return JSON.parse(raw) + } + return { setSessionSearchEnabled, call } + } + + it('refuses an in-process caller and never touches the setting', async () => { + const { call, setSessionSearchEnabled } = consentDispatcher() + expect(await call({ enabled: true })).toMatchObject({ + ok: false, + error: { code: 'forbidden' } + }) + expect(setSessionSearchEnabled).not.toHaveBeenCalled() + }) + + it('applies a paired change and answers with this host status', async () => { + setSessionSearchService(fakeSearchService()) + const { call, setSessionSearchEnabled } = consentDispatcher() + const response = await call( + { enabled: true }, + { pairedDeviceId: 'device-7', clientKind: 'runtime' } + ) + + expect(setSessionSearchEnabled).toHaveBeenCalledExactlyOnceWith(true) + expect(response).toMatchObject({ ok: true, result: { enabled: true, generation: 7 } }) + }) + + it('withholds host roots from a paired client, as searchStatus does', async () => { + setSessionSearchService({ + ...fakeSearchService(), + status: async () => ({ + enabled: true, + phase: 'degraded' as const, + filesIndexed: 0, + filesDue: 0, + filesFailed: 1, + degradedRoots: [{ root: '/Users/someone/.claude', reason: 'unreadable' }], + lastReconcileAt: null, + lastSweepCompletedAt: null, + generation: 3 + }) + }) + const { call } = consentDispatcher() + const response = await call( + { enabled: false }, + { pairedDeviceId: 'device-7', clientKind: 'runtime' } + ) + + expect(JSON.stringify(response)).not.toContain('/Users/someone/.claude') + }) + + it('reports the host refusal when this runtime has no settings store', async () => { + const runtime = new OrcaRuntimeService() + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + let raw = '' + await dispatcher.dispatchStreaming( + enableRequest({ enabled: true }), + (response) => { + raw = response + }, + { pairedDeviceId: 'device-7', clientKind: 'runtime' } + ) + expect(JSON.parse(raw)).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable' } + }) + }) + + it('rejects a non-boolean before reaching the runtime', async () => { + const { call, setSessionSearchEnabled } = consentDispatcher() + expect(await call({ enabled: 'yes' }, { pairedDeviceId: 'device-7' })).toMatchObject({ + ok: false + }) + expect(setSessionSearchEnabled).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 5681b3a6c02..b6a2165939a 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,6 +1,7 @@ import { AiVaultSearchRequestSchema, - AiVaultSearchStatusRequestSchema + AiVaultSearchStatusRequestSchema, + AiVaultSetSearchEnabledParamsSchema } from '../../../../shared/ai-vault-search-contract' import { searchSessionService, @@ -36,6 +37,25 @@ export const AI_VAULT_METHODS = [ handler: (params, { clientKind }) => sessionSearchServiceStatus(params, clientKind ? 'relay' : 'runtime') }), + defineMethod({ + name: 'aiVault.setSearchEnabled', + params: AiVaultSetSearchEnabledParamsSchema, + handler: async (params, { runtime, clientKind, pairedDeviceId }) => { + // Paired clients only: an in-process caller writes this host's own settings directly, + // and admitting one here would let any unauthenticated local path flip consent. + if (!pairedDeviceId) { + throw Object.assign( + new Error('Session search consent can only be changed by a paired client.'), + { code: 'forbidden' } + ) + } + await runtime.setSessionSearchEnabled(params.enabled) + console.warn( + `[ai-vault-search] device ${pairedDeviceId} set indexing enabled=${params.enabled}` + ) + return sessionSearchServiceStatus({}, clientKind ? 'relay' : 'runtime') + } + }), defineMethod({ name: 'aiVault.resolveSessionTitles', params: AiVaultSessionTitlesParams, diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 4290fadecf1..55a1d020a7d 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -7,12 +7,14 @@ import type { RuntimeMobileDictationController } from './runtime-mobile-dictatio import type { RuntimeMobileNotificationController } from './runtime-mobile-notification-controller' import type { RuntimeMobileSpeechCatalog } from './runtime-mobile-speech-catalog' import type { RuntimeNativeChatDraftResolutions } from './runtime-native-chat-draft-resolutions' +import type { RuntimeSessionSearchSettingsController } from './runtime-session-search-settings' import type { RuntimeSubscriptionRegistry } from './runtime-subscription-registry' export type RuntimeServiceCommandSurface = { listAiVaultSessions: RuntimeAiVaultCommands['list'] resolveAiVaultSessionTitles: RuntimeAiVaultCommands['resolveTitles'] prepareAiVaultSessionResume: RuntimeAiVaultCommands['prepare'] + setSessionSearchEnabled: RuntimeSessionSearchSettingsController['setEnabled'] onClientEvent: RuntimeClientEventBus['on'] notifyNativeChatLaunchDraftResolved: RuntimeNativeChatDraftResolutions['notify'] registerSubscriptionCleanup: RuntimeSubscriptionRegistry['register'] @@ -69,6 +71,7 @@ export type RuntimeServiceCommandSurface = { type RuntimeServiceCommandOwners = { aiVault: RuntimeAiVaultCommands + sessionSearchSettings: RuntimeSessionSearchSettingsController clientEvents: RuntimeClientEventBus nativeChatDraftResolutions: RuntimeNativeChatDraftResolutions subscriptions: RuntimeSubscriptionRegistry @@ -85,6 +88,7 @@ export function installRuntimeServiceCommandSurface( owners: RuntimeServiceCommandOwners ): void { const vault = owners.aiVault + const sessionSearchSettings = owners.sessionSearchSettings const events = owners.clientEvents const drafts = owners.nativeChatDraftResolutions const subscriptions = owners.subscriptions @@ -98,6 +102,7 @@ export function installRuntimeServiceCommandSurface( listAiVaultSessions: vault.list.bind(vault), resolveAiVaultSessionTitles: vault.resolveTitles.bind(vault), prepareAiVaultSessionResume: vault.prepare.bind(vault), + setSessionSearchEnabled: sessionSearchSettings.setEnabled.bind(sessionSearchSettings), onClientEvent: events.on.bind(events), notifyNativeChatLaunchDraftResolved: drafts.notify.bind(drafts), registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), diff --git a/src/main/runtime/runtime-session-search-settings.test.ts b/src/main/runtime/runtime-session-search-settings.test.ts new file mode 100644 index 00000000000..3dda68949ab --- /dev/null +++ b/src/main/runtime/runtime-session-search-settings.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RuntimeSessionSearchSettingsController, + type SessionSearchSettingsStore +} from './runtime-session-search-settings' +import type { GlobalSettings } from '../../shared/global-settings-types' + +type Settings = ReturnType + +function storeWith(aiVaultSearch: GlobalSettings['aiVaultSearch'] | undefined) { + let settings: Settings = { + workspaceDir: '/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + ...(aiVaultSearch ? { aiVaultSearch } : {}) + } + const updateSettings = vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + }) + const store: SessionSearchSettingsStore = { + getSettings: () => settings, + updateSettings + } + return { store, updateSettings, read: () => settings } +} + +describe('runtime session search consent', () => { + it('writes the whole policy and hands the host before/after exactly once', async () => { + const { store, updateSettings, read } = storeWith({ enabled: false, historyDays: 30 }) + const apply = vi.fn() + await new RuntimeSessionSearchSettingsController(store, apply).setEnabled(true) + + expect(updateSettings).toHaveBeenCalledExactlyOnceWith( + { aiVaultSearch: { enabled: true, historyDays: 30 } }, + { notifyListeners: true } + ) + // Retention must ride along untouched; a partial write would reset it to "all history". + expect(read().aiVaultSearch).toEqual({ enabled: true, historyDays: 30 }) + expect(apply).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ aiVaultSearch: { enabled: false, historyDays: 30 } }), + expect.objectContaining({ aiVaultSearch: { enabled: true, historyDays: 30 } }) + ) + }) + + it('normalizes an absent or malformed stored policy instead of writing it back', async () => { + const { store, updateSettings } = storeWith(undefined) + await new RuntimeSessionSearchSettingsController(store, null).setEnabled(true) + + expect(updateSettings).toHaveBeenCalledExactlyOnceWith( + { aiVaultSearch: { enabled: true, historyDays: null } }, + { notifyListeners: true } + ) + }) + + it('hands the host an unchanged pair when the value did not move', async () => { + const { store, updateSettings } = storeWith({ enabled: true, historyDays: null }) + const apply = vi.fn() + await new RuntimeSessionSearchSettingsController(store, apply).setEnabled(true) + + // The write still happens; the host hook is what refuses to restart a live index. + expect(updateSettings).toHaveBeenCalledOnce() + const [before, after] = apply.mock.calls[0] ?? [] + expect(before?.aiVaultSearch).toEqual(after?.aiVaultSearch) + }) + + it('refuses on a host with no settings store rather than reporting success', async () => { + await expect( + new RuntimeSessionSearchSettingsController(null, vi.fn()).setEnabled(true) + ).rejects.toThrow('runtime_unavailable') + }) +}) diff --git a/src/main/runtime/runtime-session-search-settings.ts b/src/main/runtime/runtime-session-search-settings.ts new file mode 100644 index 00000000000..445c9593450 --- /dev/null +++ b/src/main/runtime/runtime-session-search-settings.ts @@ -0,0 +1,36 @@ +import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { RuntimeStore } from './runtime-store-contract' + +/** + * How this host reaches the index it owns after the store write lands: the scanner + * child on the desktop, the in-process instance on orcad. Null on a host that owns + * none, where the write is still recorded and nothing is reconstructed. + */ +export type SessionSearchSettingsApply = ( + before: Pick, + after: Pick +) => void + +/** Only the two store members this write needs, so a caller need not own the whole runtime store. */ +export type SessionSearchSettingsStore = Pick + +/** Consent for this host's transcript index, written by a paired client rather than the local UI. */ +export class RuntimeSessionSearchSettingsController { + constructor( + private readonly store: SessionSearchSettingsStore | null, + private readonly apply: SessionSearchSettingsApply | null + ) {} + + async setEnabled(enabled: boolean): Promise { + if (!this.store?.getSettings || !this.store.updateSettings) { + throw new Error('runtime_unavailable') + } + const before = this.store.getSettings() + this.store.updateSettings( + { aiVaultSearch: { ...resolveAiVaultSearchSettings(before), enabled } }, + { notifyListeners: true } + ) + this.apply?.(before, this.store.getSettings()) + } +} diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index b1471ff8efe..fa756d0f033 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -120,6 +120,7 @@ export type RuntimeStore = { hostSettingOverrides?: GlobalSettings['hostSettingOverrides'] agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] + aiVaultSearch?: GlobalSettings['aiVaultSearch'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 6d7ba1fe23a..62716bbcdec 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -1,4 +1,7 @@ -import { installChildSessionSearchService } from '../ai-vault-search/session-search-enablement' +import { + applySessionSearchSettingsChange, + installChildSessionSearchService +} from '../ai-vault-search/session-search-enablement' import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' import { app } from 'electron' import { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -131,6 +134,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, orchestrationEnvironmentTransport, + // Why the same function the settings IPC handler calls: a paired client's write and a + // local one must reconcile the scanner child through one path, or they can disagree. + applySessionSearchSettings: applySessionSearchSettingsChange, skillTransactionRecovery: state.skillTransactionRecovery }) // Both desktop and headless serve own a host-local search service. diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index cf82217ec17..34b6357c814 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,6 +33,15 @@ export type AiVaultApi = { ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise + /** + * Turns indexing on or off on a paired Orca server and answers its status after the change. + * Runtime hosts only: the local index follows this desktop's own settings write, SSH hosts + * reject with `unsupported`, and a server predating the method rejects with `host-too-old`. + */ + setSearchEnabled: ( + executionHostId: ExecutionHostId, + enabled: boolean + ) => Promise /** Deletes and rebuilds this desktop's local search index. */ clearSearchIndex: () => Promise listSessions: (args?: AiVaultListArgs) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index 68feb382223..9089c82220c 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -1,5 +1,5 @@ import { createSessionSearchClient } from '../../shared/ai-vault-search-client' -import type { AiVaultSearchRequest } from '../../shared/ai-vault-search-types' +import type { AiVaultSearchRequest, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, @@ -42,6 +42,11 @@ export const aiVaultApi = { searchClient(executionHostScope).searchSessions(request), searchStatus: (executionHostScope?: ExecutionHostId) => searchClient(executionHostScope).searchStatus(), + setSearchEnabled: ( + executionHostId: ExecutionHostId, + enabled: boolean + ): Promise => + ipcRenderer.invoke('aiVault:setSearchEnabled', executionHostId, enabled), clearSearchIndex: (): Promise => ipcRenderer.invoke('aiVault:clearSearchIndex'), listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index d87e88d63bc..5f257c5c12a 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -39,6 +39,9 @@ export function createWebAiVaultApi(): NonNullable['aiVault' addressesOwnRuntime(executionHostScope) ? search.searchStatus() : Promise.resolve(unavailableSessionSearchStatus()), + // Why refused and not forwarded: consent for a host's index is an operator action, + // and the browser client has no desktop settings surface to reconcile it against. + setSearchEnabled: () => Promise.reject(new Error('unsupported')), clearSearchIndex: () => Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')), listSessions: (args?: AiVaultListArgs) => { diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index ac89f694886..dd68bbd6c67 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -29,7 +29,7 @@ export function unavailableSessionSearchStatus(): AiVaultSearchStatus { } // Only an explicit unknown-method refusal proves the old host lacks this surface. -function isUnknownMethod(error: unknown): boolean { +export function isUnknownSessionSearchMethod(error: unknown): boolean { if (!error || typeof error !== 'object' || !('code' in error)) { return false } @@ -50,7 +50,7 @@ export function createSessionSearchClient( try { raw = await call('aiVault.searchSessions', parsed) } catch (error) { - if (isUnknownMethod(error)) { + if (isUnknownSessionSearchMethod(error)) { return { kind: 'unavailable', reason: 'no-service' } } throw error @@ -73,7 +73,7 @@ export function createSessionSearchClient( transport ) } catch (error) { - if (isUnknownMethod(error)) { + if (isUnknownSessionSearchMethod(error)) { return unavailableSessionSearchStatus() } throw error diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index 98817ac27f9..3bcb0d44856 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -103,6 +103,8 @@ export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [ }) ]) export const AiVaultSearchStatusRequestSchema = z.object({}) +/** Consent flip for one host's index. Answered with that host's status after the change is applied. */ +export const AiVaultSetSearchEnabledParamsSchema = z.object({ enabled: z.boolean() }) export const AiVaultSearchStatusSchema = z.object({ enabled: z.boolean(), phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']), diff --git a/src/shared/ai-vault-search-settings.ts b/src/shared/ai-vault-search-settings.ts index 8b6b8e1b07c..98b1b9ed2ce 100644 --- a/src/shared/ai-vault-search-settings.ts +++ b/src/shared/ai-vault-search-settings.ts @@ -63,3 +63,17 @@ export function sameAiVaultSearchSettings( ): boolean { return a.enabled === b.enabled && a.historyDays === b.historyDays } + +/** + * The policy a settings write moves to, or null when nothing about it changed. + * + * Every host that owns an index closes and reconstructs on apply, so an unchanged + * value has to be filtered here rather than at the indexer. + */ +export function changedAiVaultSearchSettings( + before: { aiVaultSearch?: unknown } | null | undefined, + after: { aiVaultSearch?: unknown } | null | undefined +): AiVaultSearchSettings | null { + const next = resolveAiVaultSearchSettings(after) + return sameAiVaultSearchSettings(resolveAiVaultSearchSettings(before), next) ? null : next +} diff --git a/src/shared/ai-vault-search-types.ts b/src/shared/ai-vault-search-types.ts index fc400ffebdc..41ecd4f67c5 100644 --- a/src/shared/ai-vault-search-types.ts +++ b/src/shared/ai-vault-search-types.ts @@ -4,7 +4,8 @@ import type { AiVaultSearchResponseSchema, AiVaultSearchHitSchema, AiVaultSearchHostOutcomeSchema, - AiVaultSearchStatusSchema + AiVaultSearchStatusSchema, + AiVaultSetSearchEnabledParamsSchema } from './ai-vault-search-contract' /** @@ -23,3 +24,5 @@ export type AiVaultSearchHit = z.infer export type AiVaultSearchStatus = z.infer /** Only an all-computers merge reports these; a single-host answer omits them. */ export type AiVaultSearchHostOutcome = z.infer +/** Turning indexing on or off for one host; the response is that host's `AiVaultSearchStatus`. */ +export type AiVaultSetSearchEnabledParams = z.infer diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7824726bccd..55cdeaa5826 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -4,7 +4,8 @@ import type { z } from 'zod' import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract' import { AiVaultSearchRequestSchema, - AiVaultSearchStatusRequestSchema + AiVaultSearchStatusRequestSchema, + AiVaultSetSearchEnabledParamsSchema } from '../ai-vault-search-contract' import { BrowserClientFileChannelAbortParams, @@ -584,6 +585,7 @@ export const RPC_PARAMS_BY_METHOD = { 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, 'aiVault.searchSessions': AiVaultSearchRequestSchema, 'aiVault.searchStatus': AiVaultSearchStatusRequestSchema, + 'aiVault.setSearchEnabled': AiVaultSetSearchEnabledParamsSchema, 'artifacts.delete': ArtifactsDeleteParams, 'artifacts.getPublishedLink': SourceRequest, 'artifacts.list': ListOptions,