From 7c58c27dac1758dbe3603d62bcd0c452ecbd87a2 Mon Sep 17 00:00:00 2001 From: Ewen Date: Thu, 30 Jul 2026 17:44:59 -0700 Subject: [PATCH] feat(permission-management): add scoped permission management Centralize stable Broker identities and persist Global, Project, and Session grants across ACP runtimes. Add confirmation, connector coordination, Settings revoke/undo controls, and navigation back to remembered sessions. Preserve project deletion intent across grant cleanup failures, migrate legacy Compute grants only after a complete import, repair historical Artifact aliases idempotently, and keep provider-native web tools Once-only. --- docs/PRD.md | 2 +- prisma/schema.prisma | 19 + resources/notebook/repl_loop.js | 8 +- src/main/acp/ipc.test.ts | 26 +- src/main/acp/ipc.ts | 20 +- .../acp/permission-broker-registry.test.ts | 576 ++++++++++++++++++ src/main/acp/permission-broker.test.ts | 144 +++-- src/main/acp/permission-broker.ts | 341 ++++++++--- src/main/acp/permission-policy.ts | 44 +- src/main/acp/runtime-coordinator.test.ts | 101 ++- src/main/acp/runtime-coordinator.ts | 31 +- src/main/acp/runtime.test.ts | 411 ++++++++++++- src/main/acp/runtime.ts | 555 +++++++++++------ .../agent-framework/app-mcp-names.test.ts | 64 ++ src/main/agent-framework/app-mcp-names.ts | 22 +- src/main/agent-framework/opencode.test.ts | 25 +- src/main/agent-framework/opencode.ts | 9 +- .../compute/compute-approval-broker.test.ts | 85 ++- src/main/compute/compute-approval-broker.ts | 91 ++- src/main/compute/compute-service.ts | 13 +- src/main/compute/ipc.test.ts | 62 +- src/main/compute/ipc.ts | 101 ++- .../compute/permission-grant-adapter.test.ts | 175 ++++++ src/main/compute/permission-grant-adapter.ts | 134 ++++ src/main/connectors/approval-broker.test.ts | 25 +- src/main/connectors/approval-broker.ts | 9 +- src/main/connectors/service.test.ts | 384 +++++++++++- src/main/connectors/service.ts | 219 +++++-- src/main/ipc.ts | 134 +++- src/main/notebook/e2e.certification.test.ts | 9 +- .../notebook/host-mcp.integration.test.ts | 51 +- .../notebook/local-rpc-server.mcpcall.test.ts | 172 +++++- src/main/notebook/local-rpc-server.test.ts | 127 +++- src/main/notebook/local-rpc-server.ts | 150 ++++- src/main/notebook/runtime-service.test.ts | 55 ++ src/main/notebook/runtime-service.ts | 66 +- src/main/permission-grants/capability.test.ts | 79 +++ src/main/permission-grants/capability.ts | 160 +++++ src/main/permission-grants/catalog.test.ts | 132 ++++ src/main/permission-grants/catalog.ts | 213 +++++++ .../connector-broker.test.ts | 153 +++++ .../permission-grants/connector-broker.ts | 120 ++++ .../identity-catalog.test.ts | 22 + .../permission-grants/identity-catalog.ts | 59 ++ src/main/permission-grants/ipc.test.ts | 114 ++++ src/main/permission-grants/ipc.ts | 145 +++++ .../permission-grants/reconciliation.test.ts | 52 ++ src/main/permission-grants/reconciliation.ts | 71 +++ src/main/permission-grants/registry.test.ts | 517 ++++++++++++++++ src/main/permission-grants/registry.ts | 507 +++++++++++++++ .../permission-grants/scope-liveness.test.ts | 79 +++ src/main/permission-grants/scope-liveness.ts | 20 + .../projects/deletion-coordinator.test.ts | 94 ++- src/main/projects/deletion-coordinator.ts | 23 +- src/main/projects/prisma-client.test.ts | 39 ++ src/main/projects/prisma-client.ts | 37 ++ .../artifact-alias-repair.test.ts | 72 +++ .../artifact-alias-repair.ts | 77 +++ .../session-persistence/coordinator.test.ts | 283 +++++++++ src/main/session-persistence/coordinator.ts | 29 +- src/main/settings/claude-config-provision.ts | 2 +- src/main/settings/ipc.test.ts | 28 +- src/main/settings/ipc.ts | 21 +- src/main/settings/repository.ts | 29 +- src/main/settings/service.connectors.test.ts | 103 ++++ src/main/settings/service.ts | 47 +- src/main/settings/types.ts | 10 +- src/preload/index.d.ts | 13 + src/preload/index.ts | 21 + src/renderer/src/App.test.tsx | 44 +- src/renderer/src/App.tsx | 24 +- src/renderer/src/assets/main.css | 1 + .../PermissionUndoSnackbar.test.tsx | 154 +++++ .../src/components/PermissionUndoSnackbar.tsx | 129 ++++ .../src/lib/acp/useAcpRuntime.test.ts | 20 + src/renderer/src/lib/acp/useAcpRuntime.ts | 20 +- .../src/lib/acp/useWorkspaceAgentRuntime.ts | 8 +- .../ComputeApprovalDialog.render.test.tsx | 24 +- .../pages/settings/ComputeApprovalDialog.tsx | 42 +- .../ConnectorApprovalDialog.render.test.tsx | 88 ++- .../settings/ConnectorApprovalDialog.tsx | 65 +- .../ConnectorDetailView.render.test.tsx | 53 +- .../pages/settings/ConnectorDetailView.tsx | 67 +- .../settings/PermissionsPanel.render.test.tsx | 260 ++++++++ .../src/pages/settings/PermissionsPanel.tsx | 281 +++++++++ .../src/pages/settings/SettingsLayout.tsx | 15 +- .../settings/SettingsPage.render.test.tsx | 83 ++- .../src/pages/settings/SettingsPage.tsx | 24 +- .../ToolPermissionControl.render.test.tsx | 6 +- .../pages/settings/ToolPermissionControl.tsx | 8 +- .../src/pages/settings/settings-navigation.ts | 1 + ...oserAgentControlsMenu.interaction.test.tsx | 18 +- .../workspace/ComposerAgentControlsMenu.tsx | 8 +- ...ssionApprovalControls.interaction.test.tsx | 316 +++++++++- ...PermissionApprovalControls.render.test.tsx | 11 +- .../workspace/PermissionApprovalControls.tsx | 148 ++++- .../PermissionScopeConfirmationDialog.tsx | 91 +++ .../WorkspaceMessageScroller.render.test.tsx | 51 ++ .../workspace/WorkspaceMessageScroller.tsx | 20 +- .../stores/permission-grants-store.test.ts | 344 +++++++++++ .../src/stores/permission-grants-store.ts | 342 +++++++++++ .../src/stores/settings-store.test.ts | 4 +- src/shared/compute.ts | 6 +- src/shared/permission-grants.ts | 141 +++++ src/shared/settings.ts | 8 +- src/shared/web-api-map.generated.ts | 4 + 106 files changed, 9974 insertions(+), 786 deletions(-) create mode 100644 src/main/acp/permission-broker-registry.test.ts create mode 100644 src/main/agent-framework/app-mcp-names.test.ts create mode 100644 src/main/compute/permission-grant-adapter.test.ts create mode 100644 src/main/compute/permission-grant-adapter.ts create mode 100644 src/main/permission-grants/capability.test.ts create mode 100644 src/main/permission-grants/capability.ts create mode 100644 src/main/permission-grants/catalog.test.ts create mode 100644 src/main/permission-grants/catalog.ts create mode 100644 src/main/permission-grants/connector-broker.test.ts create mode 100644 src/main/permission-grants/connector-broker.ts create mode 100644 src/main/permission-grants/identity-catalog.test.ts create mode 100644 src/main/permission-grants/identity-catalog.ts create mode 100644 src/main/permission-grants/ipc.test.ts create mode 100644 src/main/permission-grants/ipc.ts create mode 100644 src/main/permission-grants/reconciliation.test.ts create mode 100644 src/main/permission-grants/reconciliation.ts create mode 100644 src/main/permission-grants/registry.test.ts create mode 100644 src/main/permission-grants/registry.ts create mode 100644 src/main/permission-grants/scope-liveness.test.ts create mode 100644 src/main/permission-grants/scope-liveness.ts create mode 100644 src/main/session-persistence/artifact-alias-repair.test.ts create mode 100644 src/main/session-persistence/artifact-alias-repair.ts create mode 100644 src/renderer/src/components/PermissionUndoSnackbar.test.tsx create mode 100644 src/renderer/src/components/PermissionUndoSnackbar.tsx create mode 100644 src/renderer/src/pages/settings/PermissionsPanel.render.test.tsx create mode 100644 src/renderer/src/pages/settings/PermissionsPanel.tsx create mode 100644 src/renderer/src/pages/workspace/PermissionScopeConfirmationDialog.tsx create mode 100644 src/renderer/src/stores/permission-grants-store.test.ts create mode 100644 src/renderer/src/stores/permission-grants-store.ts create mode 100644 src/shared/permission-grants.ts diff --git a/docs/PRD.md b/docs/PRD.md index 7ff44b782..f20d3b356 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,7 +79,7 @@ Key implemented capabilities, mapped to the codebase: - **Notebook execution kernel.** One persistent Python process per notebook session, bridged over stdin/stdout, with per-run history (`run.json`) and write-locking to prevent concurrent corruption. - **Artifacts and provenance.** An in-process MCP server (`open-science-artifacts`) exposes a `write_artifact_file` tool the agent calls with either inline content or a local file path. Each save creates an immutable, session-scoped artifact version with available producer code, execution history, input references, environment inventory, message context, and reviewer evidence. - **File preview.** Renderers for CSV, FASTA, HTML, image, JSON, Markdown, and plain text, plus a read-only notebook preview showing code and execution output side by side, all inside a dedicated preview workbench. -- **Permissions.** An `AcpPermissionBroker` intercepts tool-call permission requests from the agent runtime and surfaces them to the renderer for explicit approval before the call proceeds. +- **Permissions.** An `AcpPermissionBroker` intercepts tool-call permission requests from the agent runtime, resolves matching app-owned remembered grants, and surfaces unmatched requests to the renderer for explicit approval before the call proceeds. - **Attachments.** File uploads are threaded into the agent's prompt context. ### Provenance Guarantee Level diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d27c9767c..1d4709d34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,25 @@ model Project { isExample Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + permissionGrants PermissionGrant[] +} + +model PermissionGrant { + id String @id + capabilityKind String + capabilityKey String + qualifierMode String @default("none") + qualifierValue String? + scopeKind String + projectId String? + sessionId String? + fingerprint String @unique + revision Int @default(1) + createdAt DateTime? + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([capabilityKind, capabilityKey, qualifierMode, qualifierValue, scopeKind, projectId, sessionId]) + @@index([projectId, sessionId]) } // Per-project preview panel state (panel open/collapsed, active tab, opened file previews as JSON). diff --git a/resources/notebook/repl_loop.js b/resources/notebook/repl_loop.js index bf27929ef..363c9ceea 100644 --- a/resources/notebook/repl_loop.js +++ b/resources/notebook/repl_loop.js @@ -923,7 +923,13 @@ async function hostMcp(server, method, args = undefined, kwargs = undefined) { // Without it the ConnectorService gate rejects the call with missing_session. body: JSON.stringify({ method: 'mcpCall', - params: { server, method, args: callArgs, sessionId: COMPUTE_SESSION_ID } + params: { + server, + method, + args: callArgs, + sessionId: COMPUTE_SESSION_ID, + ...(COMPUTE_PROJECT_NAME ? { projectId: COMPUTE_PROJECT_NAME } : {}) + } }) }) const body = await res.json().catch(() => ({})) diff --git a/src/main/acp/ipc.test.ts b/src/main/acp/ipc.test.ts index 55e4c00d1..3b515777e 100644 --- a/src/main/acp/ipc.test.ts +++ b/src/main/acp/ipc.test.ts @@ -124,6 +124,7 @@ const registerWithFakes = (overrides?: { onSessionCancellationRequested?: (sessionId: string) => void onSessionUnavailable?: (sessionId: string) => void onAllSessionsCancellationRequested?: () => void + beforeSessionDelete?: (sessionId: string) => Promise profileService?: { getById: (id: string) => Promise } specialistSkillCatalog?: Array<{ id: string; frameworkName: string; displayName: string }> provisionedConnectorSkillNames?: string[] @@ -156,6 +157,7 @@ const registerWithFakes = (overrides?: { onSessionCancellationRequested: overrides?.onSessionCancellationRequested, onSessionUnavailable: overrides?.onSessionUnavailable, onAllSessionsCancellationRequested: overrides?.onAllSessionsCancellationRequested, + beforeSessionDelete: overrides?.beforeSessionDelete, initializationBarrier: overrides?.initializationBarrier, profileService: overrides?.profileService as never } @@ -293,7 +295,12 @@ describe('registerAcpIpcHandlers — Skill import cancellation lifecycle', () => it('invalidates a stopped prompt and deleted session before runtime teardown starts', async () => { const onSessionCancellationRequested = vi.fn() const onSessionUnavailable = vi.fn() - registerWithFakes({ onSessionCancellationRequested, onSessionUnavailable }) + const beforeSessionDelete = vi.fn().mockResolvedValue(undefined) + registerWithFakes({ + onSessionCancellationRequested, + onSessionUnavailable, + beforeSessionDelete + }) await handlers.get('acp:cancel')?.({}, { sessionId: 'session-1' }) await handlers.get('acp:delete-session')?.({}, { sessionId: 'session-2' }) @@ -303,12 +310,16 @@ describe('registerAcpIpcHandlers — Skill import cancellation lifecycle', () => expect(onSessionCancellationRequested).toHaveBeenNthCalledWith(2, 'session-2') expect(onSessionUnavailable).toHaveBeenCalledOnce() expect(onSessionUnavailable).toHaveBeenCalledWith('session-2') + expect(beforeSessionDelete).toHaveBeenCalledWith('session-2') expect(cancelPrompt).toHaveBeenCalledWith({ sessionId: 'session-1' }) expect(deleteSession).toHaveBeenCalledWith({ sessionId: 'session-2' }) expect(onSessionCancellationRequested.mock.invocationCallOrder[0]).toBeLessThan( cancelPrompt.mock.invocationCallOrder[0] ) expect(onSessionCancellationRequested.mock.invocationCallOrder[1]).toBeLessThan( + beforeSessionDelete.mock.invocationCallOrder[0] + ) + expect(beforeSessionDelete.mock.invocationCallOrder[0]).toBeLessThan( deleteSession.mock.invocationCallOrder[0] ) expect(deleteSession.mock.invocationCallOrder[0]).toBeLessThan( @@ -316,6 +327,19 @@ describe('registerAcpIpcHandlers — Skill import cancellation lifecycle', () => ) }) + it('keeps the agent session when notebook teardown fails', async () => { + const onSessionUnavailable = vi.fn() + const beforeSessionDelete = vi.fn().mockRejectedValue(new Error('notebook shutdown failed')) + registerWithFakes({ onSessionUnavailable, beforeSessionDelete }) + + await expect( + handlers.get('acp:delete-session')?.({}, { sessionId: 'session-2' }) + ).rejects.toThrow('notebook shutdown failed') + + expect(deleteSession).not.toHaveBeenCalled() + expect(onSessionUnavailable).not.toHaveBeenCalled() + }) + it('keeps pending imports invalidated when prompt or session teardown fails', async () => { const onSessionCancellationRequested = vi.fn() const onSessionUnavailable = vi.fn() diff --git a/src/main/acp/ipc.ts b/src/main/acp/ipc.ts index 7e76b9c28..59b4c0d5f 100644 --- a/src/main/acp/ipc.ts +++ b/src/main/acp/ipc.ts @@ -40,6 +40,8 @@ import { NotebookRuntimeService } from '../notebook/runtime-service' import { resolveConfigRoot, resolveDataRoot } from '../storage-root' import type { SettingsService } from '../settings/service' import type { UploadRepository } from '../uploads/repository' +import type { PermissionGrantRegistry } from '../permission-grants/registry' +import { projectRegistrySessionGrants } from './permission-broker' import { broadcastToRenderers } from '../renderer-broadcast' import { withDataRootWrite } from '../storage/migration-state' import { createLogger, errorLogFields } from '../logger' @@ -76,6 +78,7 @@ type AcpIpcOptions = AcpIpcArtifacts & { ) => Promise<() => void> // Drives the agent spawn env from the active provider so switching takes effect on reconnect. settingsService: SettingsService + permissionGrantRegistry?: PermissionGrantRegistry initializationBarrier?: Promise // Observes prompt starts and terminal turn events for desktop notifications. Optional so tests // and headless setups can run without a notification surface. @@ -90,6 +93,7 @@ type AcpIpcOptions = AcpIpcArtifacts & { onSessionCancellationRequested?: (sessionId: string) => void onSessionUnavailable?: (sessionId: string) => void onAllSessionsCancellationRequested?: () => void + beforeSessionDelete?: (sessionId: string) => Promise // Provides fresh Specialist Profiles for first-turn identity injection. Optional so existing // tests and headless setups that construct the runtime without specialist support are unaffected. profileService?: ProfileService @@ -120,6 +124,7 @@ const createRuntime = ({ notebookRpcServer, authorizeSkillImportReferencedUploads, settingsService, + permissionGrantRegistry, initializationBarrier, taskNotifications, onSessionTurnStarted, @@ -128,6 +133,7 @@ const createRuntime = ({ onSessionCancellationRequested, onSessionUnavailable, onAllSessionsCancellationRequested, + beforeSessionDelete, profileService }: AcpIpcOptions): AcpRuntimeCoordinator => { const configRoot = resolveConfigRoot() @@ -194,9 +200,12 @@ const createRuntime = ({ notebook: { projectName: DEFAULT_ARTIFACT_PROJECT_NAME, mcpEntryPath, - getRpcConnection: () => notebookRpcServer.ensureStarted(), + getRpcConnection: ({ sessionId, projectId }) => + notebookRpcServer.issueSessionConnection(sessionId, projectId), registerSessionAlias: (aliasSessionId, sessionId) => notebookRpcServer.registerSessionAlias(aliasSessionId, sessionId), + releaseSessionCapabilities: (sessionId) => + notebookRpcServer.releaseSessionCapabilities(sessionId), registerSessionSpecialist: (sessionId, specialistId) => notebookRpcServer.registerSessionSpecialist(sessionId, specialistId), setArtifactProvenanceContext: (sessionId, context) => @@ -214,6 +223,7 @@ const createRuntime = ({ activityGroups: { mcpEntryPath }, callbacks: runtimeCallbacks, permissionGrantStore, + permissionGrantRegistry, // Main process resolves the latest Profile so the renderer never needs to send systemPrompt. resolveSpecialistIdentity: profileService ? async (specialistId: string, frameworkId: string) => { @@ -279,8 +289,12 @@ const createRuntime = ({ onSessionTurnEnded, onSkillImportAttachmentEligible, onSessionCancellationRequested, - onAllSessionsCancellationRequested - } + onAllSessionsCancellationRequested, + beforeSessionDelete + }, + permissionGrantRegistry + ? () => projectRegistrySessionGrants(permissionGrantRegistry.listCached()) + : undefined ) } diff --git a/src/main/acp/permission-broker-registry.test.ts b/src/main/acp/permission-broker-registry.test.ts new file mode 100644 index 000000000..83466faa5 --- /dev/null +++ b/src/main/acp/permission-broker-registry.test.ts @@ -0,0 +1,576 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { RequestPermissionRequest } from '@agentclientprotocol/sdk' +import type { PrismaClient } from '@prisma/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + createPermissionGrantRegistry, + type PermissionGrantRegistry +} from '../permission-grants/registry' +import { createProjectDbClient, ensureProjectSchema } from '../projects/prisma-client' +import { AcpPermissionBroker, projectRegistrySessionGrants } from './permission-broker' +import { withTrustedMcpToolIdentity } from './permission-policy' + +let storageRoot: string | undefined +let client: PrismaClient | undefined + +afterEach(async () => { + await client?.$disconnect() + client = undefined + if (storageRoot) await rm(storageRoot, { recursive: true, force: true }) + storageRoot = undefined +}) + +const shellRequest = (sessionId: string): RequestPermissionRequest => ({ + sessionId, + toolCall: { + toolCallId: `tool-${sessionId}`, + title: 'Inspect repository status', + status: 'pending', + kind: 'execute', + rawInput: { command: 'git status' } + }, + options: [ + { optionId: 'provider-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'provider-allow-always', name: 'Always', kind: 'allow_always' }, + { optionId: 'provider-reject-once', name: 'Reject', kind: 'reject_once' } + ] +}) + +const secretShellRequest = (sessionId: string): RequestPermissionRequest => ({ + ...shellRequest(sessionId), + toolCall: { + ...shellRequest(sessionId).toolCall, + rawInput: { command: 'TOKEN=secret python upload.py' } + } +}) + +const registeredToolRequest = (toolName: string): RequestPermissionRequest => ({ + sessionId: 'session-registered', + toolCall: { + toolCallId: `tool-${toolName}`, + title: toolName, + status: 'pending', + _meta: { toolName } + }, + options: [ + { optionId: 'provider-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'provider-reject-once', name: 'Reject', kind: 'reject_once' } + ] +}) + +const titleOnlyRequest = (title: string): RequestPermissionRequest => ({ + sessionId: `session-title-${title}`, + toolCall: { + toolCallId: `tool-title-${title}`, + title, + status: 'pending' + }, + options: [ + { optionId: 'provider-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'provider-reject-once', name: 'Reject', kind: 'reject_once' } + ] +}) + +const providerBuiltInRequest = ( + sessionId: string, + toolName: 'WebFetch' | 'WebSearch' +): RequestPermissionRequest => ({ + sessionId, + toolCall: { + toolCallId: `tool-${sessionId}-${toolName}`, + title: toolName, + status: 'pending', + rawInput: + toolName === 'WebFetch' + ? { url: 'https://www.ncbi.nlm.nih.gov/' } + : { query: 'tumor immunology' }, + _meta: { claudeCode: { toolName } } + }, + options: [ + { optionId: 'provider-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'provider-allow-always', name: 'Always', kind: 'allow_always' }, + { optionId: 'provider-reject-once', name: 'Reject', kind: 'reject_once' } + ] +}) + +const mcpRequest = ( + sessionId: string, + reportedName: string, + title = reportedName +): RequestPermissionRequest => ({ + sessionId, + toolCall: { + toolCallId: `tool-${sessionId}`, + title, + status: 'pending', + _meta: { toolName: reportedName }, + rawInput: {} + }, + options: [ + { optionId: 'provider-allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'provider-reject-once', name: 'Reject', kind: 'reject_once' } + ] +}) + +describe('ACP permission broker with durable grants', () => { + it('cancels a request while its durable grant lookup is still pending', async () => { + let finishResolve: (() => void) | undefined + const registry = { + resolve: vi.fn( + () => + new Promise((resolve) => { + finishResolve = () => resolve(undefined) + }) + ), + remember: vi.fn(), + list: vi.fn().mockResolvedValue([]), + listCached: vi.fn().mockReturnValue([]), + revoke: vi.fn(), + restore: vi.fn(), + prune: vi.fn(), + finalizeOwnerDeletion: vi.fn(), + subscribe: vi.fn().mockReturnValue(() => undefined) + } satisfies PermissionGrantRegistry + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + + const response = broker.requestPermission(shellRequest('session-1'), { + profile: 'ask', + projectId: 'project-1' + }) + broker.cancelForSession('session-1') + finishResolve?.() + + await expect(response).resolves.toEqual({ outcome: { outcome: 'cancelled' } }) + expect(emitted).toEqual([]) + expect(broker.hasPendingForSession('session-1')).toBe(false) + }) + + it('fails closed and reports when a remembered approval cannot be persisted', async () => { + const registry = { + resolve: vi.fn().mockResolvedValue(undefined), + remember: vi.fn().mockRejectedValue(new Error('database locked')), + list: vi.fn().mockResolvedValue([]), + listCached: vi.fn().mockReturnValue([]), + revoke: vi.fn(), + restore: vi.fn(), + prune: vi.fn(), + finalizeOwnerDeletion: vi.fn(), + subscribe: vi.fn().mockReturnValue(() => undefined) + } satisfies PermissionGrantRegistry + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const providerResponse = broker.requestPermission(shellRequest('session-1'), { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + + const projectOption = emitted[0].options.find((option) => option.scope === 'project') + const rendererResponse = broker.respond({ + requestId: emitted[0].requestId, + optionId: projectOption?.optionId + }) + + await expect(rendererResponse).rejects.toThrow( + 'Permission approval could not be saved; the tool call was cancelled.' + ) + await expect(providerResponse).resolves.toEqual({ outcome: { outcome: 'cancelled' } }) + }) + + it('commits a Global grant before returning only the provider one-call decision', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-registry-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + + const first = broker.requestPermission(shellRequest('session-1'), { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual([ + 'once', + 'session', + 'project', + 'global' + ]) + const globalOption = emitted[0].options.find((option) => option.scope === 'global') + broker.respond({ requestId: emitted[0].requestId, optionId: globalOption?.optionId }) + + await expect(first).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + const [grant] = await registry.list() + expect(grant).toMatchObject({ + capability: { + kind: 'execution', + key: 'exec:agent/shell', + qualifier: { mode: 'exact', value: expect.stringMatching(/^sha256:v1:[a-f0-9]{64}$/) } + }, + scope: { kind: 'global' } + }) + expect(JSON.stringify(grant)).not.toContain('git status') + + await expect( + broker.requestPermission(shellRequest('session-2'), { + profile: 'ask', + projectId: 'project-1' + }) + ).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'provider-allow-once' } }) + expect(emitted).toHaveLength(1) + }) + + it('offers only provider Once for a secret-bearing exact request', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-secret-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + + const pending = broker.requestPermission(secretShellRequest('session-1'), { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + broker.respond({ requestId: emitted[0].requestId, optionId: 'provider-allow-once' }) + await expect(pending).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + await expect(registry.list()).resolves.toEqual([]) + }) + + it('offers only provider Once for a command that executes a mutable script', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-mutable-script-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const request = shellRequest('session-1') + request.toolCall.rawInput = { command: 'python analyze.py --input data.csv' } + + const pending = broker.requestPermission(request, { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + broker.respond({ requestId: emitted[0].requestId, optionId: 'provider-allow-once' }) + await expect(pending).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + await expect(registry.list()).resolves.toEqual([]) + }) + + it.each(['WebFetch', 'WebSearch'] as const)( + 'keeps provider-native %s Once-only and prompts again on the next call', + async (toolName) => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-built-in-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker( + (request) => emitted.push(request), + undefined, + registry + ) + const context = { profile: 'ask' as const, projectId: 'project-1' } + + const first = broker.requestPermission( + providerBuiltInRequest('session-built-in', toolName), + context + ) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + await broker.respond({ + requestId: emitted[0].requestId, + optionId: 'provider-allow-once' + }) + await expect(first).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + + const second = broker.requestPermission( + providerBuiltInRequest('session-built-in', toolName), + context + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(emitted).toHaveLength(2) + await broker.respond({ requestId: emitted[1].requestId, cancelled: true }) + await expect(second).resolves.toEqual({ outcome: { outcome: 'cancelled' } }) + await expect(registry.list()).resolves.toEqual([]) + } + ) + + it.each(['agent_create', 'Skill', 'mcp__open_science_notebook__notebook_execute'])( + 'never creates durable authority from the display-only title %s', + async (title) => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-title-only-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker( + (request) => emitted.push(request), + undefined, + registry + ) + + const pending = broker.requestPermission(titleOnlyRequest(title), { + profile: 'ask', + projectId: 'project-1', + mcpServerNames: ['open_science_notebook'] + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + broker.respond({ + requestId: emitted[0].requestId, + optionId: 'provider-allow-once' + }) + await expect(pending).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + await expect(registry.list()).resolves.toEqual([]) + } + ) + + it('routes every registered customization and local executor identity into durable scopes', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-registered-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const toolNames = [ + 'agent_create', + 'agent_update', + 'skill_publish', + 'skill_edit', + 'agent_attach_skill', + 'agent_detach_skill', + 'agent_attach_connector', + 'agent_detach_connector', + 'local_exec_python', + 'local_exec_bash' + ] + + for (const toolName of toolNames) { + const pending = broker.requestPermission(registeredToolRequest(toolName), { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + const request = emitted.at(-1)! + broker.respond({ + requestId: request.requestId, + optionId: request.options.find((option) => option.scope === 'global')?.optionId + }) + await expect(pending).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + } + + await expect(registry.list()).resolves.toHaveLength(10) + expect((await registry.list()).map((grant) => grant.capability.key).sort()).toEqual( + [ + 'customize:agent_create', + 'customize:agent_update', + 'customize:skill_publish', + 'customize:skill_edit', + 'customize:agent_attach_skill', + 'customize:agent_detach_skill', + 'customize:agent_attach_connector', + 'customize:agent_detach_connector', + 'exec:local/python', + 'exec:local/bash' + ].sort() + ) + }) + + it('reuses one app MCP grant across Claude Code, Codex, OpenCode, and runtime-trusted sparse requests', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-acp-mcp-aliases-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const context = { + profile: 'ask' as const, + projectId: 'project-1', + mcpServerNames: ['open-science-notebook'] + } + + const first = broker.requestPermission( + mcpRequest('session-claude', 'mcp__open_science_notebook__manage_packages'), + context + ) + await new Promise((resolve) => setImmediate(resolve)) + broker.respond({ + requestId: emitted[0].requestId, + optionId: emitted[0].options.find((option) => option.scope === 'global')?.optionId + }) + await expect(first).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + + for (const [sessionId, reportedName] of [ + ['session-codex', 'mcp.open-science-notebook.manage_packages'], + ['session-opencode', 'open_science_notebook_manage_packages'] + ] as const) { + await expect( + broker.requestPermission(mcpRequest(sessionId, reportedName), context) + ).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'provider-allow-once' } }) + } + + await expect( + broker.requestPermission( + withTrustedMcpToolIdentity( + mcpRequest('session-sparse', 'manage_packages', 'Manage packages'), + 'open-science-notebook/manage_packages' + ), + context + ) + ).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'provider-allow-once' } }) + + expect(emitted).toHaveLength(1) + await expect(registry.list()).resolves.toEqual([ + expect.objectContaining({ + capability: { + kind: 'mcp_tool', + key: 'mcp:open-science-notebook/manage_packages' + }, + scope: { kind: 'global' } + }) + ]) + }) + + it('uses runtime-trusted identity to align sparse dynamic MCP requests', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-dynamic-mcp-aliases-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const context = { + profile: 'ask' as const, + projectId: 'project-1', + mcpServerNames: ['custom-server'] + } + + const first = broker.requestPermission( + mcpRequest('session-claude', 'mcp__custom_server__lookup'), + context + ) + await new Promise((resolve) => setImmediate(resolve)) + broker.respond({ + requestId: emitted[0].requestId, + optionId: emitted[0].options.find((option) => option.scope === 'global')?.optionId + }) + await first + + const sparseCodex = withTrustedMcpToolIdentity( + mcpRequest('session-codex', 'lookup', 'Lookup records'), + 'custom-server/lookup' + ) + await expect(broker.requestPermission(sparseCodex, context)).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + await expect( + broker.requestPermission(mcpRequest('session-opencode', 'custom_server_lookup'), context) + ).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'provider-allow-once' } + }) + + expect(emitted).toHaveLength(1) + expect((await registry.list())[0].capability).toEqual({ + kind: 'mcp_tool', + key: 'mcp:custom-server/lookup' + }) + }) + + it('rejects a trusted MCP identity outside the configured server set', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-mismatched-mcp-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + const pending = broker.requestPermission( + withTrustedMcpToolIdentity( + mcpRequest('session-mismatch', 'lookup', 'Lookup records'), + 'other-server/lookup' + ), + { + profile: 'ask', + projectId: 'project-1', + mcpServerNames: ['custom-server'] + } + ) + await new Promise((resolve) => setImmediate(resolve)) + + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + broker.respond({ requestId: emitted[0].requestId, optionId: 'provider-allow-once' }) + await pending + await expect(registry.list()).resolves.toEqual([]) + }) + + it('projects and revokes a durable Session grant through the composer seam', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-broker-composer-')) + client = createProjectDbClient(storageRoot) + await ensureProjectSchema(client) + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client! }) + const emitted: Parameters[0]>[0][] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request), undefined, registry) + + const pending = broker.requestPermission(shellRequest('session-1'), { + profile: 'ask', + projectId: 'project-1' + }) + await new Promise((resolve) => setImmediate(resolve)) + broker.respond({ + requestId: emitted[0].requestId, + optionId: emitted[0].options.find((option) => option.scope === 'session')?.optionId + }) + await pending + + const [composerGrant] = broker.listGrants('session-1') + expect(composerGrant).toMatchObject({ + categoryKey: expect.any(String), + kind: 'shell', + label: 'Shell · Specific input', + scope: 'session' + }) + expect(projectRegistrySessionGrants(await registry.list())).toEqual({ + 'session-1': [composerGrant] + }) + await broker.revokeGrant('session-1', composerGrant.categoryKey) + + expect(broker.listGrants('session-1')).toEqual([]) + await expect(registry.list()).resolves.toEqual([]) + }) +}) diff --git a/src/main/acp/permission-broker.test.ts b/src/main/acp/permission-broker.test.ts index 14a1d6783..9fab40ff3 100644 --- a/src/main/acp/permission-broker.test.ts +++ b/src/main/acp/permission-broker.test.ts @@ -2,6 +2,7 @@ import type { RequestPermissionRequest } from '@agentclientprotocol/sdk' import { describe, expect, it } from 'vitest' import { AcpPermissionBroker, ConversationPermissionGrantStore } from './permission-broker' +import { withTrustedNativeToolIdentity } from './permission-policy' type EmittedPermissionRequest = Parameters[0]>[0] @@ -139,7 +140,7 @@ describe('ACP permission broker', () => { ) const sessionOption = emitted[0].options.find((option) => option.scope === 'session') - expect(sessionOption).toMatchObject({ name: 'This conversation', scope: 'session' }) + expect(sessionOption).toMatchObject({ name: 'This session', scope: 'session' }) expect(emitted[0].options.some((option) => option.optionId === 'allow-always')).toBe(false) broker.respond({ requestId: emitted[0].requestId, optionId: sessionOption?.optionId }) @@ -210,42 +211,44 @@ describe('ACP permission broker', () => { ]) }) - it('remembers an OpenCode Skill grant when its permission title is the only stable identity', async () => { + it('silently allows OpenCode native skill loading without remembering a grant', async () => { const emitted: EmittedPermissionRequest[] = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) const skillRequest = (): RequestPermissionRequest => - createToolPermissionRequest({ title: 'Run skill?', rawInput: {} }) + withTrustedNativeToolIdentity( + createToolPermissionRequest({ title: 'skill', kind: 'other', rawInput: {} }), + 'opencode/skill' + ) - const firstResponse = broker.requestPermission(skillRequest(), { + const response = broker.requestPermission(skillRequest(), { profile: 'ask', frameworkId: 'opencode' }) - - expect(emitted[0].options.find((option) => option.scope === 'session')).toMatchObject({ - name: 'This conversation', - kind: 'allow_always' - }) - broker.respond({ requestId: emitted[0].requestId, optionId: getSessionOptionId(emitted[0]) }) - - await expect(firstResponse).resolves.toEqual({ + expect(emitted).toEqual([]) + await expect(response).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) - await expect( - broker.requestPermission(skillRequest(), { - profile: 'ask', - frameworkId: 'opencode' - }) - ).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + expect(broker.listGrants('session-1')).toEqual([]) + }) - expect(emitted).toHaveLength(1) - expect(broker.listGrants('session-1')).toEqual([ - { - categoryKey: 'skill', - kind: 'tool', - label: 'Skill', - scope: 'session' - } - ]) + it('keeps non-OpenCode and unknown OpenCode tools on the normal approval path', () => { + const emitted: EmittedPermissionRequest[] = [] + const broker = new AcpPermissionBroker((request) => emitted.push(request)) + + void broker.requestPermission( + createToolPermissionRequest({ title: 'skill', kind: 'other', rawInput: {} }), + { profile: 'ask', frameworkId: 'claude-code' } + ) + void broker.requestPermission( + createToolPermissionRequest({ title: 'unknown capability', kind: 'other', rawInput: {} }), + { profile: 'ask', frameworkId: 'opencode' } + ) + void broker.requestPermission( + createToolPermissionRequest({ title: 'skill', kind: 'other', rawInput: {} }), + { profile: 'ask', frameworkId: 'opencode' } + ) + + expect(emitted).toHaveLength(3) }) it('requires a stable server and tool identity before offering MCP session scope', () => { @@ -444,7 +447,7 @@ describe('ACP permission broker', () => { getSessionOptionId(emitted[0]) ]) expect(emitted[0].options.find((option) => option.scope === 'session')).toMatchObject({ - name: 'This conversation', + name: 'This session', kind: 'allow_always' }) }) @@ -497,7 +500,7 @@ describe('ACP permission broker', () => { ]) }) - it('does not auto-select a Codex amendment under Full Access when it is the only allow option', () => { + it('fails closed without prompting when the provider has no one-call allow option', async () => { const emitted: Array[0]>[0]> = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) const request = createCodexCommandPermissionRequest() @@ -506,12 +509,10 @@ describe('ACP permission broker', () => { (option) => option.optionId !== 'allow_once' && option.optionId !== 'allow_always' ) - void broker.requestPermission(request, { profile: 'full', frameworkId: 'codex' }) + const response = broker.requestPermission(request, { profile: 'full', frameworkId: 'codex' }) - // The amendment is projected away, so Full Access finds no allow option and must prompt instead - // of auto-approving a grant that persists outside the app's revocable model. - expect(emitted).toHaveLength(1) - expect(emitted[0].options.map((option) => option.optionId)).toEqual(['reject_once']) + await expect(response).resolves.toEqual({ outcome: { outcome: 'cancelled' } }) + expect(emitted).toEqual([]) }) it('cancels a Codex policy amendment response that was not exposed', async () => { @@ -560,7 +561,7 @@ describe('ACP permission broker', () => { expect(broker.listGrants('session-1')).toEqual([expect.objectContaining({ scope: 'session' })]) }) - it('auto-approves later notebook calls after the user picks This conversation', async () => { + it('auto-approves later notebook calls after the user picks This session', async () => { const emitted: EmittedPermissionRequest[] = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) @@ -778,7 +779,7 @@ describe('ACP permission broker', () => { ]) expect(emitted[1]).toMatchObject({ title: 'Run Python again' }) expect(emitted[1].options).toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'This conversation' })]) + expect.arrayContaining([expect.objectContaining({ name: 'This session' })]) ) expect(emitted[2]).toMatchObject({ title: 'Remove build output' }) expect(emitted).toHaveLength(3) @@ -1106,7 +1107,7 @@ describe('ACP permission broker', () => { expect(emittedRequests).toHaveLength(2) }) - it('does not remember session grants across Agent sessions', async () => { + it('keeps WebFetch Once-only across Agent sessions', async () => { const emitted: EmittedPermissionRequest[] = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) @@ -1117,11 +1118,11 @@ describe('ACP permission broker', () => { rawInput: { url: 'https://www.ncbi.nlm.nih.gov/' } }) ) - broker.respond({ requestId: emitted[0].requestId, optionId: getSessionOptionId(emitted[0]) }) + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + broker.respond({ requestId: emitted[0].requestId, optionId: 'allow-once' }) await firstFetch - // The same category in a different session must still prompt. - broker.requestPermission( + const secondFetch = broker.requestPermission( createToolPermissionRequest({ sessionId: 'session-2', providerToolName: 'WebFetch', @@ -1129,9 +1130,11 @@ describe('ACP permission broker', () => { }) ) expect(emitted).toHaveLength(2) + broker.respond({ requestId: emitted[1].requestId, cancelled: true }) + await secondFetch }) - it('shares conversation grants across runtime brokers and clears them on deletion', async () => { + it('does not share WebFetch grants across runtime brokers', async () => { const store = new ConversationPermissionGrantStore() const firstEmitted: EmittedPermissionRequest[] = [] const secondEmitted: EmittedPermissionRequest[] = [] @@ -1146,29 +1149,19 @@ describe('ACP permission broker', () => { const firstPermission = firstBroker.requestPermission(request) firstBroker.respond({ requestId: firstEmitted[0].requestId, - optionId: getSessionOptionId(firstEmitted[0]) + optionId: 'allow-once' }) await firstPermission - await expect(secondBroker.requestPermission(request)).resolves.toEqual({ - outcome: { outcome: 'selected', optionId: 'allow-once' } - }) - expect(secondEmitted).toHaveLength(0) - expect(secondBroker.listGrants('shared-conversation')).toEqual([ - expect.objectContaining({ - categoryKey: 'webfetch:www.ncbi.nlm.nih.gov', - label: 'WebFetch (www.ncbi.nlm.nih.gov)', - scope: 'session' - }) - ]) - - secondBroker.clearSession('shared-conversation') + const secondPermission = secondBroker.requestPermission(request) + expect(secondEmitted).toHaveLength(1) + expect(secondBroker.listGrants('shared-conversation')).toEqual([]) expect(firstBroker.listGrants('shared-conversation')).toEqual([]) - void firstBroker.requestPermission(request) - expect(firstEmitted).toHaveLength(2) + secondBroker.respond({ requestId: secondEmitted[0].requestId, cancelled: true }) + await secondPermission }) - it('scopes WebFetch conversation grants to the approved hostname', async () => { + it('prompts for every WebFetch call regardless of hostname', async () => { const emitted: EmittedPermissionRequest[] = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) const firstRequest = createToolPermissionRequest({ @@ -1179,27 +1172,29 @@ describe('ACP permission broker', () => { const firstPermission = broker.requestPermission(firstRequest) broker.respond({ requestId: emitted[0].requestId, - optionId: getSessionOptionId(emitted[0]) + optionId: 'allow-once' }) await firstPermission - await expect( - broker.requestPermission( - createToolPermissionRequest({ - providerToolName: 'WebFetch', - rawInput: { url: 'https://www.ncbi.nlm.nih.gov/research/' } - }) - ) - ).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) - expect(emitted).toHaveLength(1) + const sameHostname = broker.requestPermission( + createToolPermissionRequest({ + providerToolName: 'WebFetch', + rawInput: { url: 'https://www.ncbi.nlm.nih.gov/research/' } + }) + ) + expect(emitted).toHaveLength(2) + broker.respond({ requestId: emitted[1].requestId, cancelled: true }) + await sameHostname - void broker.requestPermission( + const otherHostname = broker.requestPermission( createToolPermissionRequest({ providerToolName: 'WebFetch', rawInput: { url: 'https://example.com/' } }) ) - expect(emitted).toHaveLength(2) + expect(emitted).toHaveLength(3) + broker.respond({ requestId: emitted[2].requestId, cancelled: true }) + await otherHostname }) it('offers only one-shot approval when a WebFetch hostname cannot be verified', () => { @@ -1213,7 +1208,7 @@ describe('ACP permission broker', () => { expect(emitted[0].options).not.toContainEqual(expect.objectContaining({ scope: 'session' })) }) - it('scopes WebFetch grants from the adapter title when raw input is absent', async () => { + it('keeps title-derived WebFetch requests Once-only', async () => { const emitted: EmittedPermissionRequest[] = [] const broker = new AcpPermissionBroker((request) => emitted.push(request)) @@ -1226,14 +1221,13 @@ describe('ACP permission broker', () => { broker.respond({ requestId: emitted[0].requestId, - optionId: getSessionOptionId(emitted[0]) + optionId: 'allow-once' }) await permission expect(emitted[0].title).toBe('Fetch https://www.ncbi.nlm.nih.gov/research/') - expect(broker.listGrants('session-1')).toEqual([ - expect.objectContaining({ categoryKey: 'webfetch:www.ncbi.nlm.nih.gov' }) - ]) + expect(emitted[0].options.map((option) => option.scope).filter(Boolean)).toEqual(['once']) + expect(broker.listGrants('session-1')).toEqual([]) }) it('shares MCP grants across hyphenated and underscore-sanitized framework identities', async () => { diff --git a/src/main/acp/permission-broker.ts b/src/main/acp/permission-broker.ts index 090871150..c839c0b8b 100644 --- a/src/main/acp/permission-broker.ts +++ b/src/main/acp/permission-broker.ts @@ -6,18 +6,35 @@ import type { AcpPermissionRequest, AcpPermissionResponse } from '../../shared/acp' +import type { + PermissionCapability, + PermissionGrantRecord, + PermissionGrantScope +} from '../../shared/permission-grants' import { extractProviderToolName } from './runtime-events' import { isMcpToolName, resolveMcpProviderLeafIdentity, resolveAutomaticPermission, + trustedMcpToolIdentity, type PermissionPolicyContext } from './permission-policy' -import { resolveCanonicalMcpToolIdentity } from '../agent-framework/app-mcp-names' +import { + canonicalAppMcpServerName, + resolveCanonicalMcpToolIdentity +} from '../agent-framework/app-mcp-names' +import { + capabilityFromLegacyCategory, + categoryFromTrustedToolName +} from '../permission-grants/capability' +import { projectPermissionGrantSnapshot } from '../permission-grants/catalog' +import type { PermissionGrantRegistry } from '../permission-grants/registry' type PendingPermission = { request: AcpPermissionRequest categoryKey?: string + capability?: PermissionCapability + projectId?: string providerAllowOnceOptionId?: string resolve: (response: RequestPermissionResponse) => void } @@ -65,6 +82,8 @@ const ALLOW_ALWAYS_OPTION_KIND = 'allow_always' const ALLOW_ONCE_OPTION_KIND = 'allow_once' const REJECT_ALWAYS_OPTION_KIND = 'reject_always' const SESSION_ALLOW_OPTION_ID_PREFIX = 'open-science:allow-session:' +const PROJECT_ALLOW_OPTION_ID_PREFIX = 'open-science:allow-project:' +const GLOBAL_ALLOW_OPTION_ID_PREFIX = 'open-science:allow-global:' const FILE_TOOL_KINDS = new Set(['read', 'edit', 'delete', 'move']) const FILE_PROVIDER_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit']) const NOTEBOOK_SERVER = 'open-science-notebook' @@ -110,8 +129,29 @@ const resolveMcpToolIdentity = ( resolveCanonicalMcpToolIdentity(name, mcpServerNames) ?? resolveMcpProviderLeafIdentity(name, mcpServerNames) +const resolveTrustedMcpToolIdentity = ( + params: RequestPermissionRequest, + mcpServerNames: readonly string[] +): string | undefined => { + const identity = trustedMcpToolIdentity(params) + const separator = identity?.indexOf('/') ?? -1 + if (!identity || separator <= 0 || separator === identity.length - 1) return undefined + + const server = canonicalAppMcpServerName(identity.slice(0, separator)) + const configuredServers = new Set(mcpServerNames.map(canonicalAppMcpServerName)) + if (!configuredServers.has(server)) return undefined + + return `${server}/${identity.slice(separator + 1)}` +} + const commandSignature = (command: string): string => command.trim() +const resolveLegacyClaudeMcpIdentity = (name: string | null | undefined): string | undefined => { + if (!name?.startsWith('mcp__')) return undefined + const [server, ...toolParts] = name.slice('mcp__'.length).split('__') + return server && toolParts.length > 0 ? `${server}/${toolParts.join('__')}` : undefined +} + const resolveShellCommand = (params: RequestPermissionRequest): string | undefined => commandFromRawInput(params.toolCall.rawInput)?.trim() @@ -126,14 +166,16 @@ const recordInput = (rawInput: unknown): Record | undefined => : record } -// OpenCode's ACP permission request for its native Skill tool may be identified only by its title -// (currently `Run skill?`). Skill metadata is not stable across ACP versions, so conversation -// approval is deliberately scoped to the native Skill capability rather than an optional input name. -const isSkillPermission = (params: RequestPermissionRequest): boolean => { - const title = params.toolCall.title?.trim().toLowerCase() +// Durable Skill identity requires provider metadata. Display-only titles are accepted only by the +// legacy in-memory broker, where they cannot create persistent authority. +const isSkillPermission = ( + params: RequestPermissionRequest, + allowLegacyDisplayIdentity = false +): boolean => { const providerToolName = extractProviderToolName(params.toolCall)?.trim().toLowerCase() - return providerToolName === 'skill' || title === 'skill' || /^run\s+skill\??$/.test(title ?? '') + if (providerToolName === 'skill') return true + return allowLegacyDisplayIdentity && /\bskill\b/i.test(params.toolCall.title ?? '') } const normalizeNotebookRuntime = (value: string): string | undefined => { @@ -193,38 +235,26 @@ const resolveNotebookPermissionContext = ( const identity = resolveMcpToolIdentity(name, mcpServerNames) if (!identity) return undefined + return resolveNotebookPermissionContextForIdentity(identity, rawInput) +} + +const resolveNotebookPermissionContextForIdentity = ( + identity: string, + rawInput: unknown +): { runtime?: string } | undefined => { const tool = resolveNotebookExecutionTool(identity) if (!tool) return undefined return { runtime: resolveNotebookRuntime(tool, rawInput) } } -// WebFetch bypasses Claude's remote hostname preflight for custom API-key providers. Force any -// conversation-wide approval to stay on the exact hostname the user reviewed; an absent or malformed -// URL deliberately yields no category, so the broker offers only the provider's one-shot approval. -const resolveWebFetchHostname = (params: RequestPermissionRequest): string | undefined => { - if (extractProviderToolName(params.toolCall) !== 'WebFetch') return undefined - - const input = recordInput(params.toolCall.rawInput) - const inputUrl = typeof input?.url === 'string' ? input.url.trim() : undefined - const titleUrl = params.toolCall.title?.match(/^Fetch\s+(https?:\/\/\S+)$/i)?.[1] - const candidate = inputUrl || titleUrl - if (!candidate) return undefined - - try { - const url = new URL(candidate) - return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname : undefined - } catch { - return undefined - } -} - const isMcpPermission = ( params: RequestPermissionRequest, mcpServerNames: readonly string[] ): boolean => { const providerToolName = extractProviderToolName(params.toolCall) return ( + resolveTrustedMcpToolIdentity(params, mcpServerNames) != null || isMcpToolName(params.toolCall.title, mcpServerNames) || isMcpToolName(providerToolName, mcpServerNames) ) @@ -269,29 +299,42 @@ const projectPermissionOptions = ( // 1. MCP tool (recognized across frameworks — Claude's mcp__ prefix or an opencode _ name): // keyed by tool identity, with notebook execution tools further separated by runtime. // 2. Native Skill tool: keyed by the stable provider capability. -// 3. WebFetch: keyed by the exact parsed hostname; malformed/missing URLs cannot receive a grant. -// 4. Shell/execute tool (provider tool name Bash, or execute kind): keyed by concrete command signature. -// 5. File operations: keyed by stable operation/tool identity, independent of target path. -// 6. Other built-ins: keyed by stable provider tool name. +// 3. Shell/execute tool (provider tool name Bash, or execute kind): keyed by concrete command signature. +// 4. File operations: keyed by stable operation/tool identity, independent of target path. +// 5. Other built-ins: keyed by stable provider tool name. // The MCP check runs before the execute branch so an opencode MCP tool reporting kind:execute (e.g. a // notebook execute-cell) is grouped as its own MCP tool, not misrouted to the shared Bash category. const resolveCategoryKey = ( params: RequestPermissionRequest, - mcpServerNames: readonly string[] = [] + mcpServerNames: readonly string[] = [], + allowLegacyReportedMcp = false ): string | undefined => { const { toolCall } = params const providerToolName = extractProviderToolName(toolCall) + const trustedIdentity = resolveTrustedMcpToolIdentity(params, mcpServerNames) if (isMcpPermission(params, mcpServerNames)) { const identity = - resolveMcpToolIdentity(toolCall.title, mcpServerNames) ?? - resolveMcpToolIdentity(providerToolName, mcpServerNames) + trustedIdentity ?? + resolveMcpToolIdentity(providerToolName, mcpServerNames) ?? + (allowLegacyReportedMcp + ? (resolveMcpToolIdentity(toolCall.title, mcpServerNames) ?? + resolveLegacyClaudeMcpIdentity(providerToolName) ?? + resolveLegacyClaudeMcpIdentity(toolCall.title)) + : undefined) if (!identity) return undefined const notebookContext = - resolveNotebookPermissionContext(toolCall.title, toolCall.rawInput, mcpServerNames) ?? - resolveNotebookPermissionContext(providerToolName, toolCall.rawInput, mcpServerNames) + (trustedIdentity + ? resolveNotebookPermissionContextForIdentity(trustedIdentity, toolCall.rawInput) + : resolveNotebookPermissionContext(providerToolName, toolCall.rawInput, mcpServerNames)) ?? + (allowLegacyReportedMcp + ? (() => { + const tool = resolveNotebookExecutionTool(identity) + return tool ? { runtime: resolveNotebookRuntime(tool, toolCall.rawInput) } : undefined + })() + : undefined) if (notebookContext) { return notebookContext.runtime ? `mcp:${identity}:${notebookContext.runtime}` : undefined } @@ -299,12 +342,15 @@ const resolveCategoryKey = ( return `mcp:${identity}` } - if (isSkillPermission(params)) return 'skill' + // Only provider metadata/codecs may create durable identities. `title` is display text and can be + // model-controlled on some ACP bridges, so title-only requests remain Once-only. + const registeredCategory = categoryFromTrustedToolName(providerToolName) + if (registeredCategory) return registeredCategory - if (providerToolName === 'WebFetch') { - const hostname = resolveWebFetchHostname(params) - return hostname ? `webfetch:${hostname}` : undefined - } + if (isSkillPermission(params, allowLegacyReportedMcp)) return 'skill' + + // V1 provider-native web tools are always one-shot, including the legacy in-memory broker path. + if (providerToolName === 'WebFetch' || providerToolName === 'WebSearch') return undefined if (providerToolName === 'Bash' || toolCall.kind === 'execute') { const command = resolveShellCommand(params) @@ -378,15 +424,6 @@ const describeGrant = (categoryKey: string): AcpPermissionGrant => { } } - if (categoryKey.startsWith('webfetch:')) { - return { - categoryKey, - kind: 'tool', - label: `WebFetch (${categoryKey.slice('webfetch:'.length)})`, - scope: 'session' - } - } - if (categoryKey.startsWith('file:')) { return { categoryKey, @@ -403,14 +440,50 @@ const describeGrant = (categoryKey: string): AcpPermissionGrant => { return { categoryKey, kind: 'tool', label: categoryKey, scope: 'session' } } +const describeRegistryGrant = (record: PermissionGrantRecord): AcpPermissionGrant => { + const [view] = projectPermissionGrantSnapshot([record]).grants + const label = view.qualifierLabel + ? `${view.capabilityLabel} · ${view.qualifierLabel}` + : view.capabilityLabel + return { + // Existing renderer plumbing treats this field as opaque. Registry-backed Session grants use the + // durable row id so composer revoke cannot accidentally broaden to another capability. + categoryKey: record.id, + label, + kind: + record.capability.kind === 'execution' + ? 'shell' + : record.capability.kind === 'mcp_tool' + ? 'mcp' + : 'tool', + scope: 'session' + } +} + +const projectRegistrySessionGrants = ( + records: PermissionGrantRecord[] +): Record => { + const grantsBySession: Record = {} + for (const record of records) { + if (record.scope.kind !== 'session') continue + const grants = grantsBySession[record.scope.sessionId] ?? [] + grants.push(describeRegistryGrant(record)) + grantsBySession[record.scope.sessionId] = grants + } + return grantsBySession +} + // Tracks permission requests until the renderer chooses an outcome. class AcpPermissionBroker { private pendingRequests = new Map() + private cancellationGeneration = 0 + private readonly sessionCancellationGenerations = new Map() // Accepts the callback used to publish new permission requests to listeners. constructor( private readonly emitPermissionRequest: EmitPermissionRequest, - private readonly conversationGrants = new ConversationPermissionGrantStore() + private readonly conversationGrants = new ConversationPermissionGrantStore(), + private readonly permissionGrantRegistry?: PermissionGrantRegistry ) {} // Returns serializable pending requests for runtime snapshots. @@ -426,11 +499,32 @@ class AcpPermissionBroker { // Lists the app conversation's grants so the composer can show and revoke them. listGrants(sessionId: string): AcpPermissionGrant[] { + if (this.permissionGrantRegistry) { + return this.permissionGrantRegistry + .listCached() + .filter((record) => record.scope.kind === 'session' && record.scope.sessionId === sessionId) + .map(describeRegistryGrant) + } return this.conversationGrants.list(sessionId).map(describeGrant) } // Removes one session grant so its tool prompts again on the next call. - revokeGrant(sessionId: string, categoryKey: string): void { + async revokeGrant(sessionId: string, categoryKey: string): Promise { + if (this.permissionGrantRegistry) { + const record = this.permissionGrantRegistry + .listCached() + .find( + (candidate) => + candidate.id === categoryKey && + candidate.scope.kind === 'session' && + candidate.scope.sessionId === sessionId + ) + if (!record) return + await this.permissionGrantRegistry.revoke({ + grants: [{ id: record.id, revision: record.revision }] + }) + return + } this.conversationGrants.revoke(sessionId, categoryKey) } @@ -439,12 +533,17 @@ class AcpPermissionBroker { params: RequestPermissionRequest, policyContext?: PermissionPolicyContext ): Promise { + const cancellationGeneration = this.cancellationGeneration + const sessionCancellationGeneration = + this.sessionCancellationGenerations.get(params.sessionId) ?? 0 const requestId = randomUUID() const mcpServerNames = policyContext?.mcpServerNames ?? [] - const categoryKey = resolveCategoryKey(params, mcpServerNames) + const categoryKey = resolveCategoryKey(params, mcpServerNames, !this.permissionGrantRegistry) + const capability = categoryKey ? capabilityFromLegacyCategory(categoryKey) : undefined const isMcp = isMcpPermission(params, mcpServerNames) const mcpIdentity = isMcp - ? (resolveMcpToolIdentity(params.toolCall.title, mcpServerNames) ?? + ? (resolveTrustedMcpToolIdentity(params, mcpServerNames) ?? + resolveMcpToolIdentity(params.toolCall.title, mcpServerNames) ?? resolveMcpToolIdentity(extractProviderToolName(params.toolCall), mcpServerNames)) : undefined const projectedProviderOptions = projectPermissionOptions(params, policyContext, isMcp) @@ -456,6 +555,11 @@ class AcpPermissionBroker { const providerAllowOnceOption = providerPermissionOptions.find( (option) => option.kind.toLowerCase() === ALLOW_ONCE_OPTION_KIND ) + // Remembered scopes are app-owned, but every released call must still select a provider-native + // one-call option. Without one there is no safe positive response, so fail closed immediately. + if (!providerAllowOnceOption) { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + } const permissionOptions: AcpPermissionRequest['options'] = providerPermissionOptions.map( (option) => ({ optionId: option.optionId, @@ -464,13 +568,36 @@ class AcpPermissionBroker { ...(option.kind.toLowerCase() === ALLOW_ONCE_OPTION_KIND ? { scope: 'once' as const } : {}) }) ) - if (providerAllowOnceOption && categoryKey) { - permissionOptions.push({ - optionId: `${SESSION_ALLOW_OPTION_ID_PREFIX}${requestId}`, - name: 'This conversation', - kind: ALLOW_ALWAYS_OPTION_KIND, - scope: 'session' - }) + if (categoryKey) { + if (this.permissionGrantRegistry && capability && policyContext?.projectId) { + permissionOptions.push( + { + optionId: `${SESSION_ALLOW_OPTION_ID_PREFIX}${requestId}`, + name: 'This session', + kind: ALLOW_ALWAYS_OPTION_KIND, + scope: 'session' + }, + { + optionId: `${PROJECT_ALLOW_OPTION_ID_PREFIX}${requestId}`, + name: 'This project', + kind: ALLOW_ALWAYS_OPTION_KIND, + scope: 'project' + }, + { + optionId: `${GLOBAL_ALLOW_OPTION_ID_PREFIX}${requestId}`, + name: 'Always', + kind: ALLOW_ALWAYS_OPTION_KIND, + scope: 'global' + } + ) + } else if (!this.permissionGrantRegistry) { + permissionOptions.push({ + optionId: `${SESSION_ALLOW_OPTION_ID_PREFIX}${requestId}`, + name: 'This session', + kind: ALLOW_ALWAYS_OPTION_KIND, + scope: 'session' + }) + } } const request: AcpPermissionRequest = { requestId, @@ -501,6 +628,36 @@ class AcpPermissionBroker { }) } + if (this.permissionGrantRegistry && capability) { + return this.permissionGrantRegistry + .resolve(capability, { + projectId: policyContext?.projectId, + sessionId: params.sessionId + }) + .then((match) => { + if ( + cancellationGeneration !== this.cancellationGeneration || + sessionCancellationGeneration !== + (this.sessionCancellationGenerations.get(params.sessionId) ?? 0) + ) { + return { outcome: { outcome: 'cancelled' as const } } + } + if (match && providerAllowOnceOption) { + return { + outcome: { outcome: 'selected' as const, optionId: providerAllowOnceOption.optionId } + } + } + return this.enqueuePermissionRequest({ + requestId, + request, + categoryKey, + capability, + projectId: policyContext?.projectId, + providerAllowOnceOptionId: providerAllowOnceOption?.optionId + }) + }) + } + // A prior app-owned session grant auto-approves without prompting again. const autoAllowOptionId = categoryKey ? this.resolveAutoAllowOptionId(request, categoryKey) @@ -513,19 +670,29 @@ class AcpPermissionBroker { } // The returned promise is held open until the UI selects or cancels an option. + return this.enqueuePermissionRequest({ + requestId, + request, + categoryKey, + providerAllowOnceOptionId: providerAllowOnceOption?.optionId + }) + } + + private enqueuePermissionRequest( + pending: Omit & { requestId: string } + ): Promise { return new Promise((resolve) => { + const { requestId, ...entry } = pending this.pendingRequests.set(requestId, { - request, - categoryKey, - providerAllowOnceOptionId: providerAllowOnceOption?.optionId, + ...entry, resolve }) - this.emitPermissionRequest(request) + this.emitPermissionRequest(entry.request) }) } // Resolves one pending request and reports whether it was found. - respond(response: AcpPermissionResponse): boolean { + async respond(response: AcpPermissionResponse): Promise { const pending = this.pendingRequests.get(response.requestId) if (!pending) { @@ -547,15 +714,45 @@ class AcpPermissionBroker { } const selected = pending.request.options.find((option) => option.optionId === response.optionId) - const providerOptionId = - selected?.scope === 'session' ? pending.providerAllowOnceOptionId : response.optionId + const rememberedScope = + selected?.scope === 'session' || selected?.scope === 'project' || selected?.scope === 'global' + const providerOptionId = rememberedScope ? pending.providerAllowOnceOptionId : response.optionId if (!providerOptionId) { pending.resolve({ outcome: { outcome: 'cancelled' } }) return true } - // Session grants are owned by Open Science. The Agent receives only its one-shot option. + if ( + rememberedScope && + selected?.scope && + pending.capability && + pending.projectId && + this.permissionGrantRegistry + ) { + const scope: PermissionGrantScope = + selected.scope === 'global' + ? { kind: 'global' } + : selected.scope === 'project' + ? { kind: 'project', projectId: pending.projectId } + : { + kind: 'session', + projectId: pending.projectId, + sessionId: pending.request.sessionId + } + try { + await this.permissionGrantRegistry.remember({ capability: pending.capability, scope }) + pending.resolve({ outcome: { outcome: 'selected', optionId: providerOptionId } }) + } catch (error) { + pending.resolve({ outcome: { outcome: 'cancelled' } }) + throw new Error('Permission approval could not be saved; the tool call was cancelled.', { + cause: error + }) + } + return true + } + + // Legacy Session grants are owned by Open Science. The Agent receives only its one-shot option. if (pending.categoryKey) { this.rememberSessionGrant(pending.request, pending.categoryKey, response.optionId) } @@ -597,6 +794,7 @@ class AcpPermissionBroker { // Cancels every pending request while preserving conversation grants across Agent reconnects. cancelAllPending(): void { + this.cancellationGeneration += 1 const pendingRequests = Array.from(this.pendingRequests.keys()) for (const requestId of pendingRequests) { @@ -606,6 +804,10 @@ class AcpPermissionBroker { // Cancels pending requests for one session while leaving other sessions intact. cancelForSession(sessionId: string): void { + this.sessionCancellationGenerations.set( + sessionId, + (this.sessionCancellationGenerations.get(sessionId) ?? 0) + 1 + ) const pendingRequests = Array.from(this.pendingRequests.values()) for (const { request } of pendingRequests) { @@ -625,6 +827,7 @@ class AcpPermissionBroker { export { AcpPermissionBroker, ConversationPermissionGrantStore, + projectRegistrySessionGrants, resolveCategoryKey, resolveNotebookPermissionContext } diff --git a/src/main/acp/permission-policy.ts b/src/main/acp/permission-policy.ts index c0d6cc9bd..6f88bdba2 100644 --- a/src/main/acp/permission-policy.ts +++ b/src/main/acp/permission-policy.ts @@ -19,6 +19,7 @@ import { type PermissionPolicyContext = { profile: PermissionProfileId + projectId?: string frameworkId?: AgentFrameworkId autoReviewStrategy?: PermissionAutoReviewStrategy cwd?: string @@ -27,9 +28,13 @@ type PermissionPolicyContext = { } const TRUSTED_MCP_TOOL_IDENTITY = Symbol('trusted-mcp-tool-identity') +const TRUSTED_NATIVE_TOOL_IDENTITY = Symbol('trusted-native-tool-identity') type TrustedMcpPermissionRequest = RequestPermissionRequest & { [TRUSTED_MCP_TOOL_IDENTITY]?: string } +type TrustedNativePermissionRequest = RequestPermissionRequest & { + [TRUSTED_NATIVE_TOOL_IDENTITY]?: string +} // Carries a runtime-verified MCP identity across the broker without serializing it back to ACP. A // symbol key cannot be supplied by provider JSON, while its enumerable value survives the broker's @@ -39,6 +44,20 @@ const withTrustedMcpToolIdentity = ( identity: string ): RequestPermissionRequest => Object.assign({}, params, { [TRUSTED_MCP_TOOL_IDENTITY]: identity }) +const trustedMcpToolIdentity = (params: RequestPermissionRequest): string | undefined => + (params as TrustedMcpPermissionRequest)[TRUSTED_MCP_TOOL_IDENTITY] + +// Marks a provider-native tool only after the runtime binds its preceding tool_call to the later +// request_permission by session and call id. ACP JSON cannot forge this process-local Symbol. +const withTrustedNativeToolIdentity = ( + params: RequestPermissionRequest, + identity: string +): RequestPermissionRequest => + Object.assign({}, params, { [TRUSTED_NATIVE_TOOL_IDENTITY]: identity }) + +const trustedNativeToolIdentity = (params: RequestPermissionRequest): string | undefined => + (params as TrustedNativePermissionRequest)[TRUSTED_NATIVE_TOOL_IDENTITY] + // MCP tool naming differs per framework: Claude Code namespaces them mcp____, Codex // reports mcp.., and opencode joins them _. Claude's distinctive prefix is // self-identifying; the shorter Codex/opencode forms are checked against known session servers. @@ -133,6 +152,23 @@ const canConservativelyAutoApprove = ( const resolveAllowOptionId = (params: RequestPermissionRequest): string | undefined => params.options.find((option) => option.kind.toLowerCase() === 'allow_once')?.optionId +// OpenCode's native Skill tool only reads an app-provisioned skill definition into the model's +// context. It is framework plumbing rather than a user-authorizable side effect. Older OpenCode +// sessions can still emit request_permission, so the runtime binds that request to a preceding native +// tool_call and attaches the process-local identity below. Presentation text alone is never trusted. +const isOpenCodeNativeSkillPermission = ( + params: RequestPermissionRequest, + context: PermissionPolicyContext | undefined +): boolean => { + if (context?.frameworkId !== 'opencode' || params.toolCall.kind !== 'other') return false + if (trustedNativeToolIdentity(params) !== 'opencode/skill') return false + + const title = params.toolCall.title?.trim().toLowerCase() + const providerToolName = extractProviderToolName(params.toolCall)?.trim().toLowerCase() + + return title === 'skill' && (providerToolName == null || providerToolName === 'skill') +} + const isArtifactSaveTool = ( params: RequestPermissionRequest, mcpServerNames: readonly string[] @@ -146,7 +182,7 @@ const isArtifactSaveTool = ( const providerToolName = extractProviderToolName(params.toolCall) if (!providerToolName) return false - const trustedMcpIdentity = (params as TrustedMcpPermissionRequest)[TRUSTED_MCP_TOOL_IDENTITY] + const trustedMcpIdentity = trustedMcpToolIdentity(params) return ( trustedMcpIdentity === 'open-science-artifacts/write_artifact_file' || @@ -169,6 +205,10 @@ const resolveAutomaticPermission = ( return resolveAllowOptionId(params) } + if (isOpenCodeNativeSkillPermission(params, context)) { + return resolveAllowOptionId(params) + } + // Saving an already-existing/inline result into the exact app-owned Artifact capability is part // of normal turn finalization. It cannot execute code or choose Project/Session ownership, so it // receives one call-scoped allow decision under every profile without showing an approval card. @@ -207,6 +247,8 @@ export { resolveMcpProviderLeafIdentity, resolveAutomaticPermission, resolveAllowOptionId, + trustedMcpToolIdentity, + withTrustedNativeToolIdentity, withTrustedMcpToolIdentity } export type { PermissionPolicyContext } diff --git a/src/main/acp/runtime-coordinator.test.ts b/src/main/acp/runtime-coordinator.test.ts index 6f175e35e..a6bb82a2d 100644 --- a/src/main/acp/runtime-coordinator.test.ts +++ b/src/main/acp/runtime-coordinator.test.ts @@ -67,27 +67,32 @@ const createFakeRuntime = (options: { let snapshot = emptySnapshot() let sessionIndex = 0 let turnSequence = 0 + const sessionProjects = new Map() const connect = vi.fn(async () => snapshot) - const createSession = vi.fn(async () => { + const createSession = vi.fn(async (request: { projectName?: string } = {}) => { const sessionId = options.sessionIds[sessionIndex] sessionIndex += 1 + sessionProjects.set(sessionId, request.projectName ?? 'Artifacts') snapshot = { ...snapshot, sessionId, sessionIds: [...snapshot.sessionIds, sessionId] } options.callbacks.onStateChanged?.(snapshot) return { sessionId, cwd: '/workspace', frameworkId: options.frameworkId } }) - const resumeSession = vi.fn(async ({ sessionId }: { sessionId: string }) => { - await options.beforeResume?.() - snapshot = { - ...snapshot, - sessionId, - sessionIds: snapshot.sessionIds.includes(sessionId) - ? snapshot.sessionIds - : [...snapshot.sessionIds, sessionId] + const resumeSession = vi.fn( + async ({ sessionId, projectName }: { sessionId: string; projectName?: string }) => { + await options.beforeResume?.() + sessionProjects.set(sessionId, projectName ?? 'Artifacts') + snapshot = { + ...snapshot, + sessionId, + sessionIds: snapshot.sessionIds.includes(sessionId) + ? snapshot.sessionIds + : [...snapshot.sessionIds, sessionId] + } + options.callbacks.onStateChanged?.(snapshot) + await options.afterResumeAttached?.() + return { sessionId, cwd: '/workspace', frameworkId: options.frameworkId, contextReset: true } } - options.callbacks.onStateChanged?.(snapshot) - await options.afterResumeAttached?.() - return { sessionId, cwd: '/workspace', frameworkId: options.frameworkId, contextReset: true } - }) + ) const resetSessionContext = vi.fn(async ({ sessionId }: { sessionId: string }) => ({ sessionId, cwd: '/workspace', @@ -98,6 +103,7 @@ const createFakeRuntime = (options: { const compactSession = vi.fn(async () => ({ stopReason: 'end_turn' })) const cancelPrompt = vi.fn(async () => snapshot) const deleteSession = vi.fn(async ({ sessionId }: { sessionId: string }) => { + sessionProjects.delete(sessionId) snapshot = { ...snapshot, sessionId: snapshot.sessionId === sessionId ? undefined : snapshot.sessionId, @@ -153,6 +159,8 @@ const createFakeRuntime = (options: { const runtime = { getSnapshot: () => snapshot, getActivePromptSessions: () => [], + hasLiveSession: (projectId: string, sessionId: string) => + snapshot.sessionIds.includes(sessionId) && sessionProjects.get(sessionId) === projectId, getActiveArtifactRunIds: () => [], connect, createSession, @@ -229,6 +237,24 @@ const createFakeRuntime = (options: { } describe('AcpRuntimeCoordinator', () => { + it('recognizes a live session only under its owning project', async () => { + const coordinator = new AcpRuntimeCoordinator( + (callbacks) => + createFakeRuntime({ + frameworkId: 'claude-code', + sessionIds: ['session-1'], + callbacks + }).runtime + ) + const session = await coordinator.createSession({ projectName: 'project-1' }) + + expect(coordinator.hasLiveSession('project-1', session.sessionId)).toBe(true) + expect(coordinator.hasLiveSession('project-2', session.sessionId)).toBe(false) + + await coordinator.deleteSession({ sessionId: session.sessionId }) + expect(coordinator.hasLiveSession('project-1', session.sessionId)).toBe(false) + }) + it('forwards switchSpecialist to the owning runtime and returns its contextReset flag', async () => { const created: ReturnType[] = [] const coordinator = new AcpRuntimeCoordinator((callbacks) => { @@ -496,20 +522,20 @@ describe('AcpRuntimeCoordinator', () => { return fake.runtime }) const session = await coordinator.createSession() - store?.remember(session.sessionId, 'tool:WebFetch') + store?.remember(session.sessionId, 'file:Write') await coordinator.requestAgentFrameworkSwitch() expect(coordinator.getSnapshot()).toMatchObject({ sessionIds: [], permissionGrants: { - [session.sessionId]: [{ categoryKey: 'tool:WebFetch', label: 'WebFetch', scope: 'session' }] + [session.sessionId]: [{ categoryKey: 'file:Write', label: 'Write', scope: 'session' }] } }) coordinator.revokePermissionGrant({ sessionId: session.sessionId, - categoryKey: 'tool:WebFetch' + categoryKey: 'file:Write' }) expect(coordinator.getSnapshot().permissionGrants).toEqual({}) @@ -521,6 +547,49 @@ describe('AcpRuntimeCoordinator', () => { expect(coordinator.getSnapshot().permissionGrants).toEqual({}) }) + it('projects durable registry grants across runtime rotation and refresh notifications', async () => { + const created: ReturnType[] = [] + const onStateChanged = vi.fn() + const durableGrants: AcpStateSnapshot['permissionGrants'] = { + 'session-1': [ + { + categoryKey: 'durable-grant-1', + kind: 'mcp', + label: 'Manage packages', + scope: 'session' + } + ] + } + const coordinator = new AcpRuntimeCoordinator( + (callbacks, permissionGrantStore) => { + const fake = createFakeRuntime({ + frameworkId: created.length === 0 ? 'claude-code' : 'codex', + sessionIds: [`session-${created.length + 1}`], + callbacks, + permissionGrantStore + }) + created.push(fake) + return fake.runtime + }, + { onStateChanged }, + '', + undefined, + undefined, + undefined, + {}, + () => durableGrants + ) + + await coordinator.createSession() + await coordinator.requestAgentFrameworkSwitch() + + expect(coordinator.getSnapshot().permissionGrants).toEqual(durableGrants) + coordinator.notifyPermissionGrantsChanged() + expect(onStateChanged).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionGrants: durableGrants }) + ) + }) + it('moves later settings and model-resolved effort across active generations', async () => { const created: ReturnType[] = [] const coordinator = new AcpRuntimeCoordinator((callbacks) => { diff --git a/src/main/acp/runtime-coordinator.ts b/src/main/acp/runtime-coordinator.ts index 112ce46fd..57c635947 100644 --- a/src/main/acp/runtime-coordinator.ts +++ b/src/main/acp/runtime-coordinator.ts @@ -44,8 +44,11 @@ type AcpRuntimeCoordinatorTeardownCallbacks = { ) => void onSessionCancellationRequested?: (sessionId: string) => void onAllSessionsCancellationRequested?: () => void + beforeSessionDelete?: (sessionId: string) => Promise } +type PermissionGrantSnapshotProvider = () => AcpStateSnapshot['permissionGrants'] + type PendingPromptStart = { id: string runtime: AcpRuntime @@ -92,7 +95,8 @@ class AcpRuntimeCoordinator { private readonly initializationBarrier?: Promise, private readonly onDisconnected?: () => void, private readonly onSessionUnavailable?: (sessionId: string) => void, - private readonly teardownCallbacks: AcpRuntimeCoordinatorTeardownCallbacks = {} + private readonly teardownCallbacks: AcpRuntimeCoordinatorTeardownCallbacks = {}, + private readonly permissionGrantSnapshot?: PermissionGrantSnapshotProvider ) { this.activeRuntime = this.addRuntime() this.lastRuntime = this.activeRuntime @@ -171,7 +175,7 @@ class AcpRuntimeCoordinator { {}, ...snapshots.map(({ snapshot }) => snapshot.permissionProfiles) ), - permissionGrants: this.permissionGrantStore.snapshot(), + permissionGrants: this.permissionGrantSnapshot?.() ?? this.permissionGrantStore.snapshot(), contextUsageBySession, nativeContextCompactionSessionIds, promptInFlight: promptInFlightSessionIds.length > 0, @@ -183,6 +187,11 @@ class AcpRuntimeCoordinator { return Array.from(this.runtimes).flatMap((runtime) => runtime.getActivePromptSessions()) } + hasLiveSession(projectId: string, sessionId: string): boolean { + const runtime = this.sessionRuntimes.get(sessionId) + return runtime?.hasLiveSession(projectId, sessionId) ?? false + } + getActiveArtifactRunIds(): string[] { return Array.from(this.runtimes).flatMap((runtime) => runtime.getActiveArtifactRunIds()) } @@ -372,6 +381,7 @@ class AcpRuntimeCoordinator { this.invalidateSessionTurn(request.sessionId) const runtime = this.runtimeForSession(request.sessionId) const ownedBeforeDelete = this.sessionRuntimes.get(request.sessionId) === runtime + await this.teardownCallbacks.beforeSessionDelete?.(request.sessionId) await runtime.deleteSession(request) const ownerAfterDelete = this.sessionRuntimes.get(request.sessionId) // Attached deletes emit a runtime state change, whose reconciliation already notifies exactly once. @@ -386,7 +396,7 @@ class AcpRuntimeCoordinator { return this.getSnapshot() } - respondToPermission(response: AcpPermissionResponse): AcpStateSnapshot { + async respondToPermission(response: AcpPermissionResponse): Promise { const runtime = this.permissionRuntimes.get(response.requestId) ?? Array.from(this.runtimes).find((candidate) => @@ -395,8 +405,11 @@ class AcpRuntimeCoordinator { .pendingPermissions.some((request) => request.requestId === response.requestId) ) ?? this.getActiveRuntime() - runtime.respondToPermission(response) - this.permissionRuntimes.delete(response.requestId) + try { + await runtime.respondToPermission(response) + } finally { + this.permissionRuntimes.delete(response.requestId) + } return this.getSnapshot() } @@ -405,11 +418,15 @@ class AcpRuntimeCoordinator { return this.getSnapshot() } - revokePermissionGrant(request: AcpRevokePermissionGrantRequest): AcpStateSnapshot { - this.runtimeForSession(request.sessionId).revokePermissionGrant(request) + async revokePermissionGrant(request: AcpRevokePermissionGrantRequest): Promise { + await this.runtimeForSession(request.sessionId).revokePermissionGrant(request) return this.getSnapshot() } + notifyPermissionGrantsChanged(): void { + this.callbacks.onStateChanged?.(this.getSnapshot()) + } + // A framework change takes effect for every future turn and workflow. The old generation stays alive // until its active prompts and workflow leases finish; idle sessions resume on demand. async requestAgentFrameworkSwitch(): Promise { diff --git a/src/main/acp/runtime.test.ts b/src/main/acp/runtime.test.ts index 440fbbf2f..f75686bca 100644 --- a/src/main/acp/runtime.test.ts +++ b/src/main/acp/runtime.test.ts @@ -438,6 +438,7 @@ const startPermissionProbeAgent = ( toolKind?: 'other' | 'execute' | 'read' | null toolRawInput?: unknown providerToolName?: string + announcedProviderToolName?: string codexMcpIdentity?: { server: string tool: string @@ -489,7 +490,10 @@ const startPermissionProbeAgent = ( title: options.toolTitle, kind: options.toolKind ?? 'other', status: 'pending', - ...(options.toolRawInput === undefined ? {} : { rawInput: options.toolRawInput }) + ...(options.toolRawInput === undefined ? {} : { rawInput: options.toolRawInput }), + ...(options.announcedProviderToolName + ? { _meta: { toolName: options.announcedProviderToolName } } + : {}) } }) } @@ -1246,6 +1250,33 @@ describe('ACP runtime session management', () => { expect(runtime.getSnapshot().sessionIds).toEqual([]) }) + it('releases notebook RPC capabilities after an unexpected protocol close', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['unexpected-close-session']) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.2.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'secret-token' + }), + releaseSessionCapabilities + } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + process.stdout.end() + + await vi.waitFor(() => expect(runtime.getSnapshot().status).toBe('closed')) + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith(session.sessionId) + }) + it('emits a terminal failure for every in-flight prompt before an unexpected close clears state', async () => { const process = new FakeAgentProcess() const promptGate = createDeferred() @@ -1996,10 +2027,23 @@ describe('ACP runtime session management', () => { }) it('adopts a fresh agent session under the same app id on a context reset', async () => { + const root = await createTemporaryRoot() + const connectorCall = vi.fn(async () => ({ ok: true })) + const notebookService = new NotebookRuntimeService({ + configRoot: root, + dataRoot: root, + projectName: 'default-project', + repository: new NotebookRunRepository(root) + }) + const notebookRpcServer = new NotebookLocalRpcServer(notebookService, { + token: 'control-token', + connectorService: { call: connectorCall } + }) + temporaryDisconnections.push(() => notebookRpcServer.close()) const process = new FakeAgentProcess() const receivedPrompts: ContentBlock[][] = [] // A second agent session id is available for the fresh adoption that the reset performs. - startFakeAgent(process, ['remote-session-1', 'remote-session-2'], { + const fakeAgent = startFakeAgent(process, ['remote-session-1', 'remote-session-2'], { onPrompt: ({ prompt }) => { receivedPrompts.push(prompt) } @@ -2007,14 +2051,32 @@ describe('ACP runtime session management', () => { const runtime = new AcpRuntime({ appVersion: '0.1.0', defaultCwd: '/workspace', - spawnAgent: () => asAgentProcess(process) + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: ({ sessionId, projectId }) => + notebookRpcServer.issueSessionConnection(sessionId, projectId), + registerSessionAlias: (aliasSessionId, sessionId) => + notebookRpcServer.registerSessionAlias(aliasSessionId, sessionId), + releaseSessionCapabilities: (sessionId) => + notebookRpcServer.releaseSessionCapabilities(sessionId) + } }) const session = await runtime.createSession({ cwd: '/workspace' }) + const initialNotebookServer = fakeAgent.newSessions[0]?.mcpServers[0] + const initialEndpoint = getEnvValue(initialNotebookServer, 'OPEN_SCIENCE_NOTEBOOK_RPC_ENDPOINT') + const initialToken = getEnvValue(initialNotebookServer, 'OPEN_SCIENCE_NOTEBOOK_RPC_TOKEN') const reset = await runtime.resetSessionContext({ sessionId: session.sessionId, cwd: '/workspace' }) + const replacementNotebookServer = fakeAgent.newSessions[1]?.mcpServers[0] + const replacementToken = getEnvValue( + replacementNotebookServer, + 'OPEN_SCIENCE_NOTEBOOK_RPC_TOKEN' + ) // The app-facing id stays attached (a brand-new agent session now backs it), and the caller is told // to replay a transcript because the agent-side context was dropped. @@ -2025,6 +2087,24 @@ describe('ACP runtime session management', () => { // The fresh session still accepts prompts, so the conversation continues after the reset. await runtime.sendPrompt({ sessionId: session.sessionId, text: 'continue after compaction' }) expect(receivedPrompts.at(-1)).toBeDefined() + + const callConnector = (token: string): Promise => + fetch(initialEndpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + method: 'mcpCall', + params: { server: 'pubmed', method: 'search', args: {} } + }) + }) + + expect(replacementToken).not.toBe(initialToken) + await expect(callConnector(initialToken)).resolves.toMatchObject({ status: 401 }) + await expect(callConnector(replacementToken)).resolves.toMatchObject({ status: 200 }) + expect(connectorCall).toHaveBeenCalledOnce() }) it('uses the framework native command to compact without adding command output to chat events', async () => { @@ -2349,7 +2429,8 @@ describe('ACP runtime session management', () => { expect(opencodeMcpToolInputsMap(runtime).get(session.sessionId)?.get('retitled-call')).toEqual({ title: 'open-science-notebook_notebook_execute', - providerToolName: 'open-science-notebook_notebook_execute' + providerToolName: 'open-science-notebook_notebook_execute', + mcpIdentity: 'open-science-notebook/notebook_execute' }) }) @@ -4368,6 +4449,123 @@ describe('ACP runtime session management', () => { ]) }) + it('silently allows OpenCode native skill loading without publishing a permission request', async () => { + const process = new FakeAgentProcess() + const permissionRequests: AcpPermissionRequest[] = [] + let permissionResponse: unknown + startPermissionProbeAgent(process, { + newSessionId: 'opencode-skill-session', + toolCallId: 'opencode-skill-call', + toolTitle: 'skill', + toolKind: 'other', + toolRawInput: { name: 'mcp-pubmed' }, + announceToolCall: true, + permissionOptions: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'always', kind: 'allow_always', name: 'Always allow' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ], + onPermissionResponse: (response) => { + permissionResponse = response + } + }) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + framework: opencodeFramework, + callbacks: { + onPermissionRequest: (request) => permissionRequests.push(request) + } + }) + const session = await runtime.createSession({ cwd: '/workspace', permissionProfile: 'ask' }) + + await runtime.sendPrompt({ sessionId: session.sessionId, text: 'load a skill' }) + + expect(permissionRequests).toEqual([]) + expect(permissionResponse).toEqual({ outcome: { outcome: 'selected', optionId: 'once' } }) + expect(runtime.getSnapshot().permissionGrants[session.sessionId]).toEqual([]) + }) + + it('does not trust an isolated OpenCode permission title as native Skill identity', async () => { + const process = new FakeAgentProcess() + const permissionRequests: AcpPermissionRequest[] = [] + let permissionResponse: unknown + startPermissionProbeAgent(process, { + newSessionId: 'opencode-untrusted-skill-session', + toolCallId: 'opencode-untrusted-skill-call', + toolTitle: 'skill', + toolKind: 'other', + toolRawInput: {}, + permissionOptions: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ], + onPermissionResponse: (response) => { + permissionResponse = response + } + }) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + framework: opencodeFramework, + callbacks: { + onPermissionRequest: (request) => { + permissionRequests.push(request) + runtime.respondToPermission({ requestId: request.requestId, optionId: 'reject' }) + } + } + }) + const session = await runtime.createSession({ cwd: '/workspace', permissionProfile: 'ask' }) + + await runtime.sendPrompt({ sessionId: session.sessionId, text: 'request an unknown tool' }) + + expect(permissionRequests).toHaveLength(1) + expect(permissionRequests[0]).toMatchObject({ title: 'skill', providerToolName: undefined }) + expect(permissionResponse).toEqual({ outcome: { outcome: 'selected', optionId: 'reject' } }) + }) + + it('does not use the OpenCode Skill fallback when tool metadata names a different tool', async () => { + const process = new FakeAgentProcess() + const permissionRequests: AcpPermissionRequest[] = [] + let permissionResponse: unknown + startPermissionProbeAgent(process, { + newSessionId: 'opencode-explicit-tool-session', + toolCallId: 'opencode-explicit-tool-call', + toolTitle: 'skill', + toolKind: 'other', + toolRawInput: { name: 'mcp-pubmed' }, + announceToolCall: true, + announcedProviderToolName: 'Bash', + permissionOptions: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ], + onPermissionResponse: (response) => { + permissionResponse = response + } + }) + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + framework: opencodeFramework, + callbacks: { + onPermissionRequest: (request) => { + permissionRequests.push(request) + runtime.respondToPermission({ requestId: request.requestId, optionId: 'reject' }) + } + } + }) + const session = await runtime.createSession({ cwd: '/workspace', permissionProfile: 'ask' }) + + await runtime.sendPrompt({ sessionId: session.sessionId, text: 'run a non-Skill tool' }) + + expect(permissionRequests).toHaveLength(1) + expect(permissionResponse).toEqual({ outcome: { outcome: 'selected', optionId: 'reject' } }) + }) + it('restores OpenCode MCP inputs before separating notebook grants by language', async () => { const process = new FakeAgentProcess() const permissionRequests: AcpPermissionRequest[] = [] @@ -5465,7 +5663,7 @@ describe('ACP runtime session management', () => { isMcp: true, rawInput: { code: 'print(1)', language: 'python' }, options: expect.arrayContaining([ - expect.objectContaining({ name: 'This conversation', scope: 'session' }) + expect.objectContaining({ name: 'This session', scope: 'session' }) ]) }) expect(permissionResponse).toEqual({ @@ -6166,6 +6364,60 @@ describe('ACP runtime session management', () => { expect(mcpServerNamesMap(runtime).has(session.sessionId)).toBe(false) }) + it('releases notebook RPC capabilities when a session is deleted', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['remote-session-1']) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'secret-token' + }), + releaseSessionCapabilities + } + }) + + const session = await runtime.createSession({ cwd: '/workspace' }) + await runtime.deleteSession({ sessionId: session.sessionId }) + + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith(session.sessionId) + }) + + it('releases notebook RPC capabilities for every session on disconnect', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['remote-session-1', 'remote-session-2']) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'secret-token' + }), + releaseSessionCapabilities + } + }) + + const first = await runtime.createSession({ cwd: '/workspace' }) + const second = await runtime.createSession({ cwd: '/workspace' }) + await runtime.disconnect() + + expect(releaseSessionCapabilities).toHaveBeenCalledTimes(2) + expect(releaseSessionCapabilities).toHaveBeenCalledWith(first.sessionId) + expect(releaseSessionCapabilities).toHaveBeenCalledWith(second.sessionId) + }) + it('clears all MCP server names on disconnect', async () => { const process = new FakeAgentProcess() startFakeAgent(process, ['remote-session-1']) @@ -6368,6 +6620,103 @@ describe('ACP runtime session management', () => { ) }) + it('releases the notebook RPC capability when resumed-session setup fails', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, []) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'resumed-token' + }), + releaseSessionCapabilities + } + }) + const failure = new Error('resumed permission setup failed') + vi.spyOn( + runtime as unknown as { configurePermissionProfile: () => Promise }, + 'configurePermissionProfile' + ).mockRejectedValueOnce(failure) + + await expect( + runtime.resumeSession({ sessionId: 'restored-session', cwd: '/workspace' }) + ).rejects.toBe(failure) + + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith('restored-session') + }) + + it('releases the notebook RPC capability when fresh-session adoption fails', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['adopted-session']) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'adopted-token' + }), + releaseSessionCapabilities + } + }) + const failure = new Error('adopted permission setup failed') + vi.spyOn( + runtime as unknown as { configurePermissionProfile: () => Promise }, + 'configurePermissionProfile' + ).mockRejectedValueOnce(failure) + + await expect( + runtime.resumeSession({ + sessionId: 'switched-session', + cwd: '/workspace', + previousFrameworkId: 'codex' + }) + ).rejects.toBe(failure) + + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith('switched-session') + }) + + it('replaces a failed resume capability without revoking the adopted session capability', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['adopted-session'], { resumeNotFound: true }) + const getRpcConnection = vi.fn(async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'session-token' + })) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection, + releaseSessionCapabilities + } + }) + + await expect( + runtime.resumeSession({ sessionId: 'restored-session', cwd: '/workspace' }) + ).resolves.toMatchObject({ sessionId: 'restored-session', contextReset: true }) + + expect(getRpcConnection).toHaveBeenCalledTimes(2) + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith('restored-session') + }) + it('times out and tears down a reconnect when the agent never answers session/resume', async () => { const process = new FakeAgentProcess() const resumeReceived = createDeferred() @@ -8432,6 +8781,10 @@ describe('ACP runtime session management', () => { const process = new FakeAgentProcess() const fakeAgent = startFakeAgent(process, ['remote-session-1']) const aliases: Array<{ aliasSessionId: string; sessionId: string }> = [] + const getRpcConnection = vi.fn(async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'secret-token' + })) const runtime = new AcpRuntime({ appVersion: '0.1.0', defaultCwd: '/workspace', @@ -8440,10 +8793,7 @@ describe('ACP runtime session management', () => { projectName: 'default-project', mcpEntryPath: '/app/out/main/index.js', mcpCommand: '/Applications/Open Science.app/Contents/MacOS/Open Science', - getRpcConnection: async () => ({ - endpoint: 'http://127.0.0.1:4567', - token: 'secret-token' - }), + getRpcConnection, registerSessionAlias: (aliasSessionId, sessionId) => { aliases.push({ aliasSessionId, sessionId }) } @@ -8478,6 +8828,17 @@ describe('ACP runtime session management', () => { sessionId: 'remote-session-1' } ]) + expect(getRpcConnection).toHaveBeenNthCalledWith(1, { + sessionId: getEnvValue( + fakeAgent.newSessions[0].mcpServers[0], + 'OPEN_SCIENCE_NOTEBOOK_SESSION_ID' + ), + projectId: 'default-project' + }) + expect(getRpcConnection).toHaveBeenNthCalledWith(2, { + sessionId: 'remote-session-2', + projectId: 'default-project' + }) expect(fakeAgent.resumedSessions[0].mcpServers).toHaveLength(1) expect( getEnvValue(fakeAgent.resumedSessions[0].mcpServers[0], 'OPEN_SCIENCE_NOTEBOOK_SESSION_ID') @@ -11257,6 +11618,38 @@ describe('ACP runtime — session-creation and spawn diagnostics', () => { }) }) + it('releases the provisional notebook RPC capability when session creation fails', async () => { + const process = new FakeAgentProcess() + startFakeAgent(process, ['perm-fail-session']) + const releaseSessionCapabilities = vi.fn() + const runtime = new AcpRuntime({ + appVersion: '0.1.0', + defaultCwd: '/workspace', + spawnAgent: () => asAgentProcess(process), + notebook: { + projectName: 'default-project', + mcpEntryPath: '/app/out/main/index.js', + getRpcConnection: async () => ({ + endpoint: 'http://127.0.0.1:4567', + token: 'secret-token' + }), + releaseSessionCapabilities + } + }) + const failure = new Error('permission setup failed') + vi.spyOn( + runtime as unknown as { configurePermissionProfile: () => Promise }, + 'configurePermissionProfile' + ).mockRejectedValueOnce(failure) + + await expect(runtime.createSession({ cwd: '/workspace' })).rejects.toBe(failure) + + expect(releaseSessionCapabilities).toHaveBeenCalledOnce() + expect(releaseSessionCapabilities).toHaveBeenCalledWith( + expect.stringMatching(/^notebook-session-/) + ) + }) + it('logs backend and spawn success without executable, arguments, env, or pid', async () => { infoLogSpy.mockClear() const process = new FakeAgentProcess() diff --git a/src/main/acp/runtime.ts b/src/main/acp/runtime.ts index aa9870580..4254a805e 100644 --- a/src/main/acp/runtime.ts +++ b/src/main/acp/runtime.ts @@ -95,7 +95,11 @@ import { ConversationPermissionGrantStore, resolveNotebookPermissionContext } from './permission-broker' -import { isMcpToolName, withTrustedMcpToolIdentity } from './permission-policy' +import { + isMcpToolName, + withTrustedMcpToolIdentity, + withTrustedNativeToolIdentity +} from './permission-policy' import { applyCurrentModeUpdate } from './permission-profile-controller' import { ARTIFACT_MCP_SERVER_NAME, @@ -130,6 +134,7 @@ import { getNotebookDataRoot, getNotebookSessionRoot } from '../notebook/reposit import { codexStorageDir, codexSubscriptionStorageDir } from '../agent-framework/codex' import { getAppClaudeConfigDir } from '../settings/provider-env' import type { ResponsesBridgeSkillCandidate } from '../settings/responses-bridge' +import type { PermissionGrantRegistry } from '../permission-grants/registry' import { withDataRootWrite } from '../storage/migration-state' import { opencodeStorageDir } from '../agent-framework/opencode' import { CodexSkillActivityProjector } from './codex-skill-activity' @@ -181,6 +186,7 @@ type AcpRuntimeOptions = { defaultCwd: string callbacks?: AcpRuntimeCallbacks permissionGrantStore?: ConversationPermissionGrantStore + permissionGrantRegistry?: PermissionGrantRegistry spawnAgent?: () => ChildProcessWithoutNullStreams // Resolves the active agent backend (framework + spawn inputs) at connect time so a framework or // provider switch takes effect on reconnect. Ignored when an explicit spawnAgent is provided (tests @@ -311,8 +317,12 @@ type AcpRuntimeNotebookOptions = { projectName: string mcpEntryPath: string mcpCommand?: string - getRpcConnection?: () => Promise + getRpcConnection?: (binding: { + sessionId: string + projectId: string + }) => Promise registerSessionAlias?: (aliasSessionId: string, sessionId: string) => void + releaseSessionCapabilities?: (sessionId: string) => void registerSessionSpecialist?: (sessionId: string, specialistId: string | undefined) => void setArtifactProvenanceContext?: ( sessionId: string, @@ -368,13 +378,15 @@ type CodexMcpToolIdentity = { type OpenCodeMcpToolInput = { title: string providerToolName: string + mcpIdentity: string rawInput?: unknown } type ClaudeCodeMcpToolInput = { title: string providerToolName: string - rawInput: Record + mcpIdentity: string + rawInput?: Record } type OpenCodePermissionContextWaitOutcome = 'ready' | 'timeout' | 'cancelled' @@ -383,6 +395,23 @@ type OpenCodePermissionContextWaiter = (outcome: OpenCodePermissionContextWaitOu const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +// OpenCode's older permission bridge can omit provider metadata from request_permission. Accept the +// native Skill identity only from a preceding tool_call: either explicit provider metadata, or the +// legacy wire shape with an exact title plus a structured skill name. The runtime later binds this +// observation to request_permission by session and toolCallId. +const isOpenCodeNativeSkillToolCall = (update: SessionNotification['update']): boolean => { + if (update.sessionUpdate !== 'tool_call' || update.kind !== 'other') return false + const providerToolName = extractProviderToolName(update)?.trim().toLowerCase() + if (providerToolName !== undefined) return providerToolName === 'skill' + + const rawInput = isRecord(update.rawInput) ? update.rawInput : undefined + return ( + update.title?.trim().toLowerCase() === 'skill' && + typeof rawInput?.name === 'string' && + rawInput.name.trim().length > 0 + ) +} + const boundedNotebookPermissionInput = ( title: string, rawInput: unknown, @@ -447,22 +476,19 @@ const codexMcpToolIdentity = ( const server = event.rawInput.server const tool = event.rawInput.tool - if ( - typeof server !== 'string' || - !mcpServerNames.includes(server) || - typeof tool !== 'string' || - !tool.trim() - ) { + if (typeof server !== 'string' || typeof tool !== 'string' || !tool.trim()) { return undefined } const title = `mcp.${server}.${tool}` if (event.title !== title) return undefined + const mcpIdentity = resolveCanonicalMcpToolIdentity(title, mcpServerNames) + if (!mcpIdentity) return undefined return { title, providerToolName: tool, - mcpIdentity: `${server}/${tool}`, + mcpIdentity, rawInput: event.rawInput.arguments } } @@ -739,6 +765,9 @@ class AcpRuntime { // preceding tool update carries those arguments under the same call id, so retain them until the // permission arrives. This is required for notebook runtime separation and permission preview. private readonly opencodeMcpToolInputs = new Map>() + // Native Skill approvals use the same two-message binding as sparse OpenCode MCP approvals. Only + // the call id is retained; instruction contents and skill arguments never enter permission state. + private readonly opencodeNativeSkillToolCalls = new Map>() // OpenCode writes the tool update and permission request back-to-back. The ACP SDK dispatches // incoming messages concurrently, so the permission handler can run before the earlier update has // populated opencodeMcpToolInputs. Waiters rendezvous those two messages by session + call id. @@ -915,23 +944,27 @@ class AcpRuntime { artifacts: this.artifactRepository, artifactVersions: options.artifacts?.provenance }) - this.permissionBroker = new AcpPermissionBroker((request) => { - // Relabel to the app-facing id when this session was adopted onto a replaced agent. - const sessionId = this.agentToAppSessionId.get(request.sessionId) ?? request.sessionId - const routed = sessionId === request.sessionId ? request : { ...request, sessionId } + this.permissionBroker = new AcpPermissionBroker( + (request) => { + // Relabel to the app-facing id when this session was adopted onto a replaced agent. + const sessionId = this.agentToAppSessionId.get(request.sessionId) ?? request.sessionId + const routed = sessionId === request.sessionId ? request : { ...request, sessionId } - this.pushEvent({ - kind: 'permission', - level: 'warning', - sessionId: routed.sessionId, - toolCallId: routed.toolCallId, - title: 'Permission requested', - text: routed.title, - raw: routed - }) - this.callbacks.onPermissionRequest?.(routed) - this.emitState() - }, options.permissionGrantStore) + this.pushEvent({ + kind: 'permission', + level: 'warning', + sessionId: routed.sessionId, + toolCallId: routed.toolCallId, + title: 'Permission requested', + text: routed.title, + raw: routed + }) + this.callbacks.onPermissionRequest?.(routed) + this.emitState() + }, + options.permissionGrantStore, + options.permissionGrantRegistry + ) } // Boundary-safe context for session-creation and process-spawn diagnostics. Keep this list explicit: @@ -977,6 +1010,10 @@ class AcpRuntime { })) } + hasLiveSession(projectId: string, sessionId: string): boolean { + return this.sessions.has(sessionId) && this.sessionProjectNames.get(sessionId) === projectId + } + private getInFlightSessionIds(): string[] { return Array.from( new Set([...this.promptInFlightSessionIds, ...this.contextCompactionInFlightSessionIds]) @@ -1448,6 +1485,7 @@ class AcpRuntime { private async createSessionOperation( request: AcpCreateSessionRequest = {} ): Promise { + let provisionalNotebookSessionId: string | undefined try { log.info('createSession: starting', this.diagnosticContext()) const sessionCwd = resolve(request.cwd || this.cwd || this.options.defaultCwd) @@ -1456,6 +1494,7 @@ class AcpRuntime { const connection = await this.ensureConnected(sessionCwd) const artifactSessionId = this.createArtifactSessionId() const notebookSessionId = this.createNotebookSessionId() + provisionalNotebookSessionId = notebookSessionId || undefined const skillImportSessionId = this.createSkillImportSessionId() // Resolve specialist identity before starting the ACP session so the identity append is @@ -1564,6 +1603,9 @@ class AcpRuntime { ...(this.backendId ? { backendId: this.backendId } : {}) } } catch (error) { + if (provisionalNotebookSessionId) { + this.releaseNotebookSessionCapabilities(provisionalNotebookSessionId) + } safeLogError('createSession: failed', { ...diagnosticErrorFields(error), ...this.diagnosticContext() @@ -2003,86 +2045,100 @@ class AcpRuntime { throw new Error('ACP agent does not support session resume.') } - // Resumed sessions already have stable ids, so the artifact session mirrors the runtime session id. - const mcpServers = await this.createMcpServers({ - artifactSessionId: request.sessionId, - notebookSessionId: request.sessionId, - skillImportSessionId: request.sessionId, - sessionCwd, - projectName - }) - let resumeResponse + // A Notebook bearer token is provisional until the resumed session is fully registered. Any + // later setup failure must revoke it so an unattached Agent cannot retain host.mcp/compute access. + let notebookCapabilityProvisional = Boolean(this.notebookOptions) + let session: ActiveSession | undefined try { - resumeResponse = await connection.agent.request(acp.methods.agent.session.resume, { - sessionId: request.sessionId, - cwd: sessionCwd, - mcpServers, - ...this.buildSessionMetaArg( - [], - await this.resolveCurrentSpecialistSkills(request.sessionId) - ) + // Resumed sessions already have stable ids, so the artifact session mirrors the runtime session + // id. + const mcpServers = await this.createMcpServers({ + artifactSessionId: request.sessionId, + notebookSessionId: request.sessionId, + skillImportSessionId: request.sessionId, + sessionCwd, + projectName }) - } catch (error) { - if (!isUnresumableSessionError(error)) throw error + let resumeResponse + try { + resumeResponse = await connection.agent.request(acp.methods.agent.session.resume, { + sessionId: request.sessionId, + cwd: sessionCwd, + mcpServers, + ...this.buildSessionMetaArg( + [], + await this.resolveCurrentSpecialistSkills(request.sessionId) + ) + }) + } catch (error) { + if (!isUnresumableSessionError(error)) throw error + + // The agent could not resume this session (an app restart spawned a fresh agent process that no + // longer holds it — surfacing as -32002 not-found or a generic -32603 Internal error). Revoke + // the token handed to that failed attempt before adopting a brand-new agent session under the + // SAME app id; adoptFreshSession owns the replacement token's lifecycle. + if (notebookCapabilityProvisional) { + this.releaseNotebookSessionCapabilities(request.sessionId) + notebookCapabilityProvisional = false + } + log.info('resumed session adopted after unrecoverable resume error', { + sessionId: request.sessionId, + ...errorLogFields(error) + }) - // The agent could not resume this session (an app restart spawned a fresh agent process that no - // longer holds it — surfacing as -32002 not-found or a generic -32603 Internal error). Rather - // than dead-end the thread, adopt a brand-new agent session under the SAME app id. - log.info('resumed session adopted after unrecoverable resume error', { + return await this.adoptFreshSession(connection, request, sessionCwd, projectName) + } + // The SDK exposes public helpers for new sessions only. The runtime keeps this adapter + // narrow so resume can reuse the same update routing surface as newly-created sessions. + session = (connection.agent as unknown as ClientContextSessionAttacher).attachSession({ sessionId: request.sessionId, - ...errorLogFields(error) + ...resumeResponse }) - return this.adoptFreshSession(connection, request, sessionCwd, projectName) - } - // The SDK exposes public helpers for new sessions only. The runtime keeps this adapter - // narrow so resume can reuse the same update routing surface as newly-created sessions. - const session = (connection.agent as unknown as ClientContextSessionAttacher).attachSession({ - sessionId: request.sessionId, - ...resumeResponse - }) - - try { await this.configurePermissionProfile( request.sessionId, session, normalizePermissionProfile(request.permissionProfile) ) - } catch (error) { - session.dispose() - throw error - } - const updatedConfigOptions = await this.applySessionModel(session) - await this.applySessionEffort(session, updatedConfigOptions) + const updatedConfigOptions = await this.applySessionModel(session) + await this.applySessionEffort(session, updatedConfigOptions) - this.sessions.set(request.sessionId, session) - if (updatedConfigOptions) { - this.latestSessionConfigOptions.set(request.sessionId, updatedConfigOptions) - } - this.sessionCwds.set(request.sessionId, sessionCwd) - this.sessionMcpServerNames.set(request.sessionId, this.mcpServerNamesOf(mcpServers)) - this.sessionProjectNames.set(request.sessionId, projectName) - this.sessionFrameworks.set(request.sessionId, this.framework.id) - if (this.backendId) this.sessionBackendIds.set(request.sessionId, this.backendId) - this.rememberArtifactSession(request.sessionId, request.sessionId) - this.rememberSkillImportSession(request.sessionId, request.sessionId) - this.currentSessionId = request.sessionId - this.cwd = sessionCwd - this.pushEvent({ - kind: 'system', - level: 'info', - sessionId: request.sessionId, - title: 'Session resumed', - text: sessionCwd - }) - this.emitState() + this.sessions.set(request.sessionId, session) + if (updatedConfigOptions) { + this.latestSessionConfigOptions.set(request.sessionId, updatedConfigOptions) + } + this.sessionCwds.set(request.sessionId, sessionCwd) + this.sessionMcpServerNames.set(request.sessionId, this.mcpServerNamesOf(mcpServers)) + this.sessionProjectNames.set(request.sessionId, projectName) + this.sessionFrameworks.set(request.sessionId, this.framework.id) + if (this.backendId) this.sessionBackendIds.set(request.sessionId, this.backendId) + this.rememberArtifactSession(request.sessionId, request.sessionId) + this.rememberSkillImportSession(request.sessionId, request.sessionId) + this.currentSessionId = request.sessionId + this.cwd = sessionCwd + this.pushEvent({ + kind: 'system', + level: 'info', + sessionId: request.sessionId, + title: 'Session resumed', + text: sessionCwd + }) + this.emitState() + notebookCapabilityProvisional = false - return { - sessionId: request.sessionId, - cwd: sessionCwd, - frameworkId: this.framework.id, - ...(this.backendId ? { backendId: this.backendId } : {}) + return { + sessionId: request.sessionId, + cwd: sessionCwd, + frameworkId: this.framework.id, + ...(this.backendId ? { backendId: this.backendId } : {}) + } + } catch (error) { + session?.dispose() + if (notebookCapabilityProvisional) { + this.releaseNotebookSessionCapabilities(request.sessionId) + } + throw error } } @@ -2096,61 +2152,70 @@ class AcpRuntime { sessionCwd: string, projectName: string ): Promise { - const mcpServers = await this.createMcpServers({ - artifactSessionId: request.sessionId, - notebookSessionId: request.sessionId, - skillImportSessionId: request.sessionId, - sessionCwd, - projectName - }) - // A freshly adopted session carries no prior _meta, so the specialist identity append (Claude) - // must be re-resolved from the live binding. Without this, a context reset or specialist switch - // would silently drop the session's specialist identity. - const specialistAppend = await this.resolveCurrentSpecialistIdentityAppend(request.sessionId) - const adopted = await connection.agent - .buildSession({ - cwd: sessionCwd, - mcpServers, - ...this.buildSessionMetaArg( - specialistAppend ? [specialistAppend] : [], - await this.resolveCurrentSpecialistSkills(request.sessionId) - ) + // Fresh adoption also receives a provisional Notebook bearer token. Transfer ownership only after + // adoptSession has registered the replacement; every earlier failure revokes it and disposes any + // partially-created Agent session. + let notebookCapabilityProvisional = Boolean(this.notebookOptions) + let adopted: ActiveSession | undefined + try { + const mcpServers = await this.createMcpServers({ + artifactSessionId: request.sessionId, + notebookSessionId: request.sessionId, + skillImportSessionId: request.sessionId, + sessionCwd, + projectName }) - .start() + // A freshly adopted session carries no prior _meta, so the specialist identity append (Claude) + // must be re-resolved from the live binding. Without this, a context reset or specialist switch + // would silently drop the session's specialist identity. + const specialistAppend = await this.resolveCurrentSpecialistIdentityAppend(request.sessionId) + adopted = await connection.agent + .buildSession({ + cwd: sessionCwd, + mcpServers, + ...this.buildSessionMetaArg( + specialistAppend ? [specialistAppend] : [], + await this.resolveCurrentSpecialistSkills(request.sessionId) + ) + }) + .start() - try { await this.configurePermissionProfile( request.sessionId, adopted, normalizePermissionProfile(request.permissionProfile) ) - } catch (error) { - adopted.dispose() - throw error - } - const updatedConfigOptions = await this.applySessionModel(adopted) - await this.applySessionEffort(adopted, updatedConfigOptions) - this.adoptSession( - request.sessionId, - adopted, - sessionCwd, - projectName, - this.mcpServerNamesOf(mcpServers) - ) - // Keyed by the agent session id, matching the live-effort lookup over this.sessions values: - // an adopted session's agent id differs from the app id it is registered under. - if (updatedConfigOptions) { - this.latestSessionConfigOptions.set(adopted.sessionId, updatedConfigOptions) - } - this.emitState() + const updatedConfigOptions = await this.applySessionModel(adopted) + await this.applySessionEffort(adopted, updatedConfigOptions) + this.adoptSession( + request.sessionId, + adopted, + sessionCwd, + projectName, + this.mcpServerNamesOf(mcpServers) + ) + // Keyed by the agent session id, matching the live-effort lookup over this.sessions values: + // an adopted session's agent id differs from the app id it is registered under. + if (updatedConfigOptions) { + this.latestSessionConfigOptions.set(adopted.sessionId, updatedConfigOptions) + } + this.emitState() + notebookCapabilityProvisional = false - return { - sessionId: request.sessionId, - cwd: sessionCwd, - frameworkId: this.framework.id, - ...(this.backendId ? { backendId: this.backendId } : {}), - contextReset: true + return { + sessionId: request.sessionId, + cwd: sessionCwd, + frameworkId: this.framework.id, + ...(this.backendId ? { backendId: this.backendId } : {}), + contextReset: true + } + } catch (error) { + adopted?.dispose() + if (notebookCapabilityProvisional) { + this.releaseNotebookSessionCapabilities(request.sessionId) + } + throw error } } @@ -2180,8 +2245,8 @@ class AcpRuntime { } // Revokes an app-owned session grant so the next matching tool call prompts again. - revokePermissionGrant(request: AcpRevokePermissionGrantRequest): AcpStateSnapshot { - this.permissionBroker.revokeGrant(request.sessionId, request.categoryKey) + async revokePermissionGrant(request: AcpRevokePermissionGrantRequest): Promise { + await this.permissionBroker.revokeGrant(request.sessionId, request.categoryKey) this.emitState() return this.getSnapshot() @@ -2447,6 +2512,8 @@ class AcpRuntime { this.contextUsageUpdatedPromptTurnsBySession.clear() this.appliedSessionModels.clear() + this.releaseAllNotebookSessionCapabilities() + for (const session of this.sessions.values()) { session.dispose() } @@ -3244,6 +3311,8 @@ class AcpRuntime { this.sessionSpecialistPrefixes.delete(request.sessionId) this.sessionSpecialistIds.delete(request.sessionId) + this.releaseNotebookSessionCapabilities(request.sessionId) + // Only announce a deletion and shift the current session when something was actually attached; a // detached cleanup (post-switch) must not emit a spurious event or move currentSessionId. if (session) { @@ -3264,15 +3333,25 @@ class AcpRuntime { } // Resolves or cancels one pending permission request from the renderer. - respondToPermission(response: AcpPermissionResponse): AcpStateSnapshot { - const handled = this.permissionBroker.respond(response) - - this.pushEvent({ - kind: 'permission', - level: handled ? 'info' : 'warning', - title: handled ? 'Permission response sent' : 'Permission request not found', - text: response.cancelled ? 'cancelled' : response.optionId - }) + async respondToPermission(response: AcpPermissionResponse): Promise { + try { + const handled = await this.permissionBroker.respond(response) + this.pushEvent({ + kind: 'permission', + level: handled ? 'info' : 'warning', + title: handled ? 'Permission response sent' : 'Permission request not found', + text: response.cancelled ? 'cancelled' : response.optionId + }) + } catch (error) { + this.pushEvent({ + kind: 'permission', + level: 'error', + title: 'Permission approval could not be saved', + text: error instanceof Error ? error.message : 'The tool call was cancelled.' + }) + this.emitState() + throw error + } this.emitState() return this.getSnapshot() @@ -3954,6 +4033,25 @@ class AcpRuntime { this.notebookOptions.registerSessionAlias?.(notebookSessionId, sessionId) } + // Capability cleanup is best-effort and must never replace the session lifecycle error that caused + // it. The production callback only mutates local maps, while this guard keeps injected/test adapters + // from masking the original failure. + private releaseNotebookSessionCapabilities(sessionId: string): void { + try { + this.notebookOptions?.releaseSessionCapabilities?.(sessionId) + } catch (error) { + safeLogError('release notebook session capabilities failed', { + ...diagnosticErrorFields(error), + ...this.diagnosticContext() + }) + } + } + + private releaseAllNotebookSessionCapabilities(): void { + const sessionIds = new Set([...this.sessions.keys(), ...this.notebookRoutingIds.keys()]) + for (const sessionId of sessionIds) this.releaseNotebookSessionCapabilities(sessionId) + } + private createSkillImportSessionId(): string { if (!this.skillImportOptions) return '' @@ -3990,7 +4088,7 @@ class AcpRuntime { ): Promise { if (!this.notebookOptions || !notebookSessionId) return undefined - const connection = await this.resolveNotebookRpcConnection() + const connection = await this.resolveNotebookRpcConnection(notebookSessionId, projectName) return { endpoint: connection.endpoint, @@ -4025,13 +4123,16 @@ class AcpRuntime { } // Resolves either a static test connection or the real app-local RPC server connection. - private async resolveNotebookRpcConnection(): Promise { + private async resolveNotebookRpcConnection( + sessionId: string, + projectId: string + ): Promise { if (!this.notebookOptions) { throw new Error('Notebook runtime is not configured.') } if (this.notebookOptions.getRpcConnection) { - return this.notebookOptions.getRpcConnection() + return this.notebookOptions.getRpcConnection({ sessionId, projectId }) } throw new Error('Notebook runtime RPC connection is not configured.') @@ -4721,7 +4822,8 @@ class AcpRuntime { this.framework.id, autoReviewStrategy: profileState?.autoReviewStrategy, cwd: this.sessionCwds.get(appSessionId), - mcpServerNames + mcpServerNames, + projectId: this.resolveSessionProjectName(appSessionId) } ) } catch (error) { @@ -4777,7 +4879,12 @@ class AcpRuntime { if (framework === 'claude-code') { const title = event.title - if (!title || !isMcpToolName(title, mcpServerNames) || !isRecord(event.rawInput)) return + if (!title || !isMcpToolName(title, mcpServerNames)) return + const providerToolName = event.providerToolName ?? title + const mcpIdentity = + resolveCanonicalMcpToolIdentity(providerToolName, mcpServerNames) ?? + resolveCanonicalMcpToolIdentity(title, mcpServerNames) + if (!mcpIdentity) return const inputs = this.claudeCodeMcpToolInputs.get(sessionId) ?? new Map() this.setBoundedPermissionToolContext( @@ -4785,8 +4892,9 @@ class AcpRuntime { event.toolCallId, { title, - providerToolName: event.providerToolName ?? title, - rawInput: event.rawInput + providerToolName, + mcpIdentity, + ...(isRecord(event.rawInput) ? { rawInput: event.rawInput } : {}) }, MAX_CLAUDE_CODE_MCP_TOOL_INPUTS_PER_SESSION ) @@ -4800,6 +4908,18 @@ class AcpRuntime { closedToolCalls?.delete(event.toolCallId) if (closedToolCalls?.size === 0) this.closedOpenCodeToolCalls.delete(sessionId) } + if (isOpenCodeNativeSkillToolCall(update)) { + const calls = this.opencodeNativeSkillToolCalls.get(sessionId) ?? new Map() + this.setBoundedPermissionToolContext( + calls, + event.toolCallId, + true, + MAX_OPENCODE_MCP_TOOL_INPUTS_PER_SESSION + ) + this.opencodeNativeSkillToolCalls.set(sessionId, calls) + this.resolveOpenCodeMcpToolInputWaiters(sessionId, event.toolCallId) + return + } const originalRawInput = update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update' ? update.rawInput @@ -4808,20 +4928,30 @@ class AcpRuntime { const previous = inputs.get(event.toolCallId) const title = event.title ?? previous?.title if (!title || !isMcpToolName(title, mcpServerNames)) return + const canReusePreviousIdentity = event.title == null || event.title === previous?.title + const providerToolName = + event.providerToolName ?? + (canReusePreviousIdentity ? previous?.providerToolName : undefined) ?? + title + const mcpIdentity = + resolveCanonicalMcpToolIdentity(providerToolName, mcpServerNames) ?? + resolveCanonicalMcpToolIdentity(title, mcpServerNames) ?? + (canReusePreviousIdentity ? previous?.mcpIdentity : undefined) + if (!mcpIdentity) return const rawInput = boundedNotebookPermissionInput(title, originalRawInput, mcpServerNames) ?? event.rawInput const hasRawInput = isRecord(rawInput) && Object.keys(rawInput).length > 0 - const canReusePreviousRawInput = event.title == null || event.title === previous?.title const next: OpenCodeMcpToolInput = { title, // OpenCode may omit _meta.toolName from the later permission request. Retain the identity only // from a preceding MCP-classified tool event with the same toolCallId/title; an isolated // permission title never reaches this cache and therefore cannot obtain an automatic grant. - providerToolName: event.providerToolName ?? title, + providerToolName, + mcpIdentity, ...(hasRawInput ? { rawInput } - : canReusePreviousRawInput && previous?.rawInput !== undefined + : canReusePreviousIdentity && previous?.rawInput !== undefined ? { rawInput: previous.rawInput } : {}) } @@ -4847,12 +4977,22 @@ class AcpRuntime { return this.restoreClaudeCodeMcpToolInput(params, appSessionId, mcpServerNames) } if (framework === 'opencode') { + const trustedNativeSkill = this.restoreOpenCodeNativeSkillPermission(params, appSessionId) + if (trustedNativeSkill !== params) return trustedNativeSkill + const restored = this.restoreOpenCodeMcpToolInput(params, appSessionId, mcpServerNames) - if (restored !== params || !isMcpToolName(params.toolCall.title, mcpServerNames)) { + const isMcpRequest = isMcpToolName(params.toolCall.title, mcpServerNames) + const isNativeSkillCandidate = + params.toolCall.kind === 'other' && params.toolCall.title?.trim().toLowerCase() === 'skill' + if (!isMcpRequest && !isNativeSkillCandidate) { return restored } - if (isRecord(params.toolCall.rawInput) && Object.keys(params.toolCall.rawInput).length > 0) { - return params + if ( + isMcpRequest && + isRecord(restored.toolCall.rawInput) && + Object.keys(restored.toolCall.rawInput).length > 0 + ) { + return restored } const outcome = await this.waitForOpenCodeMcpToolInput( @@ -4868,6 +5008,8 @@ class AcpRuntime { waitMs: OPENCODE_PERMISSION_CONTEXT_WAIT_MS }) } + const restoredNativeSkill = this.restoreOpenCodeNativeSkillPermission(params, appSessionId) + if (restoredNativeSkill !== params) return restoredNativeSkill return this.restoreOpenCodeMcpToolInput(params, appSessionId, mcpServerNames) } if (framework !== 'codex' || !isCodexMcpApproval(params)) return params @@ -4906,24 +5048,27 @@ class AcpRuntime { const inputs = this.claudeCodeMcpToolInputs.get(appSessionId) const input = inputs?.get(params.toolCall.toolCallId) - if (!input || input.title !== title || !isMcpToolName(input.providerToolName, mcpServerNames)) { + if (!input || input.title !== title) { return params } inputs?.delete(params.toolCall.toolCallId) if (inputs?.size === 0) this.claudeCodeMcpToolInputs.delete(appSessionId) - return { - ...params, - toolCall: { - ...params.toolCall, - rawInput: params.toolCall.rawInput ?? input.rawInput, - _meta: { - ...(isRecord(params.toolCall._meta) ? params.toolCall._meta : {}), - toolName: input.providerToolName + return withTrustedMcpToolIdentity( + { + ...params, + toolCall: { + ...params.toolCall, + rawInput: params.toolCall.rawInput ?? input.rawInput, + _meta: { + ...(isRecord(params.toolCall._meta) ? params.toolCall._meta : {}), + toolName: input.providerToolName + } } - } - } + }, + input.mcpIdentity + ) } private waitForOpenCodeMcpToolInput( @@ -4934,6 +5079,9 @@ class AcpRuntime { if (this.shouldCancelOpenCodePermission(sessionId, toolCallId, promptTurn)) { return Promise.resolve('cancelled') } + if (this.opencodeNativeSkillToolCalls.get(sessionId)?.has(toolCallId)) { + return Promise.resolve('ready') + } const rawInput = this.opencodeMcpToolInputs.get(sessionId)?.get(toolCallId)?.rawInput if (isRecord(rawInput) && Object.keys(rawInput).length > 0) { return Promise.resolve('ready') @@ -4964,6 +5112,10 @@ class AcpRuntime { finish('cancelled') return } + if (this.opencodeNativeSkillToolCalls.get(sessionId)?.has(toolCallId)) { + finish('ready') + return + } const latestRawInput = this.opencodeMcpToolInputs.get(sessionId)?.get(toolCallId)?.rawInput if (isRecord(latestRawInput) && Object.keys(latestRawInput).length > 0) finish('ready') }) @@ -4982,6 +5134,7 @@ class AcpRuntime { private clearOpenCodePermissionToolContext(sessionId: string): void { this.opencodeMcpToolInputs.delete(sessionId) + this.opencodeNativeSkillToolCalls.delete(sessionId) this.closedOpenCodeToolCalls.delete(sessionId) const sessionWaiters = this.opencodeMcpToolInputWaiters.get(sessionId) if (!sessionWaiters) return @@ -4993,6 +5146,7 @@ class AcpRuntime { private clearAllOpenCodePermissionToolContext(): void { this.opencodeMcpToolInputs.clear() + this.opencodeNativeSkillToolCalls.clear() for (const sessionId of Array.from(this.opencodeMcpToolInputWaiters.keys())) { this.clearOpenCodePermissionToolContext(sessionId) } @@ -5031,29 +5185,52 @@ class AcpRuntime { const inputs = this.opencodeMcpToolInputs.get(appSessionId) const input = inputs?.get(params.toolCall.toolCallId) - if (!input || input.title !== title || !isMcpToolName(input.providerToolName, mcpServerNames)) { + if (!input || input.title !== title) { return params } const requestHasInput = isRecord(params.toolCall.rawInput) && Object.keys(params.toolCall.rawInput).length > 0 const cachedHasInput = isRecord(input.rawInput) && Object.keys(input.rawInput).length > 0 - if (!requestHasInput && !cachedHasInput) return params - - inputs?.delete(params.toolCall.toolCallId) - if (inputs?.size === 0) this.opencodeMcpToolInputs.delete(appSessionId) + if (requestHasInput || cachedHasInput) { + inputs?.delete(params.toolCall.toolCallId) + if (inputs?.size === 0) this.opencodeMcpToolInputs.delete(appSessionId) + } - return { - ...params, - toolCall: { - ...params.toolCall, - rawInput: requestHasInput ? params.toolCall.rawInput : input.rawInput, - _meta: { - ...(isRecord(params.toolCall._meta) ? params.toolCall._meta : {}), - toolName: input.providerToolName + return withTrustedMcpToolIdentity( + { + ...params, + toolCall: { + ...params.toolCall, + rawInput: requestHasInput ? params.toolCall.rawInput : input.rawInput, + _meta: { + ...(isRecord(params.toolCall._meta) ? params.toolCall._meta : {}), + toolName: input.providerToolName + } } - } + }, + input.mcpIdentity + ) + } + + private restoreOpenCodeNativeSkillPermission( + params: RequestPermissionRequest, + appSessionId: string + ): RequestPermissionRequest { + const calls = this.opencodeNativeSkillToolCalls.get(appSessionId) + if (!calls?.delete(params.toolCall.toolCallId)) return params + if (calls.size === 0) this.opencodeNativeSkillToolCalls.delete(appSessionId) + + const providerToolName = extractProviderToolName(params.toolCall)?.trim().toLowerCase() + if ( + params.toolCall.kind !== 'other' || + params.toolCall.title?.trim().toLowerCase() !== 'skill' || + (providerToolName != null && providerToolName !== 'skill') + ) { + return params } + + return withTrustedNativeToolIdentity(params, 'opencode/skill') } private setBoundedPermissionToolContext( @@ -5081,6 +5258,9 @@ class AcpRuntime { const opencodeInputs = this.opencodeMcpToolInputs.get(sessionId) opencodeInputs?.delete(toolCallId) if (opencodeInputs?.size === 0) this.opencodeMcpToolInputs.delete(sessionId) + const opencodeNativeSkills = this.opencodeNativeSkillToolCalls.get(sessionId) + opencodeNativeSkills?.delete(toolCallId) + if (opencodeNativeSkills?.size === 0) this.opencodeNativeSkillToolCalls.delete(sessionId) if (this.sessionFrameworks.get(sessionId) === 'opencode') { const closedToolCalls = this.closedOpenCodeToolCalls.get(sessionId) ?? new Set() if ( @@ -5596,6 +5776,7 @@ class AcpRuntime { this.skillSelectorAbortControllers.clear() this.permissionBroker.cancelAllPending() this.clearReviewerSessionState() + this.releaseAllNotebookSessionCapabilities() this.sessions.clear() this.sessionCwds.clear() this.sessionInlineImageBytes.clear() diff --git a/src/main/agent-framework/app-mcp-names.test.ts b/src/main/agent-framework/app-mcp-names.test.ts new file mode 100644 index 000000000..0ff236064 --- /dev/null +++ b/src/main/agent-framework/app-mcp-names.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { PRE_REGISTERED_PERMISSION_IDENTITIES } from '../permission-grants/identity-catalog' +import { appMcpServerAliases, resolveCanonicalMcpToolIdentity } from './app-mcp-names' + +const APP_MCP_CODEC_CASES = PRE_REGISTERED_PERMISSION_IDENTITIES.mcp_tool.flatMap((key) => { + const identity = key.slice('mcp:'.length) + const separator = identity.indexOf('/') + const server = identity.slice(0, separator) + const tool = identity.slice(separator + 1) + const safeServer = server.replace(/[^a-zA-Z0-9_]/g, '_') + return [ + [server, `mcp__${safeServer}__${tool}`, identity], + [server, `mcp.${server}.${tool}`, identity], + [server, `${safeServer}_${tool}`, identity] + ] as const +}) + +describe('resolveCanonicalMcpToolIdentity', () => { + it.each([ + 'mcp__open-science-notebook__notebook_execute', + 'mcp.open-science-notebook.notebook_execute', + 'open_science_notebook_notebook_execute' + ])('normalizes a configured framework alias %s', (reportedName) => { + expect(resolveCanonicalMcpToolIdentity(reportedName, ['open-science-notebook'])).toBe( + 'open-science-notebook/notebook_execute' + ) + }) + + it('does not turn an unregistered Claude MCP prefix into durable identity', () => { + expect( + resolveCanonicalMcpToolIdentity('mcp__reported-only__dangerous_tool', []) + ).toBeUndefined() + }) + + it.each(APP_MCP_CODEC_CASES)( + 'maps every registered app MCP identity from %s using provider name %s', + (server, reportedName, identity) => { + expect(resolveCanonicalMcpToolIdentity(reportedName, [server])).toBe(identity) + } + ) + + it('normalizes framework-safe aliases for configured dynamic servers', () => { + expect(appMcpServerAliases('custom-server')).toEqual(['custom-server', 'custom_server']) + expect(resolveCanonicalMcpToolIdentity('mcp__custom_server__lookup', ['custom-server'])).toBe( + 'custom-server/lookup' + ) + expect(resolveCanonicalMcpToolIdentity('mcp.custom_server.lookup', ['custom-server'])).toBe( + 'custom-server/lookup' + ) + expect(resolveCanonicalMcpToolIdentity('custom_server_lookup', ['custom-server'])).toBe( + 'custom-server/lookup' + ) + }) + + it('rejects a sanitized dynamic server alias when configured names collide', () => { + expect( + resolveCanonicalMcpToolIdentity('mcp__custom_server__lookup', [ + 'custom-server', + 'custom_server' + ]) + ).toBeUndefined() + }) +}) diff --git a/src/main/agent-framework/app-mcp-names.ts b/src/main/agent-framework/app-mcp-names.ts index 25c348e7c..afa0af00e 100644 --- a/src/main/agent-framework/app-mcp-names.ts +++ b/src/main/agent-framework/app-mcp-names.ts @@ -51,6 +51,8 @@ const APP_MCP_SERVER_BY_OPENCODE_NAME = new Map( APP_MCP_SERVERS.map((definition) => [definition.openCodeName, definition]) ) +const frameworkSafeMcpServerName = (name: string): string => name.replace(/[^a-zA-Z0-9_]/g, '_') + const canonicalAppMcpServerName = (name: string): string => APP_MCP_SERVER_BY_OPENCODE_NAME.get(name)?.canonicalName ?? name @@ -65,7 +67,13 @@ const appMcpServerAliases = (name: string): readonly string[] => { const canonicalName = canonicalAppMcpServerName(name) const definition = APP_MCP_SERVER_BY_CANONICAL_NAME.get(canonicalName) - return definition ? [definition.canonicalName, definition.openCodeName] : [canonicalName] + return [ + ...new Set( + definition + ? [definition.canonicalName, definition.openCodeName] + : [canonicalName, frameworkSafeMcpServerName(canonicalName)] + ) + ] } const resolveCanonicalMcpToolIdentity = ( @@ -77,14 +85,20 @@ const resolveCanonicalMcpToolIdentity = ( const canonicalServers = [ ...new Set(mcpServerNames.map((server) => canonicalAppMcpServerName(server))) ] - const configuredServerFor = (reportedServer: string): string | undefined => - canonicalServers.find((server) => appMcpServerAliases(server).includes(reportedServer)) + const configuredServerFor = (reportedServer: string): string | undefined => { + const matches = canonicalServers.filter((server) => + appMcpServerAliases(server).includes(reportedServer) + ) + return matches.length === 1 ? matches[0] : undefined + } if (name.startsWith('mcp__')) { const [reportedServer, ...toolParts] = name.slice('mcp__'.length).split('__') if (!reportedServer || toolParts.length === 0) return undefined + const server = configuredServerFor(reportedServer) + if (!server) return undefined - return `${configuredServerFor(reportedServer) ?? reportedServer}/${toolParts.join('__')}` + return `${server}/${toolParts.join('__')}` } const serverAliases = canonicalServers diff --git a/src/main/agent-framework/opencode.test.ts b/src/main/agent-framework/opencode.test.ts index 62ddcd3ff..2fe418016 100644 --- a/src/main/agent-framework/opencode.test.ts +++ b/src/main/agent-framework/opencode.test.ts @@ -67,18 +67,10 @@ describe('opencodeFramework.prepareModelConfig', () => { const rules = JSON.parse(config.env?.OPENCODE_CONFIG_CONTENT ?? '{}').permission expect(rules['*']).toBe('ask') - for (const tool of ['read', 'glob', 'grep', 'list', 'lsp']) { + for (const tool of ['read', 'glob', 'grep', 'list', 'lsp', 'skill']) { expect(rules[tool]).toBe('allow') } - for (const tool of [ - 'edit', - 'bash', - 'task', - 'skill', - 'webfetch', - 'websearch', - 'external_directory' - ]) { + for (const tool of ['edit', 'bash', 'task', 'webfetch', 'websearch', 'external_directory']) { expect(rules[tool]).toBe('ask') } }) @@ -405,17 +397,10 @@ describe('buildOpencodeConfig', () => { ) // Our rules override the base for every side-effecting built-in. - for (const tool of [ - 'edit', - 'bash', - 'task', - 'skill', - 'webfetch', - 'websearch', - 'external_directory' - ]) { + for (const tool of ['edit', 'bash', 'task', 'webfetch', 'websearch', 'external_directory']) { expect(config.permission[tool]).toBe('ask') } + expect(config.permission.skill).toBe('allow') }) it('delegates every side-effecting tool (incl. MCP) via a "*" catch-all, allowing safe reads', () => { @@ -427,7 +412,7 @@ describe('buildOpencodeConfig', () => { expect(config.permission['*']).toBe('ask') // Safe read-only tools run without prompting (parity with Claude's Ask mode). - for (const tool of ['read', 'glob', 'grep', 'list', 'lsp']) { + for (const tool of ['read', 'glob', 'grep', 'list', 'lsp', 'skill']) { expect(config.permission[tool]).toBe('allow') } // Mutating/external tools are pinned to ask (and unlisted MCP tools fall through to "*" → ask). diff --git a/src/main/agent-framework/opencode.ts b/src/main/agent-framework/opencode.ts index 0e1ce5f64..8e15f4607 100644 --- a/src/main/agent-framework/opencode.ts +++ b/src/main/agent-framework/opencode.ts @@ -83,8 +83,9 @@ const opencodeOutputLimit = (contextWindow: number, configured?: unknown): numbe } // The app's permission policy for opencode: every side-effecting/MCP tool must ASK the ACP client (the -// app's broker then enforces the selected profile); only safe read-only tools run silently (parity with -// Claude's Ask mode). The `*` catch-all covers unlisted tools (MCP artifact/notebook/connectors, etc.), +// app's broker then enforces the selected profile); safe read-only tools and OpenCode's native skill +// loader run silently (parity with other Agent frameworks). The `*` catch-all covers unlisted tools +// (MCP artifact/notebook/connectors, etc.), // and the sensitive built-ins are pinned to `ask` explicitly so a lower-precedence config that sets one // of those keys to `allow` is overridden rather than winning. Enforced via the OPENCODE_CONFIG_CONTENT // layer (see prepareModelConfig), which also disables project config entirely — the config-file block is @@ -99,7 +100,9 @@ const OPENCODE_PERMISSION_RULES: Record = { edit: 'ask', bash: 'ask', task: 'ask', - skill: 'ask', + // Skill loading only reads definitions already provisioned into the isolated OpenCode config. + // Permission for creating/editing/enabling those definitions remains app-owned elsewhere. + skill: 'allow', webfetch: 'ask', websearch: 'ask', external_directory: 'ask' diff --git a/src/main/compute/compute-approval-broker.test.ts b/src/main/compute/compute-approval-broker.test.ts index 107da6b06..80e155082 100644 --- a/src/main/compute/compute-approval-broker.test.ts +++ b/src/main/compute/compute-approval-broker.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { ComputeApprovalBroker } from './compute-approval-broker' import type { ComputeApprovalRequest } from '../../shared/compute' @@ -113,6 +113,89 @@ describe('ComputeApprovalBroker', () => { expect(() => broker.respond('nope', 'once')).not.toThrow() }) + it('denies a pending approval when its compute provider is invalidated', async () => { + const timer = makeTimer() + const remember = vi.fn() + const broker = new ComputeApprovalBroker({ + generateId: () => 'id-1', + broadcast: () => undefined, + setTimer: timer.set, + clearTimer: timer.clear, + permissionGrants: { resolve: vi.fn(), remember } as never + }) + + const decision = broker.requestWithContext(makeRequest(), { + sessionId: 'session-1', + projectId: 'project-1', + operation: 'call_command', + ownerId: 'host-row-1' + }) + await Promise.resolve() + broker.invalidateProvider('ssh:biowulf') + broker.respond('id-1', 'global') + + await expect(decision).resolves.toBe('deny') + expect(remember).not.toHaveBeenCalled() + }) + + it('does not remember approval when the provider id belongs to a recreated host', async () => { + const timer = makeTimer() + const remember = vi.fn() + const isProviderCurrent = vi.fn().mockResolvedValue(false) + const broker = new ComputeApprovalBroker({ + generateId: () => 'id-1', + broadcast: () => undefined, + setTimer: timer.set, + clearTimer: timer.clear, + permissionGrants: { resolve: vi.fn(), remember } as never, + isProviderCurrent + }) + + const decision = broker.requestWithContext(makeRequest(), { + sessionId: 'session-1', + projectId: 'project-1', + operation: 'call_command', + ownerId: 'deleted-host-row' + }) + await Promise.resolve() + broker.respond('id-1', 'project') + + await expect(decision).resolves.toBe('deny') + expect(isProviderCurrent).toHaveBeenCalledWith({ + providerId: 'ssh:biowulf', + ownerId: 'deleted-host-row' + }) + expect(remember).not.toHaveBeenCalled() + }) + + it('does not auto-allow an existing grant for a replacement host with the same provider id', async () => { + const broadcast = vi.fn() + const isProviderCurrent = vi.fn().mockResolvedValue(false) + const broker = new ComputeApprovalBroker({ + generateId: () => 'id-1', + broadcast, + permissionGrants: { + resolve: vi.fn().mockResolvedValue('project'), + remember: vi.fn() + } as never, + isProviderCurrent + }) + + await expect( + broker.requestWithContext(makeRequest(), { + sessionId: 'session-1', + projectId: 'project-1', + operation: 'call_command', + ownerId: 'replacement-host-row' + }) + ).resolves.toBe('deny') + expect(isProviderCurrent).toHaveBeenCalledWith({ + providerId: 'ssh:biowulf', + ownerId: 'replacement-host-row' + }) + expect(broadcast).not.toHaveBeenCalled() + }) + // ── conversation scope ──────────────────────────────────────────────────────────── it('records a conversation grant and skips the card on a matching second request', async () => { const timer = makeTimer() diff --git a/src/main/compute/compute-approval-broker.ts b/src/main/compute/compute-approval-broker.ts index a59f1762c..550afa0b5 100644 --- a/src/main/compute/compute-approval-broker.ts +++ b/src/main/compute/compute-approval-broker.ts @@ -1,17 +1,21 @@ import type { ComputeApprovalRequest, ComputeApprovalDecision } from '../../shared/compute' +import type { ComputePermissionGrantAdapter } from './permission-grant-adapter' // Re-export so callers that import from this module don't have to reference shared/compute directly. export type { ComputeApprovalDecision } // Context passed with each approval request so the broker can check and record grants. export type ComputeApprovalContext = { - // Unique identifier for the current session (process lifetime). Used as the key for - // conversation-scope in-memory grants. A new process → no conversation grants. + // Stable logical Session identifier. The durable adapter persists it across process restarts; + // the legacy fallback below keeps the former in-memory behavior for isolated callers and tests. sessionId: string // Project identifier used for project-scope persistent grants. projectId: string // The compute operation being approved (e.g. 'call_command'). operation: string + // Immutable ComputeHost row id captured with the request. Provider ids are reusable, so this + // distinguishes a deleted host from a later host created with the same SSH alias. + ownerId?: string } type ComputeApprovalBrokerDeps = { @@ -24,6 +28,7 @@ type ComputeApprovalBrokerDeps = { // Injectable timer for tests. setTimer?: (fn: () => void, ms: number) => ReturnType clearTimer?: (handle: ReturnType) => void + permissionGrants?: ComputePermissionGrantAdapter // Optional: check whether a project-scope grant exists for (projectId, operation, providerId). // Return true → skip the approval card with 'project' decision. checkProjectGrant?: (grant: { @@ -37,16 +42,17 @@ type ComputeApprovalBrokerDeps = { operation: string providerId: string }) => Promise + // Revalidates the immutable host identity immediately before a remembered decision is persisted. + isProviderCurrent?: (owner: { providerId: string; ownerId?: string }) => Promise } // Bridges the main-process compute gate to the renderer approval card. Holds the call_command // open (a Promise) while the user decides; auto-denies after timeoutMs to prevent indefinite hangs. // Follows the same promise + broadcast + IPC-respond pattern as ApprovalBroker in connectors. // -// Issue 05 extends the issue-04 base with three approval scopes (design.md §6): -// - 'once': no memory; card shown every time -// - 'conversation': in-memory grants map per (sessionId, operation, providerId); cleared on restart -// - 'project': persisted via settings JSON per (projectId, operation, providerId) +// The wire protocol retains `conversation`, but the production adapter translates it to a durable +// Session grant. Project and Global use the same Registry; settings.json is read only for lazy legacy +// Project migration. Callers without the adapter retain the older in-memory/test hooks below. // // Use request() for legacy callers that do not supply context (only 'once'/'deny' can result). // Use requestWithContext() to enable grant memory. @@ -56,11 +62,13 @@ export class ComputeApprovalBroker { { resolve: (decision: ComputeApprovalDecision) => void timer: ReturnType + providerId: string } >() - // Conversation-scope in-memory grants. Key = `${sessionId}:${operation}:${providerId}`. - // Scoped to this broker instance (= one app session). A restart creates a new broker → no grants. + private readonly providerGenerations = new Map() + + // Legacy fallback used only when no durable adapter is supplied. private readonly conversationGrants = new Set() private readonly timeoutMs: number @@ -80,10 +88,11 @@ export class ComputeApprovalBroker { context?: ComputeApprovalContext ): Promise { const id = this.deps.generateId() + const providerId = info.provider_id return new Promise((resolve) => { const timer = this.setTimer(() => this.settle(id, 'deny'), this.timeoutMs) - this.pending.set(id, { resolve, timer }) + this.pending.set(id, { resolve, timer, providerId }) this.deps.broadcast({ id, ...info }, context) }) } @@ -96,8 +105,25 @@ export class ComputeApprovalBroker { ): Promise { const { sessionId, projectId, operation } = ctx const providerId = info.provider_id + const providerGeneration = this.providerGenerations.get(providerId) ?? 0 + + if (this.deps.permissionGrants) { + const durableScope = await this.deps.permissionGrants.resolve({ + sessionId, + projectId, + operation, + providerId + }) + if (durableScope) { + if (!(await this.isProviderCurrent(providerId, ctx.ownerId, providerGeneration))) { + return 'deny' + } + if (durableScope === 'session') return 'conversation' + return durableScope + } + } - // ── project grant check (persistent) ────────────────────────────────────────── + // ── legacy project grant check (persistent) ─────────────────────────────────── if (this.deps.checkProjectGrant) { const hasProject = await this.deps.checkProjectGrant({ projectId, operation, providerId }) if (hasProject) return 'project' @@ -110,8 +136,24 @@ export class ComputeApprovalBroker { // ── no grant — show approval card ───────────────────────────────────────────── const decision = await this.request(info, ctx) + if ((this.providerGenerations.get(providerId) ?? 0) !== providerGeneration) return 'deny' + + const remembersDecision = + decision === 'conversation' || decision === 'project' || decision === 'global' + if ( + remembersDecision && + !(await this.isProviderCurrent(providerId, ctx.ownerId, providerGeneration)) + ) { + return 'deny' + } + // Record grant if applicable. - if (decision === 'conversation') { + if (this.deps.permissionGrants) { + await this.deps.permissionGrants.remember( + { sessionId, projectId, operation, providerId }, + decision + ) + } else if (decision === 'conversation') { this.conversationGrants.add(convKey) } else if (decision === 'project' && this.deps.saveProjectGrant) { await this.deps.saveProjectGrant({ projectId, operation, providerId }) @@ -125,6 +167,33 @@ export class ComputeApprovalBroker { this.settle(id, decision) } + // Host deletion begins by advancing its generation and denying every approval card that was + // created for the old owner. A later host may reuse providerId, but it cannot reuse these calls. + invalidateProvider(providerId: string): void { + this.providerGenerations.set(providerId, (this.providerGenerations.get(providerId) ?? 0) + 1) + for (const key of this.conversationGrants) { + if (key.endsWith(`:${providerId}`)) this.conversationGrants.delete(key) + } + for (const [id, entry] of this.pending) { + if (entry.providerId === providerId) this.settle(id, 'deny') + } + } + + private async isProviderCurrent( + providerId: string, + ownerId: string | undefined, + expectedGeneration: number + ): Promise { + if ((this.providerGenerations.get(providerId) ?? 0) !== expectedGeneration) return false + if ( + this.deps.isProviderCurrent && + !(await this.deps.isProviderCurrent({ providerId, ownerId })) + ) { + return false + } + return (this.providerGenerations.get(providerId) ?? 0) === expectedGeneration + } + private settle(id: string, decision: ComputeApprovalDecision): void { const entry = this.pending.get(id) if (!entry) return diff --git a/src/main/compute/compute-service.ts b/src/main/compute/compute-service.ts index b14cc070b..98b9e0eea 100644 --- a/src/main/compute/compute-service.ts +++ b/src/main/compute/compute-service.ts @@ -644,8 +644,8 @@ export class ComputeService { // Executes a short remote command on the SSH host, preceded by an approval gate (design.md §6). // // When sessionId and projectId are supplied, grant memory is checked and recorded: - // - conversation: session in-memory grant (no card on repeat calls in the same session) - // - project: persisted to settings JSON (no card for that project after first approval) + // - conversation: durable logical Session grant (legacy wire name) + // - project/global: durable Registry grants at the corresponding scope // - once: no memory — card shown every time // // call_command does NOT count against the concurrent job limit (design.md §5). @@ -688,7 +688,8 @@ export class ComputeService { ? await this.approvalBroker.requestWithContext(approvalInfo, { sessionId: context.sessionId, projectId: context.projectId, - operation: 'call_command' + operation: 'call_command', + ownerId: host.id }) : await this.approvalBroker.request(approvalInfo) @@ -855,7 +856,8 @@ export class ComputeService { ? await this.approvalBroker.requestWithContext(approvalInfo, { sessionId: context.sessionId, projectId: context.projectId, - operation: 'download' + operation: 'download', + ownerId: host.id }) : await this.approvalBroker.request(approvalInfo) @@ -1234,7 +1236,8 @@ export class ComputeService { const decision = await this.approvalBroker.requestWithContext(approvalInfo, { sessionId: context.sessionId, projectId: context.projectId, - operation: 'submit_job' + operation: 'submit_job', + ownerId: host.id }) if (decision === 'deny') { diff --git a/src/main/compute/ipc.test.ts b/src/main/compute/ipc.test.ts index 44e095e49..c0f7457ec 100644 --- a/src/main/compute/ipc.test.ts +++ b/src/main/compute/ipc.test.ts @@ -8,6 +8,7 @@ import type { ComputeHost, ComputeJob, CreateComputeHostRequest } from '../../sh import type { DirListing, DownloadDest, LocalFile } from '../../shared/remote-fs' import { decodeRemoteFsError } from '../../shared/remote-fs' import type { ComputeService } from './compute-service' +import type { ComputeApprovalBroker } from './compute-approval-broker' import { COMPUTE_JOB_UPDATED_CHANNEL, COMPUTE_JOBS_LIST_CHANNEL, @@ -21,6 +22,7 @@ import type { ComputeJobRepository } from './job-repository' import type { ComputeHostRepository } from './repository' import { EnabledComputeHostsRegistry } from './enabled-hosts-registry' import { addRendererBroadcastSink } from '../renderer-broadcast' +import type { PermissionGrantRegistry } from '../permission-grants/registry' // --------------------------------------------------------------------------- // electron mock — captures ipcMain.handle registrations and stubs BrowserWindow @@ -322,11 +324,13 @@ describe('host delete guard', () => { const del = vi.fn(() => Promise.resolve()) const list = vi.fn(() => Promise.resolve([])) const hasActive = vi.fn(() => Promise.resolve(false)) + const invalidateProvider = vi.fn() + const broker = { invalidateProvider } as unknown as ComputeApprovalBroker const handlers = createComputeHandlers( mockRepository({ delete: del, list }), undefined, undefined, - undefined, + broker, undefined, mockJobRepo({ hasActiveJobsForProvider: hasActive }) ) @@ -334,6 +338,10 @@ describe('host delete guard', () => { await handlers.delete('ssh:biowulf') expect(del).toHaveBeenCalledWith('ssh:biowulf') expect(hasActive).toHaveBeenCalledWith('ssh:biowulf') + expect(invalidateProvider).toHaveBeenCalledWith('ssh:biowulf') + expect(invalidateProvider.mock.invocationCallOrder[0]).toBeLessThan( + del.mock.invocationCallOrder[0] + ) }) it('allows deletion when no jobRepository is provided (backward compatibility)', async () => { @@ -344,6 +352,58 @@ describe('host delete guard', () => { await handlers.delete('ssh:biowulf') expect(del).toHaveBeenCalledWith('ssh:biowulf') }) + + it('does not expose a replacement provider id until deletion grant cleanup completes', async () => { + let releaseDeletePrune: (() => void) | undefined + let pruneCalls = 0 + const prune = vi.fn(() => { + pruneCalls += 1 + if (pruneCalls === 1) { + return new Promise<[]>((resolve) => { + releaseDeletePrune = () => resolve([]) + }) + } + return Promise.resolve([]) + }) + const del = vi.fn().mockResolvedValue(undefined) + const get = vi.fn().mockResolvedValue(null) + const create = vi.fn().mockResolvedValue(sampleHost({ id: 'replacement-host' })) + const invalidateProvider = vi.fn() + const broker = { invalidateProvider } as unknown as ComputeApprovalBroker + const permissionGrantRegistry = { prune } as unknown as PermissionGrantRegistry + const handlers = createComputeHandlers( + mockRepository({ delete: del, get, create }), + undefined, + undefined, + broker, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + permissionGrantRegistry + ) + + const deleting = handlers.delete('ssh:biowulf') + await vi.waitFor(() => expect(prune).toHaveBeenCalledTimes(1)) + const creating = handlers.create({ sshAlias: 'biowulf' }) + await Promise.resolve() + + expect(create).not.toHaveBeenCalled() + releaseDeletePrune?.() + await deleting + await expect(creating).resolves.toMatchObject({ id: 'replacement-host' }) + expect(prune).toHaveBeenNthCalledWith(1, { + kind: 'compute_provider', + providerId: 'ssh:biowulf' + }) + expect(prune).toHaveBeenNthCalledWith(2, { + kind: 'compute_provider', + providerId: 'ssh:biowulf' + }) + expect(create).toHaveBeenCalledOnce() + }) }) // --------------------------------------------------------------------------- diff --git a/src/main/compute/ipc.ts b/src/main/compute/ipc.ts index 7000bc5cf..5ac4a2322 100644 --- a/src/main/compute/ipc.ts +++ b/src/main/compute/ipc.ts @@ -17,6 +17,7 @@ import type { DetailsAuthor, ProbeResult } from '../../shared/compute' +import { computeProviderId } from '../../shared/compute' import type { DirListing, DownloadDest, @@ -43,6 +44,8 @@ import { dispatchJob } from './job-dispatcher' import { EnabledComputeHostsRegistry, enabledComputeHostsRegistry } from './enabled-hosts-registry' import { getJobHarvestDir } from './harvest-engine' import { workspaceRelativePath } from './workspace-path' +import type { PermissionGrantRegistry } from '../permission-grants/registry' +import { createComputePermissionGrantAdapter } from './permission-grant-adapter' // IPC channel names for the renderer job feed (Phase 3d, issue 05). export const COMPUTE_JOBS_LIST_CHANNEL = 'compute:jobs:list' @@ -191,8 +194,18 @@ const createComputeHandlers = ( onJobUpdated?: (job: ComputeJob) => void, artifactResolver?: ArtifactResolver, storageRoot?: string, - taskNotifications?: Pick + taskNotifications?: Pick, + permissionGrantRegistry?: PermissionGrantRegistry ): ComputeHandlers => { + const permissionGrants = permissionGrantRegistry + ? createComputePermissionGrantAdapter(permissionGrantRegistry, settingsRepository) + : undefined + if (permissionGrants) { + void permissionGrants + .migrateLegacy() + .catch((error) => log.warn('legacy compute grant migration failed', errorLogFields(error))) + } + // The broadcast function sends approval requests to all renderer windows. In tests, callers // inject a fake broker so this function is never called directly. const broker = @@ -212,15 +225,35 @@ const createComputeHandlers = ( win.webContents.send('compute:approval-request', request) } }, - // Wire project-scope grant persistence through the settings repository (issue 05). - checkProjectGrant: settingsRepository - ? (grant) => settingsRepository.hasComputeGrant(grant) - : undefined, - saveProjectGrant: settingsRepository - ? (grant) => settingsRepository.addComputeGrant(grant).then(() => undefined) - : undefined + // Isolated legacy callers retain their old hooks. Production uses only the Registry adapter. + checkProjectGrant: + settingsRepository && !permissionGrantRegistry + ? (grant) => settingsRepository.hasComputeGrant(grant) + : undefined, + saveProjectGrant: + settingsRepository && !permissionGrantRegistry + ? (grant) => settingsRepository.addComputeGrant(grant).then(() => undefined) + : undefined, + isProviderCurrent: async ({ providerId, ownerId }) => { + const current = await repository.get(providerId) + return current !== null && (ownerId === undefined || current.id === ownerId) + }, + permissionGrants }) + // Compute provider ids are deterministic and reusable. Keep create, delete, and owner-grant cleanup + // in one FIFO so a replacement host cannot become visible before stale authority is pruned. The + // tail recovers after failures; create retries cleanup before exposing an absent provider id. + let hostLifecycleTail: Promise = Promise.resolve() + const runHostLifecycleMutation = (operation: () => Promise): Promise => { + const result = hostLifecycleTail.then(operation) + hostLifecycleTail = result.then( + () => undefined, + () => undefined + ) + return result + } + // Construct the production service with the full job dependency set so agent submit_job works and // dispatcher status transitions (submitted→running/error) broadcast to the renderer. Positional // args match the ComputeService constructor: (runner, repository, broker, scpRunner, @@ -283,19 +316,32 @@ const createComputeHandlers = ( return { list: () => repository.list(), get: (providerId) => repository.get(providerId), - create: async (request) => repository.create(request), - delete: async (providerId) => { - if (jobRepository) { - const hasActive = await jobRepository.hasActiveJobsForProvider(providerId) - if (hasActive) { - throw new Error( - `Cannot delete host "${providerId}": it has submitted or running jobs. ` + - `Wait for those jobs to reach a terminal state before deleting the host.` - ) + create: (request) => + runHostLifecycleMutation(async () => { + if (permissionGrantRegistry) { + const providerId = computeProviderId(request.sshAlias) + const existing = await repository.get(providerId) + if (!existing) { + await permissionGrantRegistry.prune({ kind: 'compute_provider', providerId }) + } } - } - await repository.delete(providerId) - }, + return repository.create(request) + }), + delete: (providerId) => + runHostLifecycleMutation(async () => { + if (jobRepository) { + const hasActive = await jobRepository.hasActiveJobsForProvider(providerId) + if (hasActive) { + throw new Error( + `Cannot delete host "${providerId}": it has submitted or running jobs. ` + + `Wait for those jobs to reach a terminal state before deleting the host.` + ) + } + } + broker.invalidateProvider(providerId) + await repository.delete(providerId) + await permissionGrantRegistry?.prune({ kind: 'compute_provider', providerId }) + }), sshConfigAliases: () => listSshAliases(), probe: (providerId) => service.probe(providerId), detailsGet: (providerId) => service.getDetails(providerId), @@ -387,7 +433,8 @@ const registerComputeIpcHandlers = ( // one constructed by createComputeHandlers. Lets the renderer-callable error wrapper around // `compute:list-dir` / `compute:download` be exercised end-to-end against a fake service. injectedService?: ComputeService, - taskNotifications?: Pick + taskNotifications?: Pick, + permissionGrantRegistry?: PermissionGrantRegistry ): { computeService: ComputeService jobRepository: ComputeJobRepository @@ -397,7 +444,8 @@ const registerComputeIpcHandlers = ( const storageRoot = resolveStorageRoot() const dataRoot = resolveDataRoot() - // Share the settings repository with the broker so project grants are persisted (issue 05). + // Read the legacy settings repository only for lazy one-way Project grant import. New remembered + // approvals are written exclusively through the SQLite PermissionGrant Registry. const settingsRepo = new SettingsRepository(storageRoot) // Broadcast dispatcher status transitions to the renderer, same hook shape as the JobPoller uses. @@ -413,7 +461,8 @@ const registerComputeIpcHandlers = ( onJobUpdated, artifactResolver, dataRoot, - taskNotifications + taskNotifications, + permissionGrantRegistry ) ipcMainHandle('compute:list', () => handlers.list()) @@ -421,9 +470,9 @@ const registerComputeIpcHandlers = ( ipcMainHandle('compute:create', (_event, request: CreateComputeHostRequest) => handlers.create(request) ) - ipcMainHandle('compute:delete', (_event, request: DeleteComputeHostRequest) => - handlers.delete(request.providerId) - ) + ipcMainHandle('compute:delete', async (_event, request: DeleteComputeHostRequest) => { + await handlers.delete(request.providerId) + }) ipcMainHandle('compute:ssh-config-aliases', () => handlers.sshConfigAliases()) ipcMainHandle('compute:probe', (_event, providerId: string) => handlers.probe(providerId)) ipcMainHandle('compute:details:get', (_event, providerId: string) => diff --git a/src/main/compute/permission-grant-adapter.test.ts b/src/main/compute/permission-grant-adapter.test.ts new file mode 100644 index 000000000..42268549f --- /dev/null +++ b/src/main/compute/permission-grant-adapter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + PermissionGrantTargetUnavailableError, + type PermissionGrantRegistry +} from '../permission-grants/registry' +import { createComputePermissionGrantAdapter } from './permission-grant-adapter' + +const context = { + projectId: 'project-1', + sessionId: 'session-1', + operation: 'submit_job', + providerId: 'ssh:biowulf' +} + +describe('compute permission grant adapter', () => { + it('persists Session and Global decisions in the unified Registry', async () => { + const remember = vi.fn().mockResolvedValue({}) + const registry = { remember } as unknown as PermissionGrantRegistry + const adapter = createComputePermissionGrantAdapter(registry) + + await adapter.remember(context, 'conversation') + await adapter.remember(context, 'global') + + expect(remember).toHaveBeenNthCalledWith(1, { + capability: { + kind: 'execution', + key: 'exec:compute/ssh:biowulf/submit_job', + qualifier: { mode: 'any' } + }, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + expect(remember).toHaveBeenNthCalledWith(2, { + capability: expect.any(Object), + scope: { kind: 'global' } + }) + }) + + it('migrates legacy settings.json Project grants and clears the old source', async () => { + const remember = vi.fn().mockResolvedValue({}) + const registry = { + resolve: vi.fn().mockResolvedValue({ matchedScope: 'project' }), + remember + } as unknown as PermissionGrantRegistry + const legacy = { + listComputeGrants: vi.fn().mockResolvedValue([ + { + projectId: context.projectId, + operation: context.operation, + providerId: context.providerId + } + ]), + clearComputeGrants: vi.fn().mockResolvedValue(undefined) + } + const adapter = createComputePermissionGrantAdapter(registry, legacy) + + await expect(adapter.resolve(context)).resolves.toBe('project') + expect(remember).toHaveBeenCalledWith({ + capability: expect.objectContaining({ key: 'exec:compute/ssh:biowulf/submit_job' }), + scope: { kind: 'project', projectId: 'project-1' } + }) + expect(legacy.clearComputeGrants).toHaveBeenCalledOnce() + }) + + it('retains legacy grants when a Registry migration write fails', async () => { + const registry = { + resolve: vi.fn(), + remember: vi.fn().mockRejectedValue(new Error('database unavailable')) + } as unknown as PermissionGrantRegistry + const legacy = { + listComputeGrants: vi.fn().mockResolvedValue([ + { + projectId: context.projectId, + operation: context.operation, + providerId: context.providerId + } + ]), + clearComputeGrants: vi.fn() + } + + const adapter = createComputePermissionGrantAdapter(registry, legacy) + await expect(adapter.migrateLegacy()).rejects.toThrow('database unavailable') + expect(legacy.clearComputeGrants).not.toHaveBeenCalled() + }) + + it('retries a transient legacy migration failure in the same app lifetime', async () => { + const registry = { + remember: vi + .fn() + .mockRejectedValueOnce(new Error('database locked')) + .mockResolvedValueOnce({}) + } as unknown as PermissionGrantRegistry + const legacy = { + listComputeGrants: vi.fn().mockResolvedValue([ + { + projectId: context.projectId, + operation: context.operation, + providerId: context.providerId + } + ]), + clearComputeGrants: vi.fn().mockResolvedValue(undefined) + } + + const adapter = createComputePermissionGrantAdapter(registry, legacy) + + await expect(adapter.migrateLegacy()).rejects.toThrow('database locked') + await expect(adapter.migrateLegacy()).resolves.toBeUndefined() + expect(legacy.listComputeGrants).toHaveBeenCalledTimes(2) + expect(legacy.clearComputeGrants).toHaveBeenCalledOnce() + }) + + it('keeps a partially imported batch and clears it only after a complete idempotent retry', async () => { + const remember = vi + .fn() + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('database locked')) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + const registry = { remember } as unknown as PermissionGrantRegistry + const legacy = { + listComputeGrants: vi.fn().mockResolvedValue([ + { + projectId: context.projectId, + operation: context.operation, + providerId: context.providerId + }, + { projectId: context.projectId, operation: 'cancel_job', providerId: context.providerId } + ]), + clearComputeGrants: vi.fn().mockResolvedValue(undefined) + } + const adapter = createComputePermissionGrantAdapter(registry, legacy) + + await expect(adapter.migrateLegacy()).rejects.toThrow('database locked') + expect(legacy.clearComputeGrants).not.toHaveBeenCalled() + + await expect(adapter.migrateLegacy()).resolves.toBeUndefined() + expect(remember).toHaveBeenCalledTimes(4) + expect(legacy.listComputeGrants).toHaveBeenCalledTimes(2) + expect(legacy.clearComputeGrants).toHaveBeenCalledOnce() + }) + + it('imports valid rows but retains the entire legacy source when a Project owner is gone', async () => { + const remember = vi + .fn() + .mockRejectedValueOnce(new PermissionGrantTargetUnavailableError()) + .mockResolvedValueOnce({}) + const resolve = vi.fn().mockResolvedValue({ matchedScope: 'project' }) + const registry = { remember, resolve } as unknown as PermissionGrantRegistry + const legacy = { + listComputeGrants: vi.fn().mockResolvedValue([ + { projectId: 'deleted-project', operation: 'submit_job', providerId: 'ssh:old' }, + { + projectId: context.projectId, + operation: context.operation, + providerId: context.providerId + } + ]), + clearComputeGrants: vi.fn().mockResolvedValue(undefined) + } + + const adapter = createComputePermissionGrantAdapter(registry, legacy) + + await expect(adapter.migrateLegacy()).rejects.toThrow( + '1 legacy Compute grant owner is unavailable' + ) + expect(remember).toHaveBeenCalledTimes(2) + expect(legacy.clearComputeGrants).not.toHaveBeenCalled() + + await expect(adapter.resolve(context)).resolves.toBe('project') + expect(resolve).toHaveBeenCalledOnce() + expect(remember).toHaveBeenCalledTimes(2) + expect(legacy.listComputeGrants).toHaveBeenCalledOnce() + expect(legacy.clearComputeGrants).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/compute/permission-grant-adapter.ts b/src/main/compute/permission-grant-adapter.ts new file mode 100644 index 000000000..2dcb1c9a2 --- /dev/null +++ b/src/main/compute/permission-grant-adapter.ts @@ -0,0 +1,134 @@ +import type { ComputeApprovalDecision } from '../../shared/compute' +import type { PermissionCapability, PermissionGrantScope } from '../../shared/permission-grants' +import { + PermissionGrantTargetUnavailableError, + type PermissionGrantRegistry +} from '../permission-grants/registry' + +type LegacyComputeGrantRepository = { + listComputeGrants(): Promise> + clearComputeGrants(): Promise +} + +type ComputeGrantContext = { + projectId: string + sessionId: string + operation: string + providerId: string +} + +type ComputePermissionGrantAdapter = { + migrateLegacy(): Promise + resolve(context: ComputeGrantContext): Promise<'session' | 'project' | 'global' | undefined> + remember(context: ComputeGrantContext, decision: ComputeApprovalDecision): Promise +} + +class LegacyComputeGrantOwnersUnavailableError extends Error { + constructor(readonly unavailableCount: number) { + super( + `${unavailableCount} legacy Compute grant owner${unavailableCount === 1 ? ' is' : 's are'} unavailable` + ) + this.name = 'LegacyComputeGrantOwnersUnavailableError' + } +} + +const computeCapability = ( + context: Pick +): PermissionCapability => ({ + kind: 'execution', + key: `exec:compute/${context.providerId}/${context.operation}`, + qualifier: { mode: 'any' } +}) + +const computeScope = ( + context: ComputeGrantContext, + decision: ComputeApprovalDecision +): PermissionGrantScope | undefined => { + if (decision === 'conversation') { + return { kind: 'session', projectId: context.projectId, sessionId: context.sessionId } + } + if (decision === 'project') return { kind: 'project', projectId: context.projectId } + if (decision === 'global') return { kind: 'global' } + return undefined +} + +const createComputePermissionGrantAdapter = ( + registry: PermissionGrantRegistry, + legacy?: LegacyComputeGrantRepository +): ComputePermissionGrantAdapter => { + let migration: Promise | undefined + const migrateLegacy = (): Promise => { + if (!legacy) return Promise.resolve() + if (migration) return migration + + const attempt = (async () => { + const grants = await legacy.listComputeGrants() + let firstWriteFailure: unknown + let hasWriteFailure = false + let unavailableCount = 0 + for (const grant of grants) { + try { + await registry.remember({ + capability: computeCapability(grant), + scope: { kind: 'project', projectId: grant.projectId } + }) + } catch (error) { + if (error instanceof PermissionGrantTargetUnavailableError) { + unavailableCount += 1 + } else if (!hasWriteFailure) { + firstWriteFailure = error + hasWriteFailure = true + } + } + } + if (hasWriteFailure) throw firstWriteFailure + if (unavailableCount > 0) { + throw new LegacyComputeGrantOwnersUnavailableError(unavailableCount) + } + // Clear only after every source row has a successful Registry write. Target-unavailable rows + // are not silently discarded: the additive import is idempotent and settings.json remains the + // retry source until the complete batch succeeds. + await legacy.clearComputeGrants() + })() + migration = attempt + void attempt.catch((error) => { + // A deleted owner cannot become live again during this process. Cache that degraded result so + // every Compute request does not replay the same additive import; the next app start retries it. + // Transient Registry failures remain retryable in the current process. + if (migration === attempt && !(error instanceof LegacyComputeGrantOwnersUnavailableError)) { + migration = undefined + } + }) + return attempt + } + + const awaitUsableMigration = async (): Promise => { + try { + await migrateLegacy() + } catch (error) { + // Orphaned legacy rows keep the complete settings.json source intact, but must not prevent new + // Compute approvals from using the Registry. Database/write failures still fail closed. + if (!(error instanceof LegacyComputeGrantOwnersUnavailableError)) throw error + } + } + + return { + migrateLegacy, + + async resolve(context) { + await awaitUsableMigration() + const match = await registry.resolve(computeCapability(context), context) + return match?.matchedScope + }, + + async remember(context, decision) { + await awaitUsableMigration() + const scope = computeScope(context, decision) + if (!scope) return + await registry.remember({ capability: computeCapability(context), scope }) + } + } +} + +export { createComputePermissionGrantAdapter } +export type { ComputeGrantContext, ComputePermissionGrantAdapter, LegacyComputeGrantRepository } diff --git a/src/main/connectors/approval-broker.test.ts b/src/main/connectors/approval-broker.test.ts index 94267b390..c4e050066 100644 --- a/src/main/connectors/approval-broker.test.ts +++ b/src/main/connectors/approval-broker.test.ts @@ -40,11 +40,12 @@ describe('ApprovalBroker', () => { id: 'id-1', connector: 'biomart', method: 'get_data', - argsPreview: '{}' + argsPreview: '{}', + availableScopes: ['once'] }) - broker.respond('id-1', 'allow') - await expect(decision).resolves.toBe('allow') + broker.respond('id-1', 'once') + await expect(decision).resolves.toBe('once') }) it('auto-denies when the request times out', async () => { @@ -72,9 +73,9 @@ describe('ApprovalBroker', () => { const decision = broker.request({ connector: 'biomart', method: 'get_data', argsPreview: '{}' }) broker.respond('id-1', 'deny') - broker.respond('id-1', 'allow') // no-op: already settled + broker.respond('id-1', 'once') // no-op: already settled await expect(decision).resolves.toBe('deny') - expect(() => broker.respond('nope', 'allow')).not.toThrow() + expect(() => broker.respond('nope', 'once')).not.toThrow() }) it('runs concurrent requests independently', async () => { @@ -92,10 +93,10 @@ describe('ApprovalBroker', () => { const a = broker.request({ connector: 'x', method: 'm', argsPreview: '{}' }) const b = broker.request({ connector: 'y', method: 'm', argsPreview: '{}' }) - broker.respond('id-2', 'allow') + broker.respond('id-2', 'once') broker.respond('id-1', 'deny') await expect(a).resolves.toBe('deny') - await expect(b).resolves.toBe('allow') + await expect(b).resolves.toBe('once') expect(vi.isMockFunction(broker.request)).toBe(false) }) @@ -115,7 +116,8 @@ describe('ApprovalBroker', () => { connector: 'pubchem', method: 'search_compound', argsPreview: '{}', - sessionId: 'session-42' + sessionId: 'session-42', + availableScopes: ['once', 'session', 'project', 'global'] }) expect(broadcast).toEqual({ @@ -123,10 +125,11 @@ describe('ApprovalBroker', () => { connector: 'pubchem', method: 'search_compound', argsPreview: '{}', - sessionId: 'session-42' + sessionId: 'session-42', + availableScopes: ['once', 'session', 'project', 'global'] }) - broker.respond('id-1', 'allow') - await expect(decision).resolves.toBe('allow') + broker.respond('id-1', 'global') + await expect(decision).resolves.toBe('global') }) }) diff --git a/src/main/connectors/approval-broker.ts b/src/main/connectors/approval-broker.ts index a35a42ad5..930473f4c 100644 --- a/src/main/connectors/approval-broker.ts +++ b/src/main/connectors/approval-broker.ts @@ -1,4 +1,8 @@ -import type { ApprovalDecision, ConnectorApprovalRequest } from '../../shared/settings' +import type { + ApprovalDecision, + ConnectorApprovalRequest, + ConnectorApprovalScope +} from '../../shared/settings' export type ApprovalInfo = { connector: string @@ -7,6 +11,7 @@ export type ApprovalInfo = { // The session that triggered the connector call, when one is known, so the desktop notification // can open that conversation. sessionId?: string + availableScopes?: ConnectorApprovalScope[] } type ApprovalBrokerDeps = { @@ -46,7 +51,7 @@ export class ApprovalBroker { return new Promise((resolve) => { const timer = this.setTimer(() => this.settle(id, 'deny'), this.timeoutMs) this.pending.set(id, { resolve, timer }) - this.deps.broadcast({ id, ...info }) + this.deps.broadcast({ id, ...info, availableScopes: info.availableScopes ?? ['once'] }) }) } diff --git a/src/main/connectors/service.test.ts b/src/main/connectors/service.test.ts index d27d5a967..292e7e635 100644 --- a/src/main/connectors/service.test.ts +++ b/src/main/connectors/service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { ConnectorService } from './service' import { ParserEngine } from './engine' import type { SpecialistProfileView } from '../../shared/specialist' +import type { CustomMcpServerConfig } from './mcp-client-manager' const internal = { origin: 'internal' as const } @@ -103,7 +104,7 @@ describe('ConnectorService', () => { const fetchImpl = vi .fn() .mockResolvedValue(jsonRes({ PropertyTable: { Properties: [{ CID: 1 }] } })) - const requestApproval = vi.fn().mockResolvedValue('allow') + const requestApproval = vi.fn().mockResolvedValue('once') const svc = new ConnectorService({ engine: new ParserEngine({ fetchImpl }), getConnectors: () => ({ @@ -119,7 +120,8 @@ describe('ConnectorService', () => { expect(requestApproval).toHaveBeenCalledWith({ connector: 'chemistry', method: 'pubchem_get_compounds', - args: { cids: [1] } + args: { cids: [1] }, + availableScopes: ['once'] }) }) @@ -131,7 +133,7 @@ describe('ConnectorService', () => { const fetchImpl = vi .fn() .mockResolvedValue(jsonRes({ PropertyTable: { Properties: [{ CID: 1 }] } })) - const requestApproval = vi.fn().mockResolvedValue('allow') + const requestApproval = vi.fn().mockResolvedValue('once') const svc = new ConnectorService({ engine: new ParserEngine({ fetchImpl }), getConnectors: () => ({ @@ -154,10 +156,81 @@ describe('ConnectorService', () => { connector: 'chemistry', method: 'pubchem_get_compounds', args: { cids: [1] }, - sessionId: 'session-42' + sessionId: 'session-42', + availableScopes: ['once'] }) }) + it('does not ask again when the unified Broker resolves a matching Connector grant', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonRes({ PropertyTable: { Properties: [{ CID: 1 }] } })) + const requestApproval = vi.fn().mockResolvedValue('once') + const resolve = vi.fn().mockResolvedValue({ matchedScope: 'project' }) + const svc = new ConnectorService({ + engine: new ParserEngine({ fetchImpl }), + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['chemistry/pubchem_get_compounds'] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve } as never + }) + + await svc.call( + 'chemistry', + 'pubchem_get_compounds', + { cids: [1] }, + { sessionId: 'session-1', projectId: 'project-1' } + ) + + expect(resolve).toHaveBeenCalledWith( + { kind: 'mcp_tool', key: 'mcp:chemistry/pubchem_get_compounds' }, + { sessionId: 'session-1', projectId: 'project-1' } + ) + expect(requestApproval).not.toHaveBeenCalled() + }) + + it('commits a selected Session scope before releasing an ask-flagged Connector call', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonRes({ PropertyTable: { Properties: [{ CID: 1 }] } })) + const requestApproval = vi.fn().mockResolvedValue('session') + const resolve = vi.fn().mockResolvedValue(undefined) + const remember = vi.fn().mockResolvedValue(undefined) + const svc = new ConnectorService({ + engine: new ParserEngine({ fetchImpl }), + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['chemistry/pubchem_get_compounds'] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve, remember } as never + }) + + await svc.call( + 'chemistry', + 'pubchem_get_compounds', + { cids: [1] }, + { sessionId: 'session-1', projectId: 'project-1' } + ) + + expect(requestApproval).toHaveBeenCalledWith( + expect.objectContaining({ + availableScopes: ['once', 'session', 'project', 'global'] + }) + ) + expect(remember).toHaveBeenCalledWith({ + capability: { kind: 'mcp_tool', key: 'mcp:chemistry/pubchem_get_compounds' }, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + expect(fetchImpl).toHaveBeenCalledOnce() + }) + it('rejects an ask-flagged tool when the user denies approval', async () => { const fetchImpl = vi.fn() const requestApproval = vi.fn().mockResolvedValue('deny') @@ -177,6 +250,24 @@ describe('ConnectorService', () => { expect(fetchImpl).not.toHaveBeenCalled() }) + it('fails closed when a required approval has no prompt transport', async () => { + const fetchImpl = vi.fn() + const svc = new ConnectorService({ + engine: new ParserEngine({ fetchImpl }), + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['chemistry/pubchem_get_compounds'] + }), + resolveApiKey: () => undefined + }) + + await expect( + svc.call('chemistry', 'pubchem_get_compounds', { cids: [1] }, internal) + ).rejects.toThrow(/approval unavailable/) + expect(fetchImpl).not.toHaveBeenCalled() + }) + it('does not prompt for a tool at the default (allow)', async () => { const fetchImpl = vi .fn() @@ -212,10 +303,29 @@ describe('ConnectorService', () => { }) describe('custom MCP servers', () => { + const manager = ( + call: ReturnType, + tools = ['do_thing'] + ): { + call: ( + config: CustomMcpServerConfig, + method: string, + args: Record + ) => Promise + listTools: (config: CustomMcpServerConfig) => Promise> + } => ({ + call: call as unknown as ( + config: CustomMcpServerConfig, + method: string, + args: Record + ) => Promise, + listTools: vi.fn().mockResolvedValue(tools.map((name) => ({ name }))) + }) + it('routes a call to a custom server through mcpClientManager.call', async () => { const call = vi.fn().mockResolvedValue({ ok: true }) const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: manager(call), getConnectors: () => ({ enabledIds: [], autoAllowIds: [], @@ -254,7 +364,7 @@ describe('ConnectorService', () => { it('routes a call to a remote (streamable_http) custom server with its url/headers', async () => { const call = vi.fn().mockResolvedValue({ ok: true }) const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: manager(call), getConnectors: () => ({ enabledIds: [], autoAllowIds: [], @@ -292,7 +402,7 @@ describe('ConnectorService', () => { it('rejects a disabled custom server', async () => { const call = vi.fn() const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: manager(call), getConnectors: () => ({ enabledIds: [], autoAllowIds: [], @@ -308,21 +418,33 @@ describe('ConnectorService', () => { it('rejects a blocked tool on a custom server', async () => { const call = vi.fn() + const listTools = vi.fn().mockResolvedValue([{ name: 'dangerous' }]) + const resolve = vi.fn().mockResolvedValue({ matchedScope: 'global' }) + const requestApproval = vi.fn().mockResolvedValue('once') const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: { + call: call as never, + listTools + }, getConnectors: () => ({ enabledIds: [], - autoAllowIds: [], + autoAllowIds: ['myserver'], + askToolIds: ['myserver/dangerous'], blockedToolIds: ['myserver/dangerous'], customMcpServers: [ { id: 'srv-1', name: 'myserver', transport: 'stdio', command: 'npx', enabled: true } ] }), - resolveApiKey: () => undefined + resolveApiKey: () => undefined, + permissionGrantRegistry: { resolve } as never, + requestApproval }) await expect(svc.call('myserver', 'dangerous', {}, internal)).rejects.toThrow( /blocked by policy/ ) + expect(listTools).not.toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + expect(requestApproval).not.toHaveBeenCalled() expect(call).not.toHaveBeenCalled() }) @@ -336,9 +458,9 @@ describe('ConnectorService', () => { it('threads context.sessionId through to requestApproval for custom MCP tools', async () => { const call = vi.fn().mockResolvedValue({ ok: true }) - const requestApproval = vi.fn().mockResolvedValue('allow') + const requestApproval = vi.fn().mockResolvedValue('once') const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: manager(call), getConnectors: () => ({ enabledIds: [], autoAllowIds: [], @@ -362,10 +484,166 @@ describe('ConnectorService', () => { connector: 'myserver', method: 'do_thing', args: { x: 1 }, - sessionId: 'session-99' + sessionId: 'session-99', + availableScopes: ['once'] }) }) + it('does not connect an Ask-policy custom server before approval', async () => { + const call = vi.fn() + const listTools = vi.fn().mockResolvedValue([{ name: 'do_thing' }]) + const requestApproval = vi.fn().mockResolvedValue('deny') + const svc = new ConnectorService({ + mcpClientManager: { call: call as never, listTools }, + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['myserver/do_thing'], + customMcpServers: [ + { + id: 'srv-1', + name: 'myserver', + transport: 'streamable_http', + url: 'https://private.example/mcp', + headers: { Authorization: 'Bearer secret' }, + enabled: true + } + ] + }), + resolveApiKey: () => undefined, + requestApproval + }) + + await expect(svc.call('myserver', 'do_thing', {}, internal)).rejects.toThrow(/denied by user/) + + expect(requestApproval).toHaveBeenCalledOnce() + expect(listTools).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() + }) + + it('persists a broad custom MCP grant only after validating the approved method', async () => { + const events: string[] = [] + const requestApproval = vi.fn(async () => { + events.push('approval') + return 'project' as const + }) + const listTools = vi.fn(async () => { + events.push('listTools') + return [{ name: 'do_thing' }] + }) + const remember = vi.fn(async () => { + events.push('remember') + return {} + }) + const call = vi.fn(async () => { + events.push('call') + return { ok: true } + }) + const svc = new ConnectorService({ + mcpClientManager: { call: call as never, listTools }, + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['myserver/do_thing'], + customMcpServers: [ + { + id: 'srv-stable', + name: 'myserver', + transport: 'stdio', + command: 'npx', + enabled: true + } + ] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve: vi.fn(), remember } as never + }) + + await expect( + svc.call( + 'myserver', + 'do_thing', + { x: 1 }, + { origin: 'internal', sessionId: 'session-1', projectId: 'project-1' } + ) + ).resolves.toEqual({ ok: true }) + + expect(events).toEqual(['approval', 'listTools', 'remember', 'call']) + expect(remember).toHaveBeenCalledWith({ + capability: { kind: 'mcp_tool', key: 'mcp:srv-stable/do_thing' }, + scope: { kind: 'project', projectId: 'project-1' } + }) + }) + + it('rejects a pending approval when the custom server security configuration changes', async () => { + const original = { + id: 'srv-stable', + name: 'myserver', + transport: 'stdio' as const, + command: 'old-command', + enabled: true + } + const replacement = { + ...original, + command: 'new-command' + } + let current = original + let approve: ((decision: 'global') => void) | undefined + const requestApproval = vi.fn( + () => + new Promise<'global' | 'once'>((resolve) => { + approve = (decision) => resolve(decision) + }) + ) + const remember = vi.fn() + const call = vi.fn() + const listTools = vi.fn().mockResolvedValue([{ name: 'do_thing' }]) + const svc = new ConnectorService({ + mcpClientManager: { call: call as never, listTools }, + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['myserver/do_thing'], + customMcpServers: [current] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve: vi.fn(), remember } as never + }) + + const pendingCall = svc.call( + 'myserver', + 'do_thing', + {}, + { origin: 'internal', sessionId: 'session-1', projectId: 'project-1' } + ) + await vi.waitFor(() => expect(requestApproval).toHaveBeenCalledOnce()) + + const guard = svc.beginCustomServerSecurityChange(original.id) + current = replacement + guard.commit(replacement) + approve?.('global') + + await expect(pendingCall).rejects.toThrow('connector_configuration_changed') + expect(listTools).not.toHaveBeenCalled() + expect(remember).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() + + requestApproval.mockResolvedValueOnce('once') + call.mockResolvedValueOnce({ ok: true }) + await expect( + svc.call( + 'myserver', + 'do_thing', + {}, + { origin: 'internal', sessionId: 'session-1', projectId: 'project-1' } + ) + ).resolves.toEqual({ ok: true }) + expect(listTools).toHaveBeenCalledOnce() + expect(call).toHaveBeenCalledOnce() + }) + it('fails closed after a custom connector cannot authenticate or start, without exposing its error', async () => { const call = vi .fn() @@ -373,7 +651,7 @@ describe('ConnectorService', () => { new Error('401 Unauthorized for https://private.example with Bearer SECRET') ) const svc = new ConnectorService({ - mcpClientManager: { call }, + mcpClientManager: manager(call, ['lookup']), getConnectors: () => ({ enabledIds: [], autoAllowIds: [], @@ -383,7 +661,7 @@ describe('ConnectorService', () => { name: 'secured-server', transport: 'streamable_http', url: 'https://private.example/mcp', - enabled: false + enabled: true } ] }), @@ -424,6 +702,82 @@ describe('ConnectorService', () => { expect(error.message).not.toContain('private.example') }) }) + + it('resolves remembered grants by immutable custom server id after a rename', async () => { + const call = vi.fn().mockResolvedValue({ ok: true }) + const requestApproval = vi.fn().mockResolvedValue('once') + const resolve = vi.fn().mockResolvedValue({ matchedScope: 'session' }) + const svc = new ConnectorService({ + mcpClientManager: manager(call), + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + // The editable name remains a supported policy alias while the grant uses immutable id. + askToolIds: ['renamed-server/do_thing'], + customMcpServers: [ + { + id: 'srv-stable', + name: 'renamed-server', + transport: 'stdio', + command: 'npx', + enabled: true + } + ] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve } as never + }) + + await svc.call( + 'renamed-server', + 'do_thing', + { x: 1 }, + { origin: 'internal', sessionId: 'session-1', projectId: 'project-1' } + ) + + expect(resolve).toHaveBeenCalledWith( + { kind: 'mcp_tool', key: 'mcp:srv-stable/do_thing' }, + { + origin: 'internal', + sessionId: 'session-1', + projectId: 'project-1' + } + ) + expect(requestApproval).not.toHaveBeenCalled() + }) + + it('does not remember a broad approval for an unregistered custom method', async () => { + const call = vi.fn() + const requestApproval = vi.fn().mockResolvedValue('global') + const remember = vi.fn() + const svc = new ConnectorService({ + mcpClientManager: manager(call, ['registered_method']), + getConnectors: () => ({ + enabledIds: [], + autoAllowIds: [], + askToolIds: ['myserver/future_method'], + customMcpServers: [ + { id: 'srv-1', name: 'myserver', transport: 'stdio', command: 'npx', enabled: true } + ] + }), + resolveApiKey: () => undefined, + requestApproval, + permissionGrantRegistry: { resolve: vi.fn(), remember } as never + }) + + await expect( + svc.call( + 'myserver', + 'future_method', + {}, + { origin: 'internal', sessionId: 'session-1', projectId: 'project-1' } + ) + ).rejects.toThrow(/unknown tool/) + expect(requestApproval).toHaveBeenCalledOnce() + expect(remember).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() + }) }) }) diff --git a/src/main/connectors/service.ts b/src/main/connectors/service.ts index 102dfad81..218c94936 100644 --- a/src/main/connectors/service.ts +++ b/src/main/connectors/service.ts @@ -1,13 +1,19 @@ +import { createHash } from 'node:crypto' + import { ParserEngine } from './engine' import { ALL_CONNECTOR_IDS, getDescriptor } from './registry' import { toCustomMcpConfig } from './custom-mcp-bootstrap' import type { CustomMcpServerConfig } from './mcp-client-manager' import type { ConnectorCredentials, ToolDescriptor } from './types' -import type { StoredConnectors } from '../settings/types' -import type { ApprovalDecision } from '../../shared/settings' +import type { StoredConnectors, StoredCustomMcpServer } from '../settings/types' +import type { PermissionGrantRegistry } from '../permission-grants/registry' +import { ConnectorPermissionBroker } from '../permission-grants/connector-broker' +import type { ConnectorPermissionRequest } from '../permission-grants/connector-broker' +import type { ApprovalDecision, ConnectorApprovalScope } from '../../shared/settings' import type { SpecialistProfileView } from '../../shared/specialist' type McpClientManagerLike = { + listTools(config: CustomMcpServerConfig): Promise> call( config: CustomMcpServerConfig, method: string, @@ -20,9 +26,10 @@ type ConnectorServiceDeps = { mcpClientManager?: McpClientManagerLike getConnectors: () => StoredConnectors | undefined resolveApiKey: (ref?: string) => string | undefined - // Human approval gate for a tool call that isn't pre-approved. Absent (e.g. in tests) means the - // call runs without prompting. A connector call sends data to an external service, so a call that - // is neither pre-allowed nor skip-approved must be confirmed before it runs. + permissionGrantRegistry?: PermissionGrantRegistry + // Human approval gate for a tool call that isn't pre-approved. A connector call sends data to an + // external service, so a call that is neither pre-allowed nor skip-approved fails closed when this + // transport is absent. requestApproval?: (info: { connector: string method: string @@ -30,6 +37,7 @@ type ConnectorServiceDeps = { // The session that triggered the call, when one is known, so the resulting notification can // open the right conversation. sessionId?: string + availableScopes: ConnectorApprovalScope[] }) => Promise // Handlers for bundled tools that run privileged local code (e.g. write an artifact, open a preview) // instead of the read-only HTTP ParserEngine. Keyed by `${connector}/${method}`; invoked after the @@ -50,6 +58,7 @@ type ConnectorServiceDeps = { // (e.g. notebook host.mcp); absent for context-free callers. export type ConnectorCallContext = { sessionId?: string + projectId?: string // Agent calls are untrusted model output and must be tied to a known session. Internal callers // must opt in explicitly so they cannot accidentally inherit a session capability scope. origin?: 'agent' | 'internal' @@ -64,6 +73,30 @@ type ConnectorAccess = { specialistScoped: boolean } +type CustomServerSecurityChangeGuard = { + commit(server: StoredCustomMcpServer): void + rollback(): void +} + +const stableRecordEntries = (record: Record | undefined): [string, string][] => + Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right)) + +// Excludes display-only fields and hashes both encrypted references and legacy resolved values. The +// barrier can therefore identify a configuration generation without retaining another plaintext copy. +const customServerSecurityFingerprint = (server: StoredCustomMcpServer): string => + createHash('sha256') + .update( + JSON.stringify([ + server.transport, + server.command ?? null, + server.args ?? [], + server.url ?? null, + stableRecordEntries(server.envRefs ?? server.env), + stableRecordEntries(server.headerRefs ?? server.headers) + ]) + ) + .digest('hex') + // Deliberately contains only a stable category. In particular it must not interpolate connector // arguments, custom-server headers, credentials, or a Specialist's system prompt into an error that // may be rendered back to an agent. @@ -88,8 +121,18 @@ export class ConnectorService { string, 'connector_unavailable' | 'connector_unauthenticated' >() + private readonly permissionBroker: ConnectorPermissionBroker + private readonly customServerGenerations = new Map() + private readonly customServerBarriers = new Map< + string, + { generation: number; expectedFingerprint?: string } + >() constructor(private readonly deps: ConnectorServiceDeps) { this.engine = deps.engine ?? new ParserEngine() + this.permissionBroker = new ConnectorPermissionBroker( + deps.permissionGrantRegistry, + deps.requestApproval + ) } isEnabled(connector: string): boolean { @@ -97,6 +140,31 @@ export class ConnectorService { return !(this.deps.getConnectors()?.disabledConnectorIds ?? []).includes(connector) } + // Invalidates every call that captured the previous custom-server configuration. While the + // settings write is in progress, new calls fail closed. After commit they remain blocked until the + // refreshed connector snapshot exposes the exact persisted security configuration. + beginCustomServerSecurityChange(serverId: string): CustomServerSecurityChangeGuard { + const generation = (this.customServerGenerations.get(serverId) ?? 0) + 1 + this.customServerGenerations.set(serverId, generation) + this.customServerBarriers.set(serverId, { generation }) + + return { + commit: (server) => { + const barrier = this.customServerBarriers.get(serverId) + if (barrier?.generation !== generation) return + this.customServerBarriers.set(serverId, { + generation, + expectedFingerprint: customServerSecurityFingerprint(server) + }) + }, + rollback: () => { + if (this.customServerBarriers.get(serverId)?.generation === generation) { + this.customServerBarriers.delete(serverId) + } + } + } + } + async call( connector: string, method: string, @@ -163,11 +231,8 @@ export class ConnectorService { if (!descriptor) throw new ConnectorGateError('connector_unavailable', `unknown tool: ${connector}/${method}`) - if (!access.bypassMainPolicy && this.isBlocked(connector, method)) { - throw new ConnectorGateError('tool_blocked', `tool blocked by policy: ${connector}/${method}`) - } if (!access.bypassMainPolicy) { - await this.ensureApproved(connector, method, args, context.sessionId) + await this.ensureAuthorized(connector, connector, [connector], method, args, context) } // Bundled tools that need privileged local behavior run here, after the same gate, instead of the @@ -185,31 +250,67 @@ export class ConnectorService { context: ConnectorCallContext, access: ConnectorAccess ): Promise { + const generation = this.assertCustomServerCurrent(custom) const physicalFailure = this.unavailableCustomConnectors.get(custom.name) if (physicalFailure) throw new ConnectorGateError(physicalFailure) if (!access.bypassMainEnablement && !custom.enabled) { throw new ConnectorGateError('connector_disabled', `connector not enabled: ${custom.name}`) } if (!this.isCustomConfigRunnable(custom)) throw new ConnectorGateError('connector_unavailable') - if (!access.bypassMainPolicy && this.isBlocked(custom.name, method)) { + if (!this.deps.mcpClientManager) throw new ConnectorGateError('connector_runtime_unavailable') + + const config = toCustomMcpConfig(custom) + const request = this.authorizationRequest( + custom.name, + custom.id, + [custom.id, custom.name], + method, + args, + context + ) + // A Block decision must short-circuit before tools/list starts or connects an external process. + const policyDecision = access.bypassMainPolicy + ? undefined + : this.permissionBroker.preflight(request) + // Approval must precede tools/list because even discovery connects the external server. Defer + // durable broad grants until the approved method is confirmed in the discovered catalog. + const deferredScope = access.bypassMainPolicy + ? undefined + : await this.permissionBroker.authorize(request, policyDecision, { deferRemember: true }) + this.assertCustomServerCurrent(custom, generation) + + let tools: Array<{ name: string }> + try { + tools = await this.deps.mcpClientManager.listTools(config) + } catch (error) { + // Never relay a transport error: custom server URLs, headers, or server-provided diagnostics + // can contain credentials. Record only the availability category for subsequent fail-closed + // dispatches; a successful connection clears the transient state. + const category = + error instanceof Error && + /(?:401|403|unauthoriz|authenticat|forbidden)/i.test(error.message) + ? 'connector_unauthenticated' + : 'connector_unavailable' + this.unavailableCustomConnectors.set(custom.name, category) + throw new ConnectorGateError(category) + } + + if (!tools.some((tool) => tool.name === method)) { throw new ConnectorGateError( - 'tool_blocked', - `tool blocked by policy: ${custom.name}/${method}` + 'connector_unavailable', + `unknown tool: ${custom.name}/${method}` ) } - if (!this.deps.mcpClientManager) throw new ConnectorGateError('connector_runtime_unavailable') - if (!access.bypassMainPolicy) { - await this.ensureApproved(custom.name, method, args, context.sessionId) - } + this.assertCustomServerCurrent(custom, generation) + if (deferredScope) await this.permissionBroker.remember(request, deferredScope) + + this.assertCustomServerCurrent(custom, generation) try { - const result = await this.deps.mcpClientManager.call(toCustomMcpConfig(custom), method, args) + const result = await this.deps.mcpClientManager.call(config, method, args) this.unavailableCustomConnectors.delete(custom.name) return result } catch (error) { - // Never relay a transport error: custom server URLs, headers, or server-provided diagnostics - // can contain credentials. Record only the availability category for subsequent fail-closed - // dispatches; a successful connection clears the transient state. const category = error instanceof Error && /(?:401|403|unauthoriz|authenticat|forbidden)/i.test(error.message) @@ -220,6 +321,28 @@ export class ConnectorService { } } + private assertCustomServerCurrent( + custom: StoredCustomMcpServer, + expectedGeneration?: number + ): number { + const generation = this.customServerGenerations.get(custom.id) ?? 0 + if (expectedGeneration !== undefined && expectedGeneration !== generation) { + throw new ConnectorGateError('connector_configuration_changed') + } + + const barrier = this.customServerBarriers.get(custom.id) + if (!barrier) return generation + if ( + barrier.expectedFingerprint === undefined || + barrier.expectedFingerprint !== customServerSecurityFingerprint(custom) + ) { + throw new ConnectorGateError('connector_configuration_changed') + } + + this.customServerBarriers.delete(custom.id) + return generation + } + private isCustomConfigRunnable( custom: NonNullable[number] ): boolean { @@ -227,29 +350,49 @@ export class ConnectorService { return Boolean(custom.url) } - private isBlocked(connector: string, method: string): boolean { - const blocked = this.deps.getConnectors()?.blockedToolIds ?? [] - return blocked.includes(`${connector}/${method}`) + // The Permission Broker owns Connector policy precedence as well as durable grant matching. This + // service supplies only the registered identity, routing aliases, and current settings snapshot. + private async ensureAuthorized( + connectorLabel: string, + capabilityServerId: string, + policyIds: readonly string[], + method: string, + args: Record, + context: ConnectorCallContext + ): Promise { + await this.permissionBroker.authorize( + this.authorizationRequest( + connectorLabel, + capabilityServerId, + policyIds, + method, + args, + context + ) + ) } - // Tools run without a prompt by default. A call is confirmed by a human only when the tool is - // explicitly set to "Ask each time" AND the connector does not skip approvals. - private async ensureApproved( - connector: string, + private authorizationRequest( + connectorLabel: string, + capabilityServerId: string, + policyIds: readonly string[], method: string, args: Record, - sessionId: string | undefined - ): Promise { + context: ConnectorCallContext + ): ConnectorPermissionRequest { const c = this.deps.getConnectors() - const requiresAsk = (c?.askToolIds ?? []).includes(`${connector}/${method}`) - const skipApprovals = (c?.autoAllowIds ?? []).includes(connector) - if (!requiresAsk || skipApprovals) return - if (!this.deps.requestApproval) return // no approver wired (tests) — do not block - - const decision = await this.deps.requestApproval({ connector, method, args, sessionId }) - - if (decision !== 'allow') { - throw new Error(`tool call denied by user: ${connector}/${method}`) + return { + capability: { kind: 'mcp_tool', key: `mcp:${capabilityServerId}/${method}` }, + context, + connector: connectorLabel, + method, + args, + policy: { + aliases: policyIds, + autoAllowIds: c?.autoAllowIds, + blockedToolIds: c?.blockedToolIds, + askToolIds: c?.askToolIds + } } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e69671e50..e2061a308 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -96,6 +96,10 @@ import { registerProjectFilesIpcHandlers } from './project-files/ipc' import { createManagedFileIndexRepository } from './project-files/repository' import { ProjectDeletionCoordinator } from './projects/deletion-coordinator' import { getProjectDbClient } from './projects/prisma-client' +import { createPermissionGrantRegistry } from './permission-grants/registry' +import { isPermissionGrantScopeLive } from './permission-grants/scope-liveness' +import { registerPermissionGrantIpcHandlers } from './permission-grants/ipc' +import { reconcilePermissionGrantOwners } from './permission-grants/reconciliation' import { SessionPersistenceCoordinator } from './session-persistence/coordinator' import { type SessionPersistenceBackend } from './session-persistence/ipc' import { tryDecryptKey } from './settings/crypto' @@ -106,7 +110,7 @@ import { createProfileService } from './specialist/service' import { registerSpecialistIpcHandlers } from './specialist/ipc' import { SessionBindingService } from './specialist/session-binding' import type { StoredConnectors } from './settings/types' -import type { AppIconPreview, AppIconVariant } from '../shared/settings' +import type { AppIconPreview, AppIconVariant, RespondApprovalRequest } from '../shared/settings' import { registerStorageIpcHandlers } from './storage/ipc' import { normalizeLegacyDataPaths } from './storage/normalize-legacy-paths' import { @@ -123,6 +127,8 @@ import { broadcastToRenderers } from './renderer-broadcast' import { ConversationSkillImporter, SkillImportApprovalBroker } from './skills/conversation-import' import type { ConversationSkillImportApprovalResponse } from '../shared/settings' +const permissionGrantsLog = createLogger('permission-grants') + type IpcRegistrationOptions = { mainEntryPath: string // Headless web-serve launches (--serve) have no local desktop user; task notifications are @@ -300,9 +306,27 @@ const registerIpcHandlers = async ({ resolvePath: resolveManagedFilePath }) + // Permission scope validation starts before the ACP coordinator is constructed. Keep the late-bound + // reference here so a first-turn Session grant can recognize its live owner before the renderer's + // asynchronous session persistence finishes. + const runtimeRef: { current: ReturnType | undefined } = { + current: undefined + } + // Construct one storage/index/deletion graph for every related IPC surface. Sharing these instances // is essential: separate coordinators would have independent queues and recovery gates. const configRoot = resolveStorageRoot() + const permissionGrantRegistry = await createPermissionGrantRegistry({ + getClient: () => getProjectDbClient(configRoot), + isScopeLive: (scope) => + isPermissionGrantScopeLive(scope, { + projectExists: async (projectId) => (await projectRepository.get(projectId)) !== undefined, + persistedSessionExists: async (projectId, sessionId) => + (await sessionRepository.loadSession(projectId, sessionId)) !== undefined, + liveSessionExists: (projectId, sessionId) => + runtimeRef.current?.hasLiveSession(projectId, sessionId) ?? false + }) + }) const projectFilesRepository = createManagedFileIndexRepository( getProjectDbClient, configRoot, @@ -314,7 +338,11 @@ const registerIpcHandlers = async ({ (event) => broadcastToRenderers('project-files:changed', event), provenanceMessageSnapshots, uploadRepository, - artifactProvenanceRepository + artifactProvenanceRepository, + { + reconcileSessions: (sessions) => + reconcilePermissionGrantOwners(permissionGrantRegistry, { sessions }) + } ) const reviewRepository = createDefaultReviewRepository() const projectDeletionCoordinator = new ProjectDeletionCoordinator( @@ -322,7 +350,8 @@ const registerIpcHandlers = async ({ sessionPersistenceCoordinator, previewStateRepository, reviewRepository, - artifactProvenanceRepository + artifactProvenanceRepository, + permissionGrantRegistry ) const sessionPersistenceBackend: SessionPersistenceBackend = { loadAll: () => @@ -336,7 +365,9 @@ const registerIpcHandlers = async ({ }, deleteSession: async (projectId, sessionId) => { await projectDeletionCoordinator.recoverPendingDeletions() - return sessionPersistenceCoordinator.deleteSession(projectId, sessionId) + const result = await sessionPersistenceCoordinator.deleteSession(projectId, sessionId) + await permissionGrantRegistry.prune({ kind: 'session', projectId, sessionId }) + return result }, saveManifest: async (request) => { await projectDeletionCoordinator.recoverPendingDeletions() @@ -406,11 +437,8 @@ const registerIpcHandlers = async ({ notificationsLog.warn('connector approval notification failed', errorLogFields(error)) }) }) - // Late-bound app runtime for connector tools that attach a generated file to the current turn. The - // runtime is created below (it depends on the connector service), so the handler resolves it lazily. - const runtimeRef: { current: ReturnType | undefined } = { - current: undefined - } + // The late-bound app runtime also serves connector tools that attach a generated file to the current + // turn. It is created below because it depends on the connector service. const skillImportApprovalBroker = new SkillImportApprovalBroker({ generateId: () => randomUUID(), broadcast: buildSkillImportApprovalBroadcast({ @@ -443,12 +471,14 @@ const registerIpcHandlers = async ({ getConnectors: () => connectorsSnapshot, resolveApiKey: (ref) => tryDecryptKey(ref), mcpClientManager, - requestApproval: ({ connector, method, args, sessionId }) => + permissionGrantRegistry, + requestApproval: ({ connector, method, args, sessionId, availableScopes }) => approvalBroker.request({ connector, method, argsPreview: previewArgs(args), - ...(sessionId ? { sessionId } : {}) + ...(sessionId ? { sessionId } : {}), + availableScopes }), resolveSpecialistProfile: async (specialistId) => { try { @@ -477,7 +507,8 @@ const registerIpcHandlers = async ({ undefined, computeArtifactResolver, undefined, - taskNotifications + taskNotifications, + permissionGrantRegistry ) const dataRoot = resolveDataRoot() // Start the JobPoller wired to the shared broadcaster so every state/tail change is pushed to all @@ -544,7 +575,9 @@ const registerIpcHandlers = async ({ // The RPC server needs the runtime service to dispatch to, and the runtime service needs the RPC // server's (lazily-started) connection for host.mcp() env injection — wire the second half here to // avoid a construction cycle. - notebookService.setMcpRpcConnectionResolver(() => notebookRpcServer.ensureStarted()) + notebookService.setMcpRpcConnectionResolver(({ sessionId, projectId }) => + notebookRpcServer.issueControlConnection(sessionId, projectId) + ) // Same construction-order constraint as the RPC connection above: the runtime service is created // before the settings service, so the package-mirror lookup is wired in after the fact. notebookService.setPackageMirrorResolver(() => settingsService.getPackageMirror()) @@ -561,12 +594,9 @@ const registerIpcHandlers = async ({ ) // The renderer's approval card responds here; the broker resolves the held connector call. - ipcMainHandle( - 'connectors:approval-respond', - (_event, request: { id: string; decision: 'allow' | 'deny' }) => { - approvalBroker.respond(request.id, request.decision) - } - ) + ipcMainHandle('connectors:approval-respond', (_event, request: RespondApprovalRequest) => { + approvalBroker.respond(request.id, request.decision) + }) ipcMainHandle( 'skills:conversation-import-respond', (_event, response: ConversationSkillImportApprovalResponse) => { @@ -594,6 +624,28 @@ const registerIpcHandlers = async ({ } ) + // Repair soft-owner grants left behind if the app stopped between deleting a Connector/ComputeHost + // and pruning its authority. A failed/timeout Connector refresh leaves that owner class untouched; + // app-owned MCP catalog ids are non-UUID and are never guessed to be stale. + void initialConnectorSkillsReady + .then(async () => { + const hosts = await hostRepository.list() + await reconcilePermissionGrantOwners(permissionGrantRegistry, { + ...(connectorsSnapshot + ? { + customServerIds: connectorsSnapshot.customMcpServers?.map((server) => server.id) ?? [] + } + : {}), + computeProviderIds: hosts.map((host) => host.providerId) + }) + }) + .catch((error) => + permissionGrantsLog.error( + 'permission grant owner reconciliation failed', + errorLogFields(error) + ) + ) + registerFileSaveHandlers({ resolveManagedFilePath, resolveSessionArtifactFilePath }) registerLogsIpcHandlers() registerGithubIpcHandlers() @@ -614,6 +666,7 @@ const registerIpcHandlers = async ({ authorizeSkillImportReferencedUploads: (projectId, sessionId, paths) => conversationSkillImporter.authorizeReferencedUploads(projectId, sessionId, paths), settingsService, + permissionGrantRegistry, taskNotifications, onSessionTurnStarted: (sessionId, turnToken) => skillImportApprovalBroker.beginSessionTurn(sessionId, turnToken), @@ -625,10 +678,13 @@ const registerIpcHandlers = async ({ skillImportApprovalBroker.cancelSession(sessionId), onSessionUnavailable: (sessionId) => skillImportApprovalBroker.cancelSession(sessionId), onAllSessionsCancellationRequested: () => skillImportApprovalBroker.cancelAll(), + beforeSessionDelete: (sessionId) => + notebookService.shutdownSession(sessionId).then(() => undefined), initializationBarrier: initialConnectorSkillsReady, profileService }) runtimeRef.current = runtime + permissionGrantRegistry.subscribe(() => runtime.notifyPermissionGrantsChanged()) // Single shared teardown owner for both the before-quit handler (index.ts) and the pre-update-install // gate. Built here because it needs the runtime, which does not exist when update IPC is registered // above — so the gate is injected via a late-bound closure rather than at strategy construction. @@ -645,6 +701,9 @@ const registerIpcHandlers = async ({ ) // Spawn-config changes rotate the coordinator's runtime for future sessions. Existing sessions retain // their owning runtime, so a framework/provider switch cannot interrupt an in-flight turn. + let invalidatePermissionProjection = (): void => { + broadcastToRenderers('permissions:changed', { revision: Date.now() }) + } registerSettingsIpcHandlers({ service: settingsService, onActiveProviderChanged: () => void runtime.requestProviderReconnect(), @@ -655,7 +714,10 @@ const registerIpcHandlers = async ({ // service reads, then request a skills reload. The reload respawns the agent on next idle so a // non-Claude framework (Codex, opencode) — whose connector docs are materialized into its own // home at spawn — picks up the change too, not just the Claude config dir. - onConnectorsChanged: () => + onConnectorsChanged: () => { + // Connector policy shadows or reactivates grants without mutating them. Republish the shared + // projection immediately so both Settings surfaces describe the same effective decision. + invalidatePermissionProjection() void wireConnectorReload( () => refreshConnectorSkillDocs( @@ -667,7 +729,20 @@ const registerIpcHandlers = async ({ } ), () => void runtime.requestSkillsReload() - ), + ) + }, + onCustomServerRemoved: (serverId) => + permissionGrantRegistry.prune({ kind: 'mcp_server', serverId }).then(() => undefined), + onCustomServerSecurityChanged: async (serverId) => { + const guard = connectorService.beginCustomServerSecurityChange(serverId) + try { + await permissionGrantRegistry.prune({ kind: 'mcp_server', serverId }) + return guard + } catch (error) { + guard.rollback() + throw error + } + }, onAppIconVariantChanged, listAppIconPreviews }) @@ -900,6 +975,23 @@ const registerIpcHandlers = async ({ ) }) ) + const permissionGrantIpc = registerPermissionGrantIpcHandlers({ + registry: permissionGrantRegistry, + projects: { + list: async () => { + await projectDeletionCoordinator.recoverPendingDeletions() + return projectRepository.list() + } + }, + sessions: sessionPersistenceBackend, + connectors: { + get: async () => ({ + ...(await settingsService.getConnectors()), + bundledConnectorIds: ALL_CONNECTOR_IDS + }) + } + }) + invalidatePermissionProjection = permissionGrantIpc.invalidateProjection registerProjectFilesIpcHandlers( projectFilesRepository, sessionPersistenceCoordinator, diff --git a/src/main/notebook/e2e.certification.test.ts b/src/main/notebook/e2e.certification.test.ts index 72c796fea..494f40fd8 100644 --- a/src/main/notebook/e2e.certification.test.ts +++ b/src/main/notebook/e2e.certification.test.ts @@ -150,9 +150,12 @@ const makeHarness = async (opts: { idleTimeoutMs?: number } = {}): Promise rpcServer.ensureStarted()) - const conn = await rpcServer.ensureStarted() + // Same wiring order as main/ipc.ts: the Agent-facing MCP and persistent control REPL receive + // separate session-bound capabilities, so rotating one cannot invalidate the other. + service.setMcpRpcConnectionResolver(({ sessionId, projectId }) => + rpcServer.issueControlConnection(sessionId, projectId) + ) + const conn = await rpcServer.issueSessionConnection(SESSION, PROJECT) const env: NotebookMcpEnvironment = { endpoint: conn.endpoint, diff --git a/src/main/notebook/host-mcp.integration.test.ts b/src/main/notebook/host-mcp.integration.test.ts index ec0f1e4cc..4d7ca19f8 100644 --- a/src/main/notebook/host-mcp.integration.test.ts +++ b/src/main/notebook/host-mcp.integration.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { describe, it, expect } from 'vitest' import { NotebookKernelExecutor } from './kernel-executor' +import { NotebookLocalRpcServer } from './local-rpc-server' // host.mcp now lives ONLY in the control-plane repl kernel (a Node process). Node is always available // under vitest, so the sole gate is RUN_KERNEL — no provisioned python/r env is needed. @@ -21,6 +22,8 @@ const baseRequest = ( code: string mcpRpcEndpoint: string mcpRpcToken: string + sessionId: string + projectName: string }> ): { code: string @@ -31,6 +34,8 @@ const baseRequest = ( runtimeRoot: string mcpRpcEndpoint?: string mcpRpcToken?: string + sessionId?: string + projectName?: string } => ({ code: '', cwd: process.cwd(), @@ -42,6 +47,43 @@ const baseRequest = ( }) gate('repl kernel host.mcp', () => { + it('uses a session-bound control capability to reach the real connector route', async () => { + const rpcServer = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { + connectorService: { + call: async (server, method, args, context) => ({ server, method, args, context }) + } + }) + const connection = await rpcServer.issueControlConnection('session-42', 'project-1') + const exec = makeExecutor() + + try { + const result = await exec.execute( + baseRequest({ + code: ` + const result = await host.mcp('pubmed', 'search_articles', { query: 'tumor immunology' }) + console.log(JSON.stringify(result)) + `, + mcpRpcEndpoint: connection.endpoint, + mcpRpcToken: connection.token, + sessionId: 'session-42', + projectName: 'project-1' + }) + ) + + expect(result.status).toBe('completed') + expect(JSON.parse(result.stdout.trim())).toEqual({ + server: 'pubmed', + method: 'search_articles', + args: { query: 'tumor immunology' }, + context: { sessionId: 'session-42', projectId: 'project-1', origin: 'agent' } + }) + } finally { + await exec.shutdown() + connection.release() + await rpcServer.close() + } + }) + it('host.mcp posts to the RPC endpoint and returns the parsed result', async () => { // Minimal stub RPC endpoint returning a fixed dict for any mcpCall. const { createServer } = await import('node:http') @@ -73,7 +115,9 @@ gate('repl kernel host.mcp', () => { it('host.mcp forwards a positional args object to the RPC server', async () => { // Stub RPC endpoint that echoes back the args it received so the test can assert forwarding. const { createServer } = await import('node:http') - let received: { params?: { args?: unknown } } = {} + let received: { + params?: { args?: unknown; sessionId?: string; projectId?: string } + } = {} const server = createServer((req, res) => { let body = '' req.on('data', (c) => (body += c)) @@ -91,7 +135,9 @@ gate('repl kernel host.mcp', () => { baseRequest({ code: "const r = await host.mcp('chemistry','pubchem_get_properties',{ cids: [1, 2] }); console.log(JSON.stringify(r.echoedArgs.cids))", mcpRpcEndpoint: `http://127.0.0.1:${addr.port}`, - mcpRpcToken: 'tok' + mcpRpcToken: 'tok', + sessionId: 'session-42', + projectName: 'project-1' }) ) await exec.shutdown() @@ -99,6 +145,7 @@ gate('repl kernel host.mcp', () => { expect(result.status).toBe('completed') expect(result.stdout).toContain('[1,2]') expect(received.params?.args).toEqual({ cids: [1, 2] }) + expect(received.params).toMatchObject({ sessionId: 'session-42', projectId: 'project-1' }) }) it('surfaces the RPC server error message when host.mcp gets a non-2xx response', async () => { diff --git a/src/main/notebook/local-rpc-server.mcpcall.test.ts b/src/main/notebook/local-rpc-server.mcpcall.test.ts index 54b69696d..aa109b965 100644 --- a/src/main/notebook/local-rpc-server.mcpcall.test.ts +++ b/src/main/notebook/local-rpc-server.mcpcall.test.ts @@ -5,17 +5,82 @@ const fakeConnector = { call: async (s: string, m: string, a: Record) => ({ s, m, a }) } let server: NotebookLocalRpcServer | undefined +const sessionConnection = ( + target: NotebookLocalRpcServer, + sessionId = 's-42', + projectId = 'project-1' +): ReturnType => + target.issueSessionConnection(sessionId, projectId) + afterEach(async () => { await server?.close() server = undefined }) describe('mcpCall RPC', () => { - it('routes mcpCall to the connector service', async () => { + it('rejects privileged calls made with the server-wide bootstrap token', async () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { connectorService: fakeConnector as never }) const { endpoint, token } = await server.ensureStarted() + const res = await fetch(endpoint, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'mcpCall', + params: { server: 'chemistry', method: 'pubchem_get_properties', args: {} } + }) + }) + + expect(res.status).toBe(401) + await expect(res.json()).resolves.toEqual({ + error: 'A session-bound notebook RPC token is required.' + }) + }) + + it('issues a scoped control connection without invalidating the Agent session connection', async () => { + server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { + connectorService: fakeConnector as never + }) + const agentConnection = await sessionConnection(server) + const controlConnection = await server.issueControlConnection('s-42', 'project-1') + const callMcp = (token: string): Promise => + fetch(controlConnection.endpoint, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + method: 'mcpCall', + params: { server: 'pubmed', method: 'search_articles', args: {} } + }) + }) + + await expect((await callMcp(controlConnection.token)).json()).resolves.toEqual({ + result: { s: 'pubmed', m: 'search_articles', a: {} } + }) + await expect((await callMcp(agentConnection.token)).json()).resolves.toEqual({ + result: { s: 'pubmed', m: 'search_articles', a: {} } + }) + + const disallowed = await fetch(controlConnection.endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${controlConnection.token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ method: 'state', params: { sessionId: 's-42' } }) + }) + expect(disallowed.status).toBe(403) + + controlConnection.release() + const revoked = await callMcp(controlConnection.token) + expect(revoked.status).toBe(401) + }) + + it('routes mcpCall to the connector service', async () => { + server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { + connectorService: fakeConnector as never + }) + const { endpoint, token } = await sessionConnection(server) const res = await fetch(`${endpoint}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -29,14 +94,14 @@ describe('mcpCall RPC', () => { }) }) - it('forwards the caller session id as call context so writes attribute to the right session', async () => { - let seenContext: { sessionId?: string } | undefined + it('uses the session-bound owner and ignores forged RPC owner fields', async () => { + let seenContext: { sessionId?: string; projectId?: string } | undefined const capturing = { call: async ( _s: string, _m: string, _a: Record, - context?: { sessionId?: string } + context?: { sessionId?: string; projectId?: string } ) => { seenContext = context return { ok: true } @@ -45,27 +110,48 @@ describe('mcpCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { connectorService: capturing as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) await fetch(`${endpoint}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'mcpCall', - params: { server: 'molecule', method: 'preview_molecule', args: {}, sessionId: 's-42' } + params: { + server: 'molecule', + method: 'preview_molecule', + args: {}, + sessionId: 'forged-session', + projectId: 'forged-project' + } }) }) - expect(seenContext).toEqual({ sessionId: 's-42', origin: 'agent' }) + expect(seenContext).toEqual({ + sessionId: 's-42', + projectId: 'project-1', + origin: 'agent' + }) }) it('uses the registered Specialist scope rather than RPC-supplied identity data', async () => { let seenContext: - { sessionId?: string; origin?: 'agent' | 'internal'; specialistId?: string } | undefined + | { + sessionId?: string + projectId?: string + origin?: 'agent' | 'internal' + specialistId?: string + } + | undefined const capturing = { call: async ( _s: string, _m: string, _a: Record, - context?: { sessionId?: string; origin?: 'agent' | 'internal'; specialistId?: string } + context?: { + sessionId?: string + projectId?: string + origin?: 'agent' | 'internal' + specialistId?: string + } ) => { seenContext = context return { ok: true } @@ -76,7 +162,7 @@ describe('mcpCall RPC', () => { }) server.registerSessionSpecialist('real-session', 'specialist-1') server.registerSessionAlias('notebook-session', 'real-session') - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server, 'notebook-session', 'project-1') await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -93,6 +179,7 @@ describe('mcpCall RPC', () => { }) expect(seenContext).toEqual({ sessionId: 'real-session', + projectId: 'project-1', origin: 'agent', specialistId: 'specialist-1' }) @@ -108,13 +195,17 @@ describe('computeCall RPC', () => { cmd: string, intent: string, loginShell: boolean, - timeoutSeconds?: number - ) => ({ ...fakeResult, _args: { providerId, cmd, intent, loginShell, timeoutSeconds } }) + timeoutSeconds?: number, + context?: { sessionId: string; projectId: string } + ) => ({ + ...fakeResult, + _args: { providerId, cmd, intent, loginShell, timeoutSeconds, context } + }) } server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -126,7 +217,9 @@ describe('computeCall RPC', () => { cmd: 'echo hi', intent: 'test', login_shell: true, - timeout_seconds: 30 + timeout_seconds: 30, + session_id: 'forged-session', + project_id: 'forged-project' } }) }) @@ -139,13 +232,17 @@ describe('computeCall RPC', () => { expect(body.result._args.providerId).toBe('ssh:biowulf') expect(body.result._args.loginShell).toBe(true) expect(body.result._args.timeoutSeconds).toBe(30) + expect(body.result._args.context).toEqual({ + sessionId: 's-42', + projectId: 'project-1' + }) }) it('returns 401 without Bearer token', async () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: {} as never }) - const { endpoint } = await server.ensureStarted() + const { endpoint } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -156,7 +253,7 @@ describe('computeCall RPC', () => { it('returns 500 when compute service is not configured', async () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -182,7 +279,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -202,7 +299,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -222,21 +319,32 @@ describe('computeCall RPC', () => { } const fakeCompute = { callCommand: async () => ({}), - download: async (providerId: string, remotePath: string, dest: { kind: string }) => ({ + download: async ( + providerId: string, + remotePath: string, + dest: { kind: string }, + context?: { sessionId: string; projectId: string } + ) => ({ ...fakeLocalFile, - _args: { providerId, remotePath, destKind: dest.kind } + _args: { providerId, remotePath, destKind: dest.kind, context } }) } server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ method: 'computeCall', - params: { op: 'download', provider_id: 'ssh:biowulf', remote_path: '/remote/results.csv' } + params: { + op: 'download', + provider_id: 'ssh:biowulf', + remote_path: '/remote/results.csv', + session_id: 'forged-session', + project_id: 'forged-project' + } }) }) expect(res.status).toBe(200) @@ -247,6 +355,10 @@ describe('computeCall RPC', () => { expect(body.result._args.providerId).toBe('ssh:biowulf') expect(body.result._args.remotePath).toBe('/remote/results.csv') expect(body.result._args.destKind).toBe('session-cache') + expect(body.result._args.context).toEqual({ + sessionId: 's-42', + projectId: 'project-1' + }) }) it('returns 500 with download_denied error when download is denied', async () => { @@ -261,7 +373,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -289,7 +401,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -314,7 +426,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -343,7 +455,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -374,7 +486,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -409,7 +521,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -450,7 +562,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -483,7 +595,7 @@ describe('computeCall RPC', () => { server = new NotebookLocalRpcServer({ execute: async () => ({}) } as never, { computeService: fakeCompute as never }) - const { endpoint, token } = await server.ensureStarted() + const { endpoint, token } = await sessionConnection(server) const res = await fetch(endpoint, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, diff --git a/src/main/notebook/local-rpc-server.test.ts b/src/main/notebook/local-rpc-server.test.ts index d11b02567..251864ace 100644 --- a/src/main/notebook/local-rpc-server.test.ts +++ b/src/main/notebook/local-rpc-server.test.ts @@ -256,6 +256,95 @@ describe('notebook local RPC server', () => { } }) + it('revokes session RPC capabilities and removes aliases when their session is released', async () => { + const root = await createStorageRoot() + const connectorCall = vi.fn(async () => ({ ok: true })) + const service = new NotebookRuntimeService({ + configRoot: root, + dataRoot: root, + projectName: 'default-project', + repository: new NotebookRunRepository(root) + }) + const server = new NotebookLocalRpcServer(service, { + token: 'secret-token', + connectorService: { call: connectorCall } + }) + const connection = await server.issueSessionConnection('notebook-session-1', 'default-project') + server.registerSessionAlias('notebook-session-1', 'real-session-1') + + try { + server.releaseSessionCapabilities('real-session-1') + + const response = await fetch(connection.endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${connection.token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + method: 'mcpCall', + params: { server: 'pubmed', method: 'search', args: {} } + }) + }) + const payload = (await response.json()) as { error: string } + + expect(response.status).toBe(401) + expect(payload.error).toMatch(/invalid notebook rpc token/i) + expect(connectorCall).not.toHaveBeenCalled() + expect( + ( + server as unknown as { + sessionAliases: Map + } + ).sessionAliases.has('notebook-session-1') + ).toBe(false) + } finally { + await server.close() + } + }) + + it('rotates Agent capabilities across a pre-start alias without revoking the control plane', async () => { + const root = await createStorageRoot() + const connectorCall = vi.fn(async () => ({ ok: true })) + const service = new NotebookRuntimeService({ + configRoot: root, + dataRoot: root, + projectName: 'default-project', + repository: new NotebookRunRepository(root) + }) + const server = new NotebookLocalRpcServer(service, { + token: 'secret-token', + connectorService: { call: connectorCall } + }) + const initial = await server.issueSessionConnection('notebook-session-1', 'default-project') + server.registerSessionAlias('notebook-session-1', 'real-session-1') + const control = await server.issueControlConnection('real-session-1', 'default-project') + const replacement = await server.issueSessionConnection('real-session-1', 'default-project') + + const callConnector = (token: string): Promise => + fetch(replacement.endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + method: 'mcpCall', + params: { server: 'pubmed', method: 'search', args: {} } + }) + }) + + try { + await expect(callConnector(initial.token)).resolves.toMatchObject({ status: 401 }) + await expect(callConnector(replacement.token)).resolves.toMatchObject({ status: 200 }) + await expect(callConnector(control.token)).resolves.toMatchObject({ status: 200 }) + expect(connectorCall).toHaveBeenCalledTimes(2) + } finally { + control.release() + await server.close() + } + }) + it('dispatches Artifact Version creation through the authenticated main-process bridge', async () => { const root = await createStorageRoot() const service = new NotebookRuntimeService({ @@ -962,16 +1051,19 @@ describe('notebook local RPC server', () => { token: 'secret-token', computeService: fakeComputeService }) - const connection = await server.ensureStarted() + const connection = await server.issueSessionConnection('my-session', 'default-project') try { // Known session → returns the registered host list. const withHosts = await fetch(connection.endpoint, { method: 'POST', - headers: { authorization: 'Bearer secret-token', 'content-type': 'application/json' }, + headers: { + authorization: `Bearer ${connection.token}`, + 'content-type': 'application/json' + }, body: JSON.stringify({ method: 'computeCall', - params: { op: 'list_compute', session_id: 'my-session' } + params: { op: 'list_compute', session_id: 'forged-session' } }) }) const withHostsPayload = (await withHosts.json()) as { result: string[] } @@ -980,9 +1072,16 @@ describe('notebook local RPC server', () => { expect(withHostsPayload.result).toEqual(['ssh:cluster-1']) // Unknown session → empty array. - const noHosts = await fetch(connection.endpoint, { + const otherConnection = await server.issueSessionConnection( + 'other-session', + 'default-project' + ) + const noHosts = await fetch(otherConnection.endpoint, { method: 'POST', - headers: { authorization: 'Bearer secret-token', 'content-type': 'application/json' }, + headers: { + authorization: `Bearer ${otherConnection.token}`, + 'content-type': 'application/json' + }, body: JSON.stringify({ method: 'computeCall', params: { op: 'list_compute', session_id: 'other-session' } @@ -1031,15 +1130,18 @@ describe('notebook local RPC server', () => { token: 'secret-token', computeService: fakeComputeService }) - const connection = await server.ensureStarted() + const connection = await server.issueSessionConnection('my-session', 'default-project') try { const response = await fetch(connection.endpoint, { method: 'POST', - headers: { authorization: 'Bearer secret-token', 'content-type': 'application/json' }, + headers: { + authorization: `Bearer ${connection.token}`, + 'content-type': 'application/json' + }, body: JSON.stringify({ method: 'computeCall', - params: { op: 'set_concurrency_limit', session_id: 'my-session', limit: 10 } + params: { op: 'set_concurrency_limit', session_id: 'forged-session', limit: 10 } }) }) @@ -1081,15 +1183,18 @@ describe('notebook local RPC server', () => { token: 'secret-token', computeService: fakeComputeService }) - const connection = await server.ensureStarted() + const connection = await server.issueSessionConnection('my-session', 'default-project') try { const response = await fetch(connection.endpoint, { method: 'POST', - headers: { authorization: 'Bearer secret-token', 'content-type': 'application/json' }, + headers: { + authorization: `Bearer ${connection.token}`, + 'content-type': 'application/json' + }, body: JSON.stringify({ method: 'computeCall', - params: { op: 'concurrency_status', session_id: 'my-session' } + params: { op: 'concurrency_status', session_id: 'forged-session' } }) }) const payload = (await response.json()) as { diff --git a/src/main/notebook/local-rpc-server.ts b/src/main/notebook/local-rpc-server.ts index ac3d5f6ec..803a226eb 100644 --- a/src/main/notebook/local-rpc-server.ts +++ b/src/main/notebook/local-rpc-server.ts @@ -39,6 +39,7 @@ type NotebookLocalRpcServerOptions = { args: Record, context?: { sessionId?: string + projectId?: string origin?: 'agent' | 'internal' specialistId?: string } @@ -115,6 +116,12 @@ type ArtifactRpcCapability = Omit void> } +type NotebookRpcSessionBinding = { + sessionId: string + projectId: string + allowedMethods?: ReadonlySet +} + class RpcHttpError extends Error { constructor( readonly statusCode: number, @@ -132,6 +139,7 @@ const ARTIFACT_RPC_METHODS = new Set([ // Capabilities are revoked when the turn ends. This upper bound only limits abandoned tokens, so // it must comfortably exceed long notebook executions that remain inside one active turn. const DEFAULT_ARTIFACT_RPC_CAPABILITY_TTL_MS = 2 * 60 * 60 * 1_000 +const CONTROL_RPC_METHODS = new Set(['mcpCall', 'computeCall']) const isArtifactRpcMethod = (method: string): method is ArtifactRpcMethod => ARTIFACT_RPC_METHODS.has(method as ArtifactRpcMethod) @@ -177,6 +185,8 @@ class NotebookLocalRpcServer { private server: Server | undefined private startPromise: Promise | undefined private readonly sessionAliases = new Map() + private readonly sessionRpcCapabilities = new Map() + private readonly sessionRpcTokens = new Map() // The session → Specialist relationship is established by the ACP runtime, not supplied by the // notebook process. Keeping it here prevents an agent from selecting another Specialist's scope // by forging an RPC parameter. @@ -283,6 +293,8 @@ class NotebookLocalRpcServer { this.server = undefined this.startPromise = undefined this.artifactRpcCapabilities.clear() + this.sessionRpcCapabilities.clear() + this.sessionRpcTokens.clear() if (!server) return @@ -299,6 +311,99 @@ class NotebookLocalRpcServer { this.sessionAliases.set(aliasSessionId, sessionId) } + private resolveSessionCapabilityOwners(sessionId: string): Set { + const ownedSessionIds = new Set([sessionId]) + + // Alias chains are not expected, but computing the small transitive closure keeps rotation and + // revocation correct if an adopted session is ever remapped more than once. + let foundAlias = true + while (foundAlias) { + foundAlias = false + for (const [aliasSessionId, targetSessionId] of this.sessionAliases) { + if (!ownedSessionIds.has(aliasSessionId) && !ownedSessionIds.has(targetSessionId)) continue + if (!ownedSessionIds.has(aliasSessionId)) { + ownedSessionIds.add(aliasSessionId) + foundAlias = true + } + if (!ownedSessionIds.has(targetSessionId)) { + ownedSessionIds.add(targetSessionId) + foundAlias = true + } + } + } + + return ownedSessionIds + } + + private revokeAgentSessionCapabilities(sessionId: string): void { + for (const ownedSessionId of this.resolveSessionCapabilityOwners(sessionId)) { + const token = this.sessionRpcTokens.get(ownedSessionId) + if (token) this.sessionRpcCapabilities.delete(token) + this.sessionRpcTokens.delete(ownedSessionId) + } + } + + // Revokes every bearer capability owned by one app session, including capabilities issued under + // the pre-start alias used before ACP returns the final session id. Control-plane capabilities are + // discovered by their binding rather than sessionRpcTokens because they intentionally do not rotate + // the Agent-facing token. + releaseSessionCapabilities(sessionId: string): void { + const ownedSessionIds = this.resolveSessionCapabilityOwners(sessionId) + + for (const [token, binding] of this.sessionRpcCapabilities) { + if (ownedSessionIds.has(binding.sessionId)) this.sessionRpcCapabilities.delete(token) + } + for (const ownedSessionId of ownedSessionIds) { + this.sessionRpcTokens.delete(ownedSessionId) + this.sessionSpecialists.delete(ownedSessionId) + } + for (const [aliasSessionId, targetSessionId] of this.sessionAliases) { + if (ownedSessionIds.has(aliasSessionId) || ownedSessionIds.has(targetSessionId)) { + this.sessionAliases.delete(aliasSessionId) + } + } + } + + async issueSessionConnection( + sessionId: string, + projectId: string + ): Promise { + const connection = await this.ensureStarted() + // A context reset issues the replacement under the final ACP id, while the original token can be + // keyed by its pre-start notebook-session-* alias. Rotate the complete Agent-facing alias closure; + // dedicated control-plane capabilities stay valid for the live Notebook runtime. + this.revokeAgentSessionCapabilities(sessionId) + + const token = randomUUID() + this.sessionRpcTokens.set(sessionId, token) + this.sessionRpcCapabilities.set(token, { sessionId, projectId }) + return { endpoint: connection.endpoint, token } + } + + // Issues a dedicated capability for the persistent control-plane REPL. Unlike the Agent-facing + // session connection, this does not rotate `sessionRpcTokens`: both callers remain valid, and the + // narrower capability cannot invoke Notebook lifecycle or execution RPC methods. + async issueControlConnection( + sessionId: string, + projectId: string + ): Promise void }> { + const connection = await this.ensureStarted() + const token = randomUUID() + this.sessionRpcCapabilities.set(token, { + sessionId, + projectId, + allowedMethods: CONTROL_RPC_METHODS + }) + + return { + endpoint: connection.endpoint, + token, + release: () => { + this.sessionRpcCapabilities.delete(token) + } + } + } + registerSessionSpecialist(sessionId: string, specialistId: string | undefined): void { if (specialistId) this.sessionSpecialists.set(sessionId, specialistId) else this.sessionSpecialists.delete(sessionId) @@ -427,8 +532,27 @@ class NotebookLocalRpcServer { const acquired = this.acquireArtifactRpcRequest(method, bearerToken, params) params = acquired.params releaseArtifactRequest = acquired.release - } else if (authorization !== `Bearer ${this.token}`) { - throw new RpcHttpError(401, 'Invalid notebook RPC token.') + } else { + const sessionBinding = this.sessionRpcCapabilities.get(bearerToken) + if (sessionBinding) { + if (sessionBinding.allowedMethods && !sessionBinding.allowedMethods.has(method)) { + throw new RpcHttpError(403, `Notebook RPC capability does not allow ${method}.`) + } + // The request body is agent-controlled. Owner fields always come from the unforgeable, + // per-session capability issued while building this session's Notebook environment. + params = { + ...params, + sessionId: sessionBinding.sessionId, + projectId: sessionBinding.projectId + } + } else { + if (authorization !== `Bearer ${this.token}`) { + throw new RpcHttpError(401, 'Invalid notebook RPC token.') + } + if (method === 'mcpCall' || method === 'computeCall') { + throw new RpcHttpError(401, 'A session-bound notebook RPC token is required.') + } + } } // Resolve pre-session aliases before the runtime service looks up persistent state. const result = await this.dispatch(method, this.resolveSessionAlias(params)) @@ -533,8 +657,10 @@ class NotebookLocalRpcServer { const toolMethod = typeof params.method === 'string' ? params.method : '' const args = isRecord(params.args) ? params.args : {} const sessionId = typeof params.sessionId === 'string' ? params.sessionId : undefined + const projectId = typeof params.projectId === 'string' ? params.projectId : undefined return this.connectorService.call(server, toolMethod, args, { sessionId, + ...(projectId ? { projectId } : {}), origin: 'agent', ...(sessionId && this.sessionSpecialists.get(sessionId) ? { specialistId: this.sessionSpecialists.get(sessionId) } @@ -542,12 +668,14 @@ class NotebookLocalRpcServer { }) } - // computeCall routes compute API operations to ComputeService (design.md §2). The `op` field - // allows future ops (list, details) to be added without breaking the contract (design.md §5). - // Not session-scoped — like mcpCall it bypasses the session routing below. + // computeCall routes compute API operations to ComputeService. Ownership comes only from the + // session-bound RPC capability; snake_case owner fields in the agent-controlled body are never + // accepted as authority. if (method === 'computeCall') { if (!this.computeService) throw new Error('Compute service is not configured.') const op = typeof params.op === 'string' ? params.op : '' + const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '' + const projectId = typeof params.projectId === 'string' ? params.projectId : '' if (op === 'call_command') { const providerId = typeof params.provider_id === 'string' ? params.provider_id : '' const cmd = typeof params.cmd === 'string' ? params.cmd : '' @@ -555,10 +683,6 @@ class NotebookLocalRpcServer { const loginShell = typeof params.login_shell === 'boolean' ? params.login_shell : true const timeoutSeconds = typeof params.timeout_seconds === 'number' ? params.timeout_seconds : undefined - // Optional session/project context for grant-scope approval memory (issue 05). - // When absent, callCommand falls back to the legacy 'once'-only behaviour. - const sessionId = typeof params.session_id === 'string' ? params.session_id : undefined - const projectId = typeof params.project_id === 'string' ? params.project_id : undefined const context = sessionId && projectId ? { sessionId, projectId } : undefined try { return await this.computeService.callCommand( @@ -613,9 +737,6 @@ class NotebookLocalRpcServer { if (op === 'download') { const providerId = typeof params.provider_id === 'string' ? params.provider_id : '' const remotePath = typeof params.remote_path === 'string' ? params.remote_path : '' - // Optional session/project context for grant-scope approval memory (matching call_command). - const sessionId = typeof params.session_id === 'string' ? params.session_id : undefined - const projectId = typeof params.project_id === 'string' ? params.project_id : undefined const context = sessionId && projectId ? { sessionId, projectId } : undefined return this.computeService.download( providerId, @@ -631,8 +752,6 @@ class NotebookLocalRpcServer { const providerId = typeof params.provider_id === 'string' ? params.provider_id : '' const intent = typeof params.intent === 'string' ? params.intent : '' const command = typeof params.command === 'string' ? params.command : '' - const sessionId = typeof params.session_id === 'string' ? params.session_id : '' - const projectId = typeof params.project_id === 'string' ? params.project_id : '' const options = { environment: typeof params.environment === 'string' ? params.environment : undefined, resourceRequest: isRecord(params.resources) @@ -681,7 +800,6 @@ class NotebookLocalRpcServer { // this conversation via the ComputeHostSelector. Session id comes from COMPUTE_SESSION_ID in // the repl spawn env (same passthrough used by submit_job / call_command). if (op === 'list_compute') { - const sessionId = typeof params.session_id === 'string' ? params.session_id : '' return this.computeService.getEnabledComputeHosts(sessionId) } @@ -689,7 +807,6 @@ class NotebookLocalRpcServer { // Limits the number of non-terminal jobs across all providers in this session. Jobs exceeding // the limit enter 'queued' state and auto-dispatch when slots free up. if (op === 'set_concurrency_limit') { - const sessionId = typeof params.session_id === 'string' ? params.session_id : '' const limit = typeof params.limit === 'number' ? params.limit : 0 return this.computeService.setSessionConcurrencyLimit(sessionId, limit) } @@ -698,7 +815,6 @@ class NotebookLocalRpcServer { // Returns session_limit (user-set or null), active_count (non-terminal jobs in session), // queued_count (queued jobs in session), and provider_ceilings (per-provider hard limits). if (op === 'concurrency_status') { - const sessionId = typeof params.session_id === 'string' ? params.session_id : '' return this.computeService.getSessionConcurrencyStatus(sessionId) } diff --git a/src/main/notebook/runtime-service.test.ts b/src/main/notebook/runtime-service.test.ts index a64def98d..ad3d1b3ff 100644 --- a/src/main/notebook/runtime-service.test.ts +++ b/src/main/notebook/runtime-service.test.ts @@ -816,6 +816,61 @@ describe('notebook runtime service', () => { }) }) + it('caches a session-bound control connection and releases it with the runtime session', async () => { + const root = await createStorageRoot() + const executions: NotebookExecutionRequest[] = [] + const release = vi.fn() + const resolveConnection = vi.fn(async (binding: { sessionId: string; projectId: string }) => ({ + endpoint: 'http://127.0.0.1:1/x', + token: 'session-token', + release, + binding + })) + const service = new NotebookRuntimeService({ + configRoot: root, + dataRoot: root, + projectName: 'default-project', + repository: new NotebookRunRepository(root), + executorFactory: () => ({ + execute: async (request): Promise => { + executions.push(request) + return { + status: 'completed', + stdout: '', + stderr: '', + traceback: '', + cwdAfter: request.cwd, + outputs: [] + } + }, + shutdown: async () => ({ reaped: true }) + }) + }) + service.setMcpRpcConnectionResolver(resolveConnection) + + for (const code of ['return 1', 'return 2']) { + await service.executeControl({ + projectName: 'default-project', + sessionId: 'session-1', + workspaceCwd: root, + code + }) + } + + expect(resolveConnection).toHaveBeenCalledOnce() + expect(resolveConnection).toHaveBeenCalledWith({ + sessionId: 'session-1', + projectId: 'default-project' + }) + expect(executions.map((request) => request.mcpRpcToken)).toEqual([ + 'session-token', + 'session-token' + ]) + + await service.shutdown({ sessionId: 'session-1', workspaceCwd: root }) + expect(release).toHaveBeenCalledOnce() + }) + it('rejects a dynamically executed package installer in the control REPL before dispatch', async () => { const root = await createStorageRoot() const execute = vi.fn() diff --git a/src/main/notebook/runtime-service.ts b/src/main/notebook/runtime-service.ts index aea2144ad..cb26bb327 100644 --- a/src/main/notebook/runtime-service.ts +++ b/src/main/notebook/runtime-service.ts @@ -327,10 +327,11 @@ type DefaultEnvProvisioner = { provisionR: (onProgress: (p: ProvisionProgress) => void) => Promise } -// The connector RPC endpoint/token injected into a kernel's spawn env for host.mcp(). The token is -// stable for the lifetime of the local RPC server that issues it, so resolving it again on every run -// is cheap and always yields the same value the already-spawned kernel captured at its own spawn time. -type McpRpcConnection = { endpoint: string; token: string } +// The session-scoped connector RPC capability injected into the persistent control-plane REPL. The +// service caches it for the RuntimeSession lifetime because the child captures it only when spawned; +// release revokes that capability when the runtime session is shut down. +type McpRpcConnection = { endpoint: string; token: string; release?: () => void } +type McpRpcConnectionBinding = { sessionId: string; projectId: string } type NotebookRuntimeServiceOptions = { // Config root: source of the app-owned claude config dir (protected from the kernel). Never relocated. @@ -344,7 +345,7 @@ type NotebookRuntimeServiceOptions = { // Resolves the connector RPC connection to inject into the kernel spawn env. Usually set after // construction via setMcpRpcConnectionResolver, since the RPC server is constructed with this // service as a dependency (constructing them in the other order would cycle). - getMcpRpcConnection?: () => Promise + getMcpRpcConnection?: (binding: McpRpcConnectionBinding) => Promise // Resolves the user-configured package mirror (settings). Usually set after construction via // setPackageMirrorResolver, mirroring getMcpRpcConnection above — kept optional/async so a // synchronous test double works just as well as the real (disk-backed) settings service. @@ -441,6 +442,9 @@ type RuntimeSession = { // control runs proceed independently of data cells but are still serialized among themselves (the // single control process handles one request at a time). controlQueue: Promise + // Dedicated capability for host.mcp/host.compute. It is separate from the Agent-facing Notebook MCP + // token so reconnect rotation cannot invalidate a live persistent REPL. + mcpRpcConnection?: McpRpcConnection // Process keys whose kernel was lost (crash/hard-timeout) during their current run. A run clears its // key before executing and re-adds it via onTerminated on loss, so the post-run 'idle' write is // skipped and the 'terminated' status survives (the next clean run of that key clears it back). @@ -1063,7 +1067,8 @@ class NotebookRuntimeService { // can settle them) rather than leaking a dangling teardown. private readonly revocationDrains = new Set>() private runSequence = 0 - private mcpRpcConnectionResolver: (() => Promise) | undefined + private mcpRpcConnectionResolver: + ((binding: McpRpcConnectionBinding) => Promise) | undefined private packageMirrorResolver: (() => PackageMirror | undefined | Promise) | undefined private runtimeEnablementResolver: @@ -1659,7 +1664,9 @@ class NotebookRuntimeService { // Wires the connector RPC connection lookup after construction (the local RPC server that provides // it is itself constructed with this service as a dependency, so it cannot be passed in up front). - setMcpRpcConnectionResolver(resolver: () => Promise): void { + setMcpRpcConnectionResolver( + resolver: (binding: McpRpcConnectionBinding) => Promise + ): void { this.mcpRpcConnectionResolver = resolver } @@ -2178,9 +2185,9 @@ class NotebookRuntimeService { inputFiles: request.provenanceContext ? (request.registeredInputFiles ?? []) : [] } - // Backed by the RPC server's cached start promise, so it settles to the same stable {endpoint, - // token} the repl kernel captured at its own spawn time. - const mcpRpc = await this.resolveMcpRpcConnection() + // Resolve once per RuntimeSession: the persistent repl captures this dedicated capability when + // it starts, and the service releases it with the session. + const mcpRpc = await this.resolveMcpRpcConnection(session) const blockedMutation = detectManagedRuntimeMutation({ source: request.code, @@ -3300,14 +3307,19 @@ class NotebookRuntimeService { async shutdown( request: NotebookSessionRequest ): Promise<{ sessionId: string; status: 'shutdown' }> { - const session = this.sessions.get(request.sessionId) + return this.shutdownSession(request.sessionId) + } + + async shutdownSession(sessionId: string): Promise<{ sessionId: string; status: 'shutdown' }> { + const session = this.sessions.get(sessionId) if (session) { await session.executor.shutdown() - this.sessions.delete(request.sessionId) + this.releaseMcpRpcConnection(session) + this.sessions.delete(sessionId) } - return { sessionId: request.sessionId, status: 'shutdown' } + return { sessionId, status: 'shutdown' } } // Crash recovery (WS13): reconcile any runtime operation the previous process left in flight. Run at @@ -3652,9 +3664,9 @@ class NotebookRuntimeService { // Let any in-flight disable drain-and-close settle first, so a revocation teardown never races the // shutdown (and tests don't leak a dangling background drain). await Promise.all(Array.from(this.revocationDrains)).catch(() => undefined) - const results = await Promise.all( - Array.from(this.sessions.values()).map((session) => session.executor.shutdown()) - ) + const sessions = Array.from(this.sessions.values()) + const results = await Promise.all(sessions.map((session) => session.executor.shutdown())) + for (const session of sessions) this.releaseMcpRpcConnection(session) this.sessions.clear() return { reaped: results.every((result) => result.reaped) } } @@ -3909,18 +3921,32 @@ class NotebookRuntimeService { return { run, result } } - // Best-effort lookup of the connector RPC connection: host.mcp() is unavailable (rather than the - // whole cell failing) when no resolver is wired or the RPC server fails to start. - private async resolveMcpRpcConnection(): Promise { + // Best-effort, once-per-runtime-session capability lookup: host.mcp() is unavailable (rather than + // the whole cell failing) when no resolver is wired or the RPC server fails to start. + private async resolveMcpRpcConnection( + session: RuntimeSession + ): Promise { + if (session.mcpRpcConnection) return session.mcpRpcConnection if (!this.mcpRpcConnectionResolver) return undefined try { - return await this.mcpRpcConnectionResolver() + const connection = await this.mcpRpcConnectionResolver({ + sessionId: session.sessionId, + projectId: session.projectName + }) + session.mcpRpcConnection = connection + return connection } catch { return undefined } } + private releaseMcpRpcConnection(session: RuntimeSession): void { + const connection = session.mcpRpcConnection + session.mcpRpcConnection = undefined + connection?.release?.() + } + // Best-effort lookup of the configured package mirror: an install falls back to the region default // (never a hard failure) when no resolver is wired or the settings read throws. private async resolvePackageMirror(): Promise { diff --git a/src/main/permission-grants/capability.test.ts b/src/main/permission-grants/capability.test.ts new file mode 100644 index 000000000..e8d866017 --- /dev/null +++ b/src/main/permission-grants/capability.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { capabilityFromLegacyCategory, categoryFromTrustedToolName } from './capability' + +describe('capabilityFromLegacyCategory', () => { + it.each(['WebFetch', 'web_fetch', 'WebSearch', 'provider_specific_tool'])( + 'keeps the unregistered provider-native tool %s Once-only', + (providerName) => { + expect(capabilityFromLegacyCategory(`tool:${providerName}`)).toBeUndefined() + } + ) + + it.each([ + 'curl -H "Authorization: Bearer abc" https://example.com', + 'TOKEN=abc python upload.py', + 'curl https://user:password@example.com/data', + 'curl https://example.com/data?api_key=abc', + 'deploy --password hunter2', + 'GITHUB_PAT=ghp_example python upload.py', + 'AWS_ACCESS_KEY_ID=AKIAEXAMPLE python upload.py', + 'curl -u user:password https://example.com', + 'curl -H "X-Auth-Token: secret" https://example.com', + 'curl --oauth2-bearer eyJhbGciOiJIUzI1NiJ9.payload.signature https://example.com', + 'sshpass -p secret ssh user@example.com', + 'curl -H "X-Custom-Auth: opaque" https://example.com' + ])('keeps a secret-bearing exact command Once-only', (command) => { + expect(capabilityFromLegacyCategory(`shell:${command}`)).toBeUndefined() + }) + + it('persists a content-independent exact command as a redacted digest', () => { + expect(capabilityFromLegacyCategory('shell:git status')).toMatchObject({ + kind: 'execution', + key: 'exec:agent/shell', + qualifier: { mode: 'exact', value: expect.stringMatching(/^sha256:v1:/) } + }) + }) + + it.each([ + 'git push', + './git status', + '/tmp/git status', + 'git.exe status', + 'Git status', + 'python analyze.py', + 'python /tmp/analyze.py', + 'bash analyze.sh', + 'bash ./analyze.sh', + 'node script.js', + 'Rscript analysis.R', + 'pytest tests/test_model.py', + 'bash -c analyze.sh', + 'python -c print(1)', + 'python analyze.py --token value' + ])('keeps an unproven exact command Once-only: %s', (command) => { + expect(capabilityFromLegacyCategory(`shell:${command}`)).toBeUndefined() + }) + + it('keeps an unregistered app-owned MCP method Once-only', () => { + expect( + capabilityFromLegacyCategory('mcp:open-science-notebook/reviewer_internal') + ).toBeUndefined() + }) + + it.each([ + ['CreateAgent', 'customize:agent_create'], + ['agent_update', 'customize:agent_update'], + ['Publish skill', 'customize:skill_publish'], + ['skill_edit', 'customize:skill_edit'], + ['AttachSkill', 'customize:agent_attach_skill'], + ['agent_detach_skill', 'customize:agent_detach_skill'], + ['Attach connector', 'customize:agent_attach_connector'], + ['agent_detach_connector', 'customize:agent_detach_connector'], + ['local_exec_python', 'local_exec:python'], + ['local-bash', 'local_exec:bash'] + ])('normalizes the registered tool name %s', (providerName, category) => { + expect(categoryFromTrustedToolName(providerName)).toBe(category) + expect(capabilityFromLegacyCategory(category)).toBeDefined() + }) +}) diff --git a/src/main/permission-grants/capability.ts b/src/main/permission-grants/capability.ts new file mode 100644 index 000000000..9d7b82147 --- /dev/null +++ b/src/main/permission-grants/capability.ts @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto' + +import type { PermissionCapability } from '../../shared/permission-grants' +import { isPreRegisteredPermissionIdentity } from './identity-catalog' + +const NOTEBOOK_RUNTIME_QUALIFIERS = new Set(['python', 'r', 'javascript', 'bash']) +const FILE_OPERATION_KEYS: Readonly> = { + Read: 'read', + Write: 'write', + Edit: 'edit', + MultiEdit: 'edit', + NotebookEdit: 'notebook_edit', + read: 'read', + edit: 'edit', + delete: 'delete', + move: 'move' +} +const TRUSTED_TOOL_CATEGORIES: Readonly> = { + agent_create: 'customize:agent_create', + create_agent: 'customize:agent_create', + agent_update: 'customize:agent_update', + update_agent: 'customize:agent_update', + skill_publish: 'customize:skill_publish', + publish_skill: 'customize:skill_publish', + skill_edit: 'customize:skill_edit', + edit_skill: 'customize:skill_edit', + agent_attach_skill: 'customize:agent_attach_skill', + attach_skill: 'customize:agent_attach_skill', + agent_detach_skill: 'customize:agent_detach_skill', + detach_skill: 'customize:agent_detach_skill', + agent_attach_connector: 'customize:agent_attach_connector', + attach_connector: 'customize:agent_attach_connector', + agent_detach_connector: 'customize:agent_detach_connector', + detach_connector: 'customize:agent_detach_connector', + local_exec_python: 'local_exec:python', + local_python: 'local_exec:python', + python_exec: 'local_exec:python', + local_exec_bash: 'local_exec:bash', + local_bash: 'local_exec:bash', + bash_exec: 'local_exec:bash' +} +// Persisting an exact command is safe only when the input itself is not credential-bearing. The +// digest protects display/storage privacy; it does not make a secret reusable authority. +const SECRET_BEARING_INPUT_PATTERNS = [ + /\b(?:authorization|proxy-authorization|x-api-key|api-key|x-auth-token|x-amz-security-token|cookie)\s*:/i, + /\b(?:token|access[_-]?token|api[_-]?key|secret|password|passwd|credential)s?\s*=/i, + /--(?:token|access-token|api-key|secret|password|passwd)(?:=|\s+)/i, + /\b[A-Z0-9_]*(?:TOKEN|API_KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|PAT|ACCESS_KEY(?:_ID)?|SECRET_ACCESS_KEY|CLIENT_SECRET|PRIVATE_KEY)\s*=/, + /(?:^|\s)(?:-u|--user)(?:=|\s+)['"]?[^\s:'"]+:[^\s'"]+/i, + /\b(?:github_pat_|gh[pousr]_|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{16,})[A-Za-z0-9_-]*/, + /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@/i, + /[?&](?:token|access_token|api_key|key|secret|password|signature)=[^&#\s]+/i +] as const + +const PERSISTABLE_GIT_SUBCOMMANDS = new Set(['status']) + +const containsSecretBearingMaterial = (value: string): boolean => + SECRET_BEARING_INPUT_PATTERNS.some((pattern) => pattern.test(value)) + +// Exact-command memory is deliberately opt-in. A command digest proves only that the command text is +// unchanged; it cannot prove that a referenced script or local executable still has the same content. +// V1 therefore persists only commands whose safety is independent of mutable workspace files. Every +// interpreter, test runner, and shell-script invocation remains provider Once-only. +const isPersistableExactCommand = (command: string): boolean => { + if (containsSecretBearingMaterial(command) || /[\r\n;&|<>`$\\'"=]/.test(command)) return false + const tokens = command.trim().split(/\s+/) + // Only the PATH-resolved system command is stable enough for V1. A path-qualified executable can + // be replaced after approval while leaving the stored command digest unchanged. + const executable = tokens.shift() + if (executable !== 'git') return false + + const subcommand = tokens.shift()?.toLowerCase() + return Boolean(subcommand && PERSISTABLE_GIT_SUBCOMMANDS.has(subcommand) && tokens.length === 0) +} + +const exactPermissionQualifier = (value: string): { mode: 'exact'; value: string } => ({ + mode: 'exact', + value: `sha256:v1:${createHash('sha256').update(value).digest('hex')}` +}) + +const normalizeTrustedToolName = (value: string): string => + value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() + +const categoryFromTrustedToolName = (value: string | undefined): string | undefined => + value ? TRUSTED_TOOL_CATEGORIES[normalizeTrustedToolName(value)] : undefined + +const capabilityFromLegacyCategory = (categoryKey: string): PermissionCapability | undefined => { + if (categoryKey.startsWith('customize:')) { + const key = categoryKey + return isPreRegisteredPermissionIdentity('customize_mutation', key) + ? { kind: 'customize_mutation', key } + : undefined + } + + if (categoryKey.startsWith('local_exec:')) { + const key = `exec:local/${categoryKey.slice('local_exec:'.length)}` + return isPreRegisteredPermissionIdentity('execution', key) + ? { kind: 'execution', key, qualifier: { mode: 'any' } } + : undefined + } + + if (categoryKey.startsWith('shell:')) { + const command = categoryKey.slice('shell:'.length) + if (!command || !isPersistableExactCommand(command)) return undefined + return { + kind: 'execution', + key: 'exec:agent/shell', + qualifier: exactPermissionQualifier(command) + } + } + + if (categoryKey.startsWith('mcp:')) { + const descriptor = categoryKey.slice('mcp:'.length) + const separator = descriptor.lastIndexOf(':') + const possibleQualifier = separator >= 0 ? descriptor.slice(separator + 1) : undefined + const hasRuntimeQualifier = + possibleQualifier !== undefined && NOTEBOOK_RUNTIME_QUALIFIERS.has(possibleQualifier) + const identity = hasRuntimeQualifier ? descriptor.slice(0, separator) : descriptor + if (!identity.includes('/')) return undefined + const key = `mcp:${identity}` + if ( + identity.startsWith('open-science-') && + !isPreRegisteredPermissionIdentity('mcp_tool', key) + ) { + return undefined + } + return { + kind: 'mcp_tool', + key, + ...(hasRuntimeQualifier + ? { qualifier: { mode: 'category' as const, value: possibleQualifier } } + : {}) + } + } + + if (categoryKey === 'skill') { + return { kind: 'skill_operation', key: 'skill:invoke' } + } + + if (categoryKey.startsWith('file:')) { + const operation = FILE_OPERATION_KEYS[categoryKey.slice('file:'.length)] + return operation ? { kind: 'file_operation', key: `file:${operation}` } : undefined + } + + // V1 has no persistable built-in provider tools. Unknown provider-native fallback names remain + // Once-only until an explicit cross-framework Broker registration is added. + return undefined +} + +export { + capabilityFromLegacyCategory, + categoryFromTrustedToolName, + containsSecretBearingMaterial, + exactPermissionQualifier +} diff --git a/src/main/permission-grants/catalog.test.ts b/src/main/permission-grants/catalog.test.ts new file mode 100644 index 000000000..902579f4f --- /dev/null +++ b/src/main/permission-grants/catalog.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' + +import { projectPermissionGrantSnapshot } from './catalog' + +describe('permission grant renderer projection', () => { + it('uses owner names and never exposes an exact qualifier digest', () => { + const digest = `sha256:v1:${'a'.repeat(64)}` + const snapshot = projectPermissionGrantSnapshot( + [ + { + id: 'grant-1', + revision: 1, + capability: { + kind: 'execution', + key: 'exec:agent/shell', + qualifier: { mode: 'exact', value: digest } + }, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + } + ], + { + projects: new Map([['project-1', 'Research project']]), + sessions: new Map([['session-1', 'Analyze samples']]) + } + ) + + expect(snapshot).toMatchObject({ + counts: { all: 1, global: 0, project: 0, session: 1 }, + grants: [ + { + capabilityLabel: 'Shell', + qualifierLabel: 'Specific input', + scopeLabel: 'Session: Analyze samples' + } + ] + }) + expect(JSON.stringify(snapshot)).not.toContain(digest) + }) + + it('discloses when a broader grant still covers a revocable row', () => { + const capability = { kind: 'execution' as const, key: 'exec:local/python' } + const snapshot = projectPermissionGrantSnapshot([ + { id: 'global', revision: 1, capability, scope: { kind: 'global' } }, + { + id: 'project', + revision: 1, + capability, + scope: { kind: 'project', projectId: 'project-1' } + }, + { + id: 'session', + revision: 1, + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + } + ]) + + expect(snapshot.grants.find((grant) => grant.id === 'global')?.coveredBy).toBeUndefined() + expect(snapshot.grants.find((grant) => grant.id === 'project')?.coveredBy).toBe('global') + expect(snapshot.grants.find((grant) => grant.id === 'session')?.coveredBy).toBe('project') + }) + + it('projects Connector block/allow coverage without mutating the grant', () => { + const records = [ + { + id: 'blocked', + revision: 1, + capability: { kind: 'mcp_tool' as const, key: 'mcp:custom-1/write' }, + scope: { kind: 'global' as const } + }, + { + id: 'covered', + revision: 1, + capability: { kind: 'mcp_tool' as const, key: 'mcp:chemistry/search' }, + scope: { kind: 'global' as const } + } + ] + const snapshot = projectPermissionGrantSnapshot(records, { + connectorPolicy: { + bundledConnectorIds: ['chemistry'], + customMcpServers: [{ id: 'custom-1', name: 'renamed', enabled: true }], + blockedToolIds: ['renamed/write'], + askToolIds: ['renamed/write'] + } + }) + + expect(snapshot.grants.find((grant) => grant.id === 'blocked')).toMatchObject({ + connectorServerId: 'custom-1', + connectorToolName: 'write', + effectiveState: 'blocked_by_policy', + policyHint: 'Blocked in Connectors; this permission is currently inactive' + }) + expect(snapshot.grants.find((grant) => grant.id === 'covered')).toMatchObject({ + effectiveState: 'covered_by_policy', + policyHint: 'Allowed by Connector policy even without this permission' + }) + }) + + it('does not project Connector policy onto app-owned MCP tools', () => { + const snapshot = projectPermissionGrantSnapshot( + [ + { + id: 'notebook', + revision: 1, + capability: { + kind: 'mcp_tool', + key: 'mcp:open-science-notebook/notebook_execute' + }, + scope: { kind: 'global' } + } + ], + { connectorPolicy: { bundledConnectorIds: ['chemistry'] } } + ) + + expect(snapshot.grants[0]).not.toHaveProperty('connectorServerId') + expect(snapshot.grants[0]).not.toHaveProperty('effectiveState') + expect(snapshot.grants[0]).not.toHaveProperty('policyHint') + }) + + it('uses the stable customization identity for its display label', () => { + const snapshot = projectPermissionGrantSnapshot([ + { + id: 'customize', + revision: 1, + capability: { kind: 'customize_mutation', key: 'customize:agent_attach_connector' }, + scope: { kind: 'global' } + } + ]) + + expect(snapshot.grants[0].capabilityLabel).toBe('Attach connector') + }) +}) diff --git a/src/main/permission-grants/catalog.ts b/src/main/permission-grants/catalog.ts new file mode 100644 index 000000000..d3544994a --- /dev/null +++ b/src/main/permission-grants/catalog.ts @@ -0,0 +1,213 @@ +import type { + PermissionGrantFamily, + PermissionGrantMutationResult, + PermissionGrantMutationView, + PermissionGrantRecord, + PermissionGrantSnapshot, + PermissionGrantView +} from '../../shared/permission-grants' + +type PermissionGrantNames = { + projects?: ReadonlyMap + sessions?: ReadonlyMap + connectorPolicy?: ConnectorPolicySnapshot +} + +type PermissionGrantProjectionMetadata = Pick< + PermissionGrantSnapshot, + 'version' | 'incompleteStores' +> + +type ConnectorPolicySnapshot = { + bundledConnectorIds?: readonly string[] + autoAllowIds?: readonly string[] + blockedToolIds?: readonly string[] + askToolIds?: readonly string[] + disabledConnectorIds?: readonly string[] + customMcpServers?: ReadonlyArray<{ id: string; name: string; enabled: boolean }> +} + +const CUSTOMIZE_LABELS: Readonly> = { + 'customize:agent_create': 'Create agent', + 'customize:agent_update': 'Update agent', + 'customize:skill_publish': 'Publish skill', + 'customize:skill_edit': 'Edit skill', + 'customize:agent_attach_skill': 'Attach skill', + 'customize:agent_detach_skill': 'Detach skill', + 'customize:agent_attach_connector': 'Attach connector', + 'customize:agent_detach_connector': 'Detach connector' +} + +const titleFromKey = (key: string): string => { + const leaf = key.split(/[/:]/).filter(Boolean).at(-1) ?? key + return leaf + .replaceAll('_', ' ') + .replaceAll('-', ' ') + .replace(/^./, (value) => value.toUpperCase()) +} + +const familyFor = (record: PermissionGrantRecord): PermissionGrantFamily => { + switch (record.capability.kind) { + case 'customize_mutation': + return 'registry_writes' + case 'execution': + return 'local_compute' + case 'mcp_tool': + return 'connectors' + case 'file_operation': + return 'file_operations' + case 'skill_operation': + return 'skills' + case 'builtin_tool': + return 'built_in_tools' + } +} + +const capabilityLabelFor = (record: PermissionGrantRecord): string => { + if (record.capability.kind === 'customize_mutation') { + return CUSTOMIZE_LABELS[record.capability.key] ?? titleFromKey(record.capability.key) + } + return titleFromKey(record.capability.key) +} + +const qualifierLabelFor = (record: PermissionGrantRecord): string | undefined => { + const qualifier = record.capability.qualifier + if (!qualifier) return undefined + if (qualifier.mode === 'any') return 'Any call' + if (qualifier.mode === 'exact') return 'Specific input' + return qualifier.value +} + +const capabilityIdentity = (record: PermissionGrantRecord): string => { + const qualifier = record.capability.qualifier + return JSON.stringify([ + record.capability.kind, + record.capability.key, + qualifier?.mode ?? 'none', + qualifier && qualifier.mode !== 'any' ? qualifier.value : null + ]) +} + +const coveringScopeFor = ( + record: PermissionGrantRecord, + records: PermissionGrantRecord[] +): 'global' | 'project' | undefined => { + if (record.scope.kind === 'global') return undefined + const identity = capabilityIdentity(record) + const matching = records.filter( + (candidate) => candidate.id !== record.id && capabilityIdentity(candidate) === identity + ) + if (record.scope.kind === 'session') { + const projectId = record.scope.projectId + if ( + matching.some( + (candidate) => candidate.scope.kind === 'project' && candidate.scope.projectId === projectId + ) + ) { + return 'project' + } + } + return matching.some((candidate) => candidate.scope.kind === 'global') ? 'global' : undefined +} + +const connectorProjection = ( + record: PermissionGrantRecord, + policy: ConnectorPolicySnapshot | undefined +): Pick< + PermissionGrantView, + 'connectorServerId' | 'connectorToolName' | 'effectiveState' | 'policyHint' +> => { + if (record.capability.kind !== 'mcp_tool') return {} + const match = /^mcp:([^/]+)\/(.+)$/.exec(record.capability.key) + if (!match) return {} + const [, serverId, toolName] = match + const custom = policy?.customMcpServers?.find((server) => server.id === serverId) + const isBundled = policy?.bundledConnectorIds?.includes(serverId) ?? false + if (!custom && !isBundled) return {} + const aliases = custom ? [custom.id, custom.name] : [serverId] + const hasToolPolicy = (entries: readonly string[] | undefined): boolean => + aliases.some((alias) => entries?.includes(`${alias}/${toolName}`)) ?? false + const disabled = custom + ? !custom.enabled + : (policy?.disabledConnectorIds ?? []).includes(serverId) + const blocked = disabled || hasToolPolicy(policy?.blockedToolIds) + const covered = + aliases.some((alias) => policy?.autoAllowIds?.includes(alias)) || + !hasToolPolicy(policy?.askToolIds) + + return { + connectorServerId: serverId, + connectorToolName: toolName, + effectiveState: blocked ? 'blocked_by_policy' : covered ? 'covered_by_policy' : 'active', + ...(blocked + ? { policyHint: 'Blocked in Connectors; this permission is currently inactive' } + : covered + ? { policyHint: 'Allowed by Connector policy even without this permission' } + : {}) + } +} + +const projectGrantRecord = ( + record: PermissionGrantRecord, + names: PermissionGrantNames, + coveredBy?: 'global' | 'project' +): PermissionGrantView => { + const scope = record.scope + const projectName = scope.kind === 'global' ? undefined : names.projects?.get(scope.projectId) + const sessionName = scope.kind === 'session' ? names.sessions?.get(scope.sessionId) : undefined + const scopeLabel = + scope.kind === 'global' + ? 'Global' + : scope.kind === 'project' + ? `Project: ${projectName ?? 'Unknown project'}` + : `Session: ${sessionName ?? 'Unknown session'}` + + return { + id: record.id, + revision: record.revision, + family: familyFor(record), + capabilityKind: record.capability.kind, + capabilityLabel: capabilityLabelFor(record), + ...(qualifierLabelFor(record) ? { qualifierLabel: qualifierLabelFor(record) } : {}), + scopeKind: scope.kind, + scopeLabel, + ...(coveredBy ? { coveredBy } : {}), + ...connectorProjection(record, names.connectorPolicy), + ...(scope.kind === 'global' ? {} : { projectId: scope.projectId }), + ...(scope.kind === 'session' ? { sessionId: scope.sessionId } : {}), + ...(record.createdAt ? { createdAt: record.createdAt } : {}) + } +} + +const projectPermissionGrantSnapshot = ( + records: PermissionGrantRecord[], + names: PermissionGrantNames = {}, + metadata: PermissionGrantProjectionMetadata = { version: 0, incompleteStores: [] } +): PermissionGrantSnapshot => { + const grants = records.map((record) => + projectGrantRecord(record, names, coveringScopeFor(record, records)) + ) + return { + ...metadata, + grants, + counts: { + all: grants.length, + global: grants.filter((grant) => grant.scopeKind === 'global').length, + project: grants.filter((grant) => grant.scopeKind === 'project').length, + session: grants.filter((grant) => grant.scopeKind === 'session').length + } + } +} + +const projectPermissionGrantMutation = ( + result: PermissionGrantMutationResult, + names: PermissionGrantNames = {}, + metadata: PermissionGrantProjectionMetadata = { version: 0, incompleteStores: [] } +): PermissionGrantMutationView => ({ + ...projectPermissionGrantSnapshot(result.grants, names, metadata), + ...(result.receipt ? { receipt: result.receipt } : {}), + conflicts: result.conflicts +}) + +export { projectPermissionGrantMutation, projectPermissionGrantSnapshot } +export type { ConnectorPolicySnapshot, PermissionGrantNames, PermissionGrantProjectionMetadata } diff --git a/src/main/permission-grants/connector-broker.test.ts b/src/main/permission-grants/connector-broker.test.ts new file mode 100644 index 000000000..114872e82 --- /dev/null +++ b/src/main/permission-grants/connector-broker.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PermissionGrantRecord } from '../../shared/permission-grants' +import type { PermissionGrantRegistry } from './registry' +import { ConnectorPermissionBroker, type ConnectorPermissionRequest } from './connector-broker' + +const capability = { + kind: 'mcp_tool' as const, + key: 'mcp:pubmed/search_articles', + qualifier: { mode: 'any' as const } +} + +const createRequest = ( + overrides?: Partial +): ConnectorPermissionRequest => ({ + capability, + context: { projectId: 'project-1', sessionId: 'session-1' }, + connector: 'pubmed', + method: 'search_articles', + args: { query: 'tumor immunology' }, + policy: { + aliases: ['pubmed'], + askToolIds: ['pubmed/search_articles'] + }, + ...overrides +}) + +const createRegistry = ( + resolved?: PermissionGrantRecord +): { + registry: PermissionGrantRegistry + resolve: ReturnType> + remember: ReturnType> +} => { + const resolve = vi + .fn() + .mockResolvedValue( + resolved ? { grant: resolved, matchedScope: resolved.scope.kind } : undefined + ) + const remember = vi + .fn() + .mockImplementation(async ({ capability: rememberedCapability, scope }) => ({ + id: 'grant-1', + capability: rememberedCapability, + scope, + revision: 1 + })) + return { + registry: { resolve, remember } as unknown as PermissionGrantRegistry, + resolve, + remember + } +} + +describe('ConnectorPermissionBroker', () => { + it('enforces Block before consulting remembered grants or prompting', () => { + const { registry, resolve } = createRegistry() + const prompt = vi.fn() + const broker = new ConnectorPermissionBroker(registry, prompt) + const request = createRequest({ + policy: { + aliases: ['pubmed'], + autoAllowIds: ['pubmed'], + blockedToolIds: ['pubmed/search_articles'] + } + }) + + expect(() => broker.preflight(request)).toThrow('tool blocked by policy') + expect(resolve).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + }) + + it('allows policy-approved calls without consulting grants or prompting', async () => { + const { registry, resolve } = createRegistry() + const prompt = vi.fn() + const broker = new ConnectorPermissionBroker(registry, prompt) + const request = createRequest({ + policy: { aliases: ['pubmed'], autoAllowIds: ['pubmed'] } + }) + + await expect(broker.authorize(request)).resolves.toBeUndefined() + expect(resolve).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + }) + + it('uses a remembered grant before opening Require approval', async () => { + const remembered: PermissionGrantRecord = { + id: 'grant-1', + capability, + scope: { kind: 'project', projectId: 'project-1' }, + revision: 1 + } + const { registry, resolve } = createRegistry(remembered) + const prompt = vi.fn() + const broker = new ConnectorPermissionBroker(registry, prompt) + + await expect(broker.authorize(createRequest())).resolves.toBeUndefined() + expect(resolve).toHaveBeenCalledWith(capability, { + projectId: 'project-1', + sessionId: 'session-1' + }) + expect(prompt).not.toHaveBeenCalled() + }) + + it('offers only scopes backed by the current context and durable registry', async () => { + const { registry, remember } = createRegistry() + const prompt = vi.fn().mockResolvedValue('project') + const broker = new ConnectorPermissionBroker(registry, prompt) + + await expect( + broker.authorize(createRequest({ context: { projectId: 'project-1' } })) + ).resolves.toBeUndefined() + + expect(prompt).toHaveBeenCalledWith( + expect.objectContaining({ availableScopes: ['once', 'project', 'global'] }) + ) + expect(remember).toHaveBeenCalledWith({ + capability, + scope: { kind: 'project', projectId: 'project-1' } + }) + }) + + it('returns a confirmed scope without persisting when the caller defers the write', async () => { + const { registry, remember } = createRegistry() + const prompt = vi.fn().mockResolvedValue('session') + const broker = new ConnectorPermissionBroker(registry, prompt) + + await expect( + broker.authorize(createRequest(), 'require_approval', { deferRemember: true }) + ).resolves.toEqual({ + kind: 'session', + projectId: 'project-1', + sessionId: 'session-1' + }) + expect(remember).not.toHaveBeenCalled() + }) + + it('fails closed when approval is denied or durable grant storage fails', async () => { + const denied = new ConnectorPermissionBroker( + createRegistry().registry, + vi.fn().mockResolvedValue('deny') + ) + await expect(denied.authorize(createRequest())).rejects.toThrow('tool call denied by user') + + const { registry, remember } = createRegistry() + remember.mockRejectedValue(new Error('database unavailable')) + const storageFailure = new ConnectorPermissionBroker( + registry, + vi.fn().mockResolvedValue('global') + ) + await expect(storageFailure.authorize(createRequest())).rejects.toThrow('database unavailable') + }) +}) diff --git a/src/main/permission-grants/connector-broker.ts b/src/main/permission-grants/connector-broker.ts new file mode 100644 index 000000000..b2efd6cbe --- /dev/null +++ b/src/main/permission-grants/connector-broker.ts @@ -0,0 +1,120 @@ +import type { ApprovalDecision, ConnectorApprovalScope } from '../../shared/settings' +import type { + PermissionCapability, + PermissionGrantContext, + PermissionGrantScope +} from '../../shared/permission-grants' +import type { PermissionGrantRegistry } from './registry' + +type ConnectorPermissionPrompt = (info: { + connector: string + method: string + args: Record + sessionId?: string + availableScopes: ConnectorApprovalScope[] +}) => Promise + +type ConnectorPolicyInput = { + aliases: readonly string[] + autoAllowIds?: readonly string[] + blockedToolIds?: readonly string[] + askToolIds?: readonly string[] +} + +type ConnectorPermissionRequest = { + capability: PermissionCapability + context: PermissionGrantContext + connector: string + method: string + args: Record + policy: ConnectorPolicyInput +} + +type ConnectorPolicyDecision = 'allow' | 'require_approval' +type ConnectorAuthorizationOptions = { deferRemember?: boolean } + +class ConnectorPermissionBroker { + constructor( + private readonly registry: PermissionGrantRegistry | undefined, + private readonly prompt: ConnectorPermissionPrompt | undefined + ) {} + + preflight(request: ConnectorPermissionRequest): ConnectorPolicyDecision { + const policyId = (alias: string): string => `${alias}/${request.method}` + if ( + request.policy.aliases.some((alias) => + request.policy.blockedToolIds?.includes(policyId(alias)) + ) + ) { + throw new Error(`tool blocked by policy: ${request.connector}/${request.method}`) + } + const skipApprovals = request.policy.aliases.some((alias) => + request.policy.autoAllowIds?.includes(alias) + ) + const requiresApproval = request.policy.aliases.some((alias) => + request.policy.askToolIds?.includes(policyId(alias)) + ) + return skipApprovals || !requiresApproval ? 'allow' : 'require_approval' + } + + async authorize( + request: ConnectorPermissionRequest, + policyDecision: ConnectorPolicyDecision = this.preflight(request), + options: ConnectorAuthorizationOptions = {} + ): Promise { + if (policyDecision === 'allow') return undefined + + if (await this.registry?.resolve(request.capability, request.context)) return undefined + if (!this.prompt) { + throw new Error(`approval unavailable: ${request.connector}/${request.method}`) + } + + const availableScopes: ConnectorApprovalScope[] = ['once'] + if (this.registry) { + if (request.context.projectId && request.context.sessionId) availableScopes.push('session') + if (request.context.projectId) availableScopes.push('project') + availableScopes.push('global') + } + + const decision = await this.prompt({ + connector: request.connector, + method: request.method, + args: request.args, + ...(request.context.sessionId ? { sessionId: request.context.sessionId } : {}), + availableScopes + }) + if (decision === 'deny' || !availableScopes.includes(decision)) { + throw new Error(`tool call denied by user: ${request.connector}/${request.method}`) + } + if (decision === 'once') return undefined + + const scope: PermissionGrantScope = + decision === 'global' + ? { kind: 'global' } + : decision === 'project' + ? { kind: 'project', projectId: request.context.projectId! } + : { + kind: 'session', + projectId: request.context.projectId!, + sessionId: request.context.sessionId! + } + + if (options.deferRemember) return scope + + await this.remember(request, scope) + return undefined + } + + async remember(request: ConnectorPermissionRequest, scope: PermissionGrantScope): Promise { + // Remembered authority must be durable before the current call is released. + await this.registry!.remember({ capability: request.capability, scope }) + } +} + +export { ConnectorPermissionBroker } +export type { + ConnectorPermissionPrompt, + ConnectorPermissionRequest, + ConnectorPolicyDecision, + ConnectorPolicyInput +} diff --git a/src/main/permission-grants/identity-catalog.test.ts b/src/main/permission-grants/identity-catalog.test.ts new file mode 100644 index 000000000..4c554d107 --- /dev/null +++ b/src/main/permission-grants/identity-catalog.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { + PRE_REGISTERED_PERMISSION_IDENTITIES, + PRE_REGISTERED_PERMISSION_IDENTITY_COUNT +} from './identity-catalog' + +describe('permission identity catalog', () => { + it('contains the closed 32-identity v1 bootstrap inventory', () => { + expect(PRE_REGISTERED_PERMISSION_IDENTITY_COUNT).toBe(32) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.builtin_tool).toEqual([]) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.customize_mutation).toHaveLength(8) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.mcp_tool).toHaveLength(15) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.execution).toHaveLength(2) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.file_operation).toHaveLength(6) + expect(PRE_REGISTERED_PERMISSION_IDENTITIES.skill_operation).toHaveLength(1) + }) + + it('does not expose internal Reviewer MCP identities', () => { + expect(JSON.stringify(PRE_REGISTERED_PERMISSION_IDENTITIES)).not.toMatch(/review/i) + }) +}) diff --git a/src/main/permission-grants/identity-catalog.ts b/src/main/permission-grants/identity-catalog.ts new file mode 100644 index 000000000..2cfef16f7 --- /dev/null +++ b/src/main/permission-grants/identity-catalog.ts @@ -0,0 +1,59 @@ +import type { PermissionCapabilityKind } from '../../shared/permission-grants' + +// Closed v1 bootstrap catalog. Dynamic Connector, ComputeHost, and redacted exact-command identities +// are admitted only by their trusted runtime adapters and do not change this fixed inventory. +const PRE_REGISTERED_PERMISSION_IDENTITIES: Readonly< + Record +> = { + customize_mutation: [ + 'customize:agent_create', + 'customize:agent_update', + 'customize:skill_publish', + 'customize:skill_edit', + 'customize:agent_attach_skill', + 'customize:agent_detach_skill', + 'customize:agent_attach_connector', + 'customize:agent_detach_connector' + ], + mcp_tool: [ + 'mcp:open-science-notebook/notebook_execute', + 'mcp:open-science-notebook/repl_execute', + 'mcp:open-science-notebook/bash_execute', + 'mcp:open-science-notebook/notebook_state', + 'mcp:open-science-notebook/list_notebook_runtimes', + 'mcp:open-science-notebook/notebook_bind_runtime', + 'mcp:open-science-notebook/notebook_switch_runtime', + 'mcp:open-science-notebook/notebook_restart', + 'mcp:open-science-notebook/notebook_shutdown', + 'mcp:open-science-notebook/inspect_packages', + 'mcp:open-science-notebook/manage_packages', + 'mcp:open-science-notebook/manage_environments', + 'mcp:open-science-artifacts/write_artifact_file', + 'mcp:open-science-activity/begin_activity_group', + 'mcp:open-science-skills/request_skill_import' + ], + execution: ['exec:local/python', 'exec:local/bash'], + file_operation: [ + 'file:read', + 'file:write', + 'file:edit', + 'file:notebook_edit', + 'file:delete', + 'file:move' + ], + skill_operation: ['skill:invoke'], + builtin_tool: [] +} + +const PRE_REGISTERED_PERMISSION_IDENTITY_COUNT = Object.values( + PRE_REGISTERED_PERMISSION_IDENTITIES +).reduce((count, identities) => count + identities.length, 0) + +const isPreRegisteredPermissionIdentity = (kind: PermissionCapabilityKind, key: string): boolean => + PRE_REGISTERED_PERMISSION_IDENTITIES[kind].includes(key) + +export { + PRE_REGISTERED_PERMISSION_IDENTITIES, + PRE_REGISTERED_PERMISSION_IDENTITY_COUNT, + isPreRegisteredPermissionIdentity +} diff --git a/src/main/permission-grants/ipc.test.ts b/src/main/permission-grants/ipc.test.ts new file mode 100644 index 000000000..56d3a9dd9 --- /dev/null +++ b/src/main/permission-grants/ipc.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => + handlers.set(channel, handler) + } +})) + +import type { PermissionGrantRegistry } from './registry' +import { webRpc } from '../ipc-handler-registry' +import { registerPermissionGrantIpcHandlers } from './ipc' + +beforeEach(() => handlers.clear()) + +describe('permission grant IPC', () => { + it('registers list, revision-aware revoke, restore, and change notification', async () => { + let listener: (() => void) | undefined + const registry = { + list: vi.fn().mockResolvedValue([]), + revoke: vi.fn().mockResolvedValue({ grants: [], conflicts: [] }), + restore: vi.fn().mockResolvedValue({ grants: [], conflicts: [] }), + subscribe: vi.fn((next: () => void) => { + listener = next + return () => undefined + }) + } as unknown as PermissionGrantRegistry + const broadcast = vi.fn() + const controller = registerPermissionGrantIpcHandlers({ + registry, + projects: { list: vi.fn().mockResolvedValue([]) }, + sessions: { loadAll: vi.fn().mockResolvedValue({ sessions: [], manifest: {} }) }, + broadcast + }) + + expect([...handlers.keys()]).toEqual([ + 'permissions:list', + 'permissions:revoke', + 'permissions:restore' + ]) + expect(webRpc.channels()).toEqual( + expect.arrayContaining(['permissions:list', 'permissions:revoke', 'permissions:restore']) + ) + await handlers.get('permissions:revoke')?.(undefined, { + grants: [{ id: 'grant-1', revision: 2 }] + }) + await handlers.get('permissions:restore')?.(undefined, { undoToken: 'undo-1' }) + expect(registry.revoke).toHaveBeenCalledWith({ grants: [{ id: 'grant-1', revision: 2 }] }) + expect(registry.restore).toHaveBeenCalledWith({ undoToken: 'undo-1' }) + + controller.invalidateProjection() + expect(broadcast).toHaveBeenCalledWith('permissions:changed', { revision: 1 }) + + listener?.() + expect(broadcast).toHaveBeenLastCalledWith('permissions:changed', { revision: 2 }) + }) + + it('rejects an empty revoke request at the IPC boundary', async () => { + const registry = { + subscribe: vi.fn(() => () => undefined) + } as unknown as PermissionGrantRegistry + registerPermissionGrantIpcHandlers({ + registry, + projects: { list: vi.fn().mockResolvedValue([]) }, + sessions: { loadAll: vi.fn().mockResolvedValue({ sessions: [], manifest: {} }) } + }) + + await expect(handlers.get('permissions:revoke')?.(undefined, { grants: [] })).rejects.toThrow( + 'Select at least one permission grant' + ) + }) + + it('versions snapshots and reports partial metadata stores without hiding grants', async () => { + let listener: (() => void) | undefined + const registry = { + list: vi.fn().mockResolvedValue([ + { + id: 'grant-1', + revision: 1, + capability: { kind: 'file_operation', key: 'file:read' }, + scope: { kind: 'global' } + } + ]), + subscribe: vi.fn((next: () => void) => { + listener = next + return () => undefined + }) + } as unknown as PermissionGrantRegistry + registerPermissionGrantIpcHandlers({ + registry, + projects: { list: vi.fn().mockRejectedValue(new Error('project store unavailable')) }, + sessions: { + loadAll: vi.fn().mockResolvedValue({ + sessions: [], + manifest: {}, + diagnostics: { isComplete: false, warnings: [] } + }) + }, + connectors: { get: vi.fn().mockRejectedValue(new Error('settings unavailable')) }, + broadcast: vi.fn() + }) + + listener?.() + await expect(handlers.get('permissions:list')?.(undefined)).resolves.toMatchObject({ + version: 1, + incompleteStores: ['projects', 'sessions', 'connector_policy'], + counts: { all: 1 } + }) + }) +}) diff --git a/src/main/permission-grants/ipc.ts b/src/main/permission-grants/ipc.ts new file mode 100644 index 000000000..2a41c899e --- /dev/null +++ b/src/main/permission-grants/ipc.ts @@ -0,0 +1,145 @@ +import type { + PermissionGrantMutationView, + PermissionGrantMutationResult, + PermissionGrantRestoreRequest, + PermissionGrantRevokeRequest, + PermissionGrantSnapshot, + PermissionGrantsChangedEvent +} from '../../shared/permission-grants' +import type { Project } from '../../shared/projects' +import type { LoadAllSessionsResult } from '../../shared/session-persistence' +import { ipcMainHandle } from '../ipc-handler-registry' +import { broadcastToRenderers } from '../renderer-broadcast' +import { + projectPermissionGrantMutation, + projectPermissionGrantSnapshot, + type ConnectorPolicySnapshot +} from './catalog' +import type { PermissionGrantRegistry } from './registry' + +type PermissionGrantIpcOptions = { + registry: PermissionGrantRegistry + projects: { list(): Promise } + sessions: { loadAll(): Promise } + connectors?: { get(): Promise } + broadcast?: (channel: string, payload: PermissionGrantsChangedEvent) => void +} + +type PermissionGrantIpcController = { + dispose(): void + invalidateProjection(): void +} + +const validateRevokeRequest = (request: PermissionGrantRevokeRequest): void => { + if (!request || !Array.isArray(request.grants) || request.grants.length === 0) { + throw new Error('Select at least one permission grant to revoke.') + } + if (request.grants.length > 1_000) throw new Error('Too many permission grants selected.') + for (const grant of request.grants) { + if (!grant.id?.trim() || !Number.isSafeInteger(grant.revision) || grant.revision < 1) { + throw new Error('Invalid permission grant revision.') + } + } +} + +const validateRestoreRequest = (request: PermissionGrantRestoreRequest): void => { + if (!request?.undoToken?.trim()) throw new Error('Permission Undo token is required.') +} + +const registerPermissionGrantIpcHandlers = ( + options: PermissionGrantIpcOptions +): PermissionGrantIpcController => { + const names = async (): Promise<{ + projects: Map + sessions: Map + connectorPolicy?: ConnectorPolicySnapshot + incompleteStores: PermissionGrantSnapshot['incompleteStores'] + }> => { + const [projectsResult, sessionsResult, connectorPolicyResult] = await Promise.allSettled([ + options.projects.list(), + options.sessions.loadAll(), + options.connectors?.get() + ]) + const projects = projectsResult.status === 'fulfilled' ? projectsResult.value : [] + const sessions: LoadAllSessionsResult = + sessionsResult.status === 'fulfilled' + ? sessionsResult.value + : { sessions: [], manifest: { version: 1 } } + const connectorPolicy = + connectorPolicyResult.status === 'fulfilled' ? connectorPolicyResult.value : undefined + const incompleteStores: PermissionGrantSnapshot['incompleteStores'] = [] + if (projectsResult.status === 'rejected') incompleteStores.push('projects') + if (sessionsResult.status === 'rejected' || sessions.diagnostics?.isComplete === false) { + incompleteStores.push('sessions') + } + if (connectorPolicyResult.status === 'rejected') incompleteStores.push('connector_policy') + return { + projects: new Map(projects.map((project): [string, string] => [project.id, project.name])), + sessions: new Map( + sessions.sessions.map((session): [string, string] => [session.id, session.title]) + ), + incompleteStores, + ...(connectorPolicy ? { connectorPolicy } : {}) + } + } + + let version = 0 + const snapshot = async (): Promise => { + for (;;) { + const snapshotVersion = version + const metadata = await names() + const records = await options.registry.list() + if (snapshotVersion !== version) continue + return projectPermissionGrantSnapshot(records, metadata, { + version: snapshotVersion, + incompleteStores: metadata.incompleteStores + }) + } + } + const mutationSnapshot = async ( + result: PermissionGrantMutationResult + ): Promise => { + for (;;) { + const snapshotVersion = version + const metadata = await names() + result.grants = await options.registry.list() + if (snapshotVersion !== version) continue + return projectPermissionGrantMutation(result, metadata, { + version: snapshotVersion, + incompleteStores: metadata.incompleteStores + }) + } + } + + ipcMainHandle('permissions:list', snapshot) + ipcMainHandle( + 'permissions:revoke', + async (_event, request: PermissionGrantRevokeRequest): Promise => { + validateRevokeRequest(request) + const result = await options.registry.revoke(request) + return mutationSnapshot(result) + } + ) + ipcMainHandle( + 'permissions:restore', + async ( + _event, + request: PermissionGrantRestoreRequest + ): Promise => { + validateRestoreRequest(request) + const result = await options.registry.restore(request) + return mutationSnapshot(result) + } + ) + + const broadcast = options.broadcast ?? broadcastToRenderers + const invalidateProjection = (): void => { + version += 1 + broadcast('permissions:changed', { revision: version }) + } + const dispose = options.registry.subscribe(invalidateProjection) + return { dispose, invalidateProjection } +} + +export { registerPermissionGrantIpcHandlers } +export type { PermissionGrantIpcController, PermissionGrantIpcOptions } diff --git a/src/main/permission-grants/reconciliation.test.ts b/src/main/permission-grants/reconciliation.test.ts new file mode 100644 index 000000000..0ad306918 --- /dev/null +++ b/src/main/permission-grants/reconciliation.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PermissionGrantRecord } from '../../shared/permission-grants' +import { reconcilePermissionGrantOwners } from './reconciliation' + +const record = ( + id: string, + capability: PermissionGrantRecord['capability'], + scope: PermissionGrantRecord['scope'] = { kind: 'global' } +): PermissionGrantRecord => ({ id, revision: 1, capability, scope }) + +describe('reconcilePermissionGrantOwners', () => { + it('prunes only orphaned Session and dynamic soft-owner grants', async () => { + const staleServerId = '11111111-1111-4111-8111-111111111111' + const liveServerId = '22222222-2222-4222-8222-222222222222' + const list = vi + .fn() + .mockResolvedValue([ + record( + 'live-session', + { kind: 'execution', key: 'exec:agent/shell' }, + { kind: 'session', projectId: 'project-1', sessionId: 'session-live' } + ), + record( + 'stale-session', + { kind: 'execution', key: 'exec:agent/shell' }, + { kind: 'session', projectId: 'project-1', sessionId: 'session-stale' } + ), + record('live-custom', { kind: 'mcp_tool', key: `mcp:${liveServerId}/search` }), + record('stale-custom', { kind: 'mcp_tool', key: `mcp:${staleServerId}/search` }), + record('app-mcp', { kind: 'mcp_tool', key: 'mcp:open-science-notebook/notebook_execute' }), + record('live-compute', { kind: 'execution', key: 'exec:compute/ssh:live/call_command' }), + record('stale-compute', { kind: 'execution', key: 'exec:compute/ssh:stale/download' }) + ]) + const prune = vi.fn().mockResolvedValue([]) + + await reconcilePermissionGrantOwners( + { list, prune }, + { + sessions: [{ projectId: 'project-1', sessionId: 'session-live' }], + customServerIds: [liveServerId], + computeProviderIds: ['ssh:live'] + } + ) + + expect(prune.mock.calls.map(([owner]) => owner)).toEqual([ + { kind: 'session', projectId: 'project-1', sessionId: 'session-stale' }, + { kind: 'mcp_server', serverId: staleServerId }, + { kind: 'compute_provider', providerId: 'ssh:stale' } + ]) + }) +}) diff --git a/src/main/permission-grants/reconciliation.ts b/src/main/permission-grants/reconciliation.ts new file mode 100644 index 000000000..8e97aba61 --- /dev/null +++ b/src/main/permission-grants/reconciliation.ts @@ -0,0 +1,71 @@ +import type { PermissionGrantOwner, PermissionGrantRecord } from '../../shared/permission-grants' + +type PermissionGrantReconciliationRegistry = { + list(): Promise + prune(owner: PermissionGrantOwner): Promise +} + +type PermissionGrantOwnerSnapshot = { + sessions?: ReadonlyArray<{ projectId: string; sessionId: string }> + customServerIds?: readonly string[] + computeProviderIds?: readonly string[] +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const reconcilePermissionGrantOwners = async ( + registry: PermissionGrantReconciliationRegistry, + snapshot: PermissionGrantOwnerSnapshot +): Promise => { + const records = await registry.list() + const sessions = snapshot.sessions + ? new Set( + snapshot.sessions.map(({ projectId, sessionId }) => JSON.stringify([projectId, sessionId])) + ) + : undefined + const customServerIds = snapshot.customServerIds ? new Set(snapshot.customServerIds) : undefined + const computeProviderIds = snapshot.computeProviderIds + ? new Set(snapshot.computeProviderIds) + : undefined + const owners = new Map() + + for (const record of records) { + if (sessions && record.scope.kind === 'session') { + const key = JSON.stringify([record.scope.projectId, record.scope.sessionId]) + if (!sessions.has(key)) { + owners.set(`session:${key}`, { + kind: 'session', + projectId: record.scope.projectId, + sessionId: record.scope.sessionId + }) + } + } + + if (record.capability.kind === 'mcp_tool') { + const serverId = /^mcp:([^/]+)\//.exec(record.capability.key)?.[1] + // App and bundled server ids are catalog strings. Custom servers are generated UUIDs, which + // lets startup remove a grant left behind by an interrupted settings deletion without guessing + // whether an unknown catalog string is retired or merely from a newer app version. + if ( + customServerIds && + serverId && + UUID_PATTERN.test(serverId) && + !customServerIds.has(serverId) + ) { + owners.set(`mcp:${serverId}`, { kind: 'mcp_server', serverId }) + } + } + + if (record.capability.kind === 'execution') { + const providerId = /^exec:compute\/([^/]+)\//.exec(record.capability.key)?.[1] + if (computeProviderIds && providerId && !computeProviderIds.has(providerId)) { + owners.set(`compute:${providerId}`, { kind: 'compute_provider', providerId }) + } + } + } + + for (const owner of owners.values()) await registry.prune(owner) +} + +export { reconcilePermissionGrantOwners } +export type { PermissionGrantOwnerSnapshot, PermissionGrantReconciliationRegistry } diff --git a/src/main/permission-grants/registry.test.ts b/src/main/permission-grants/registry.test.ts new file mode 100644 index 000000000..172ca28c7 --- /dev/null +++ b/src/main/permission-grants/registry.test.ts @@ -0,0 +1,517 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { Prisma, PrismaClient } from '@prisma/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createProjectDbClient, ensureProjectSchema } from '../projects/prisma-client' +import { createPermissionGrantRegistry, PermissionGrantTargetUnavailableError } from './registry' + +let storageRoot: string | undefined +let clients: PrismaClient[] = [] + +afterEach(async () => { + await Promise.all(clients.map((client) => client.$disconnect())) + clients = [] + + if (storageRoot) { + await rm(storageRoot, { recursive: true, force: true }) + storageRoot = undefined + } +}) + +const openClient = async (): Promise => { + if (!storageRoot) storageRoot = await mkdtemp(join(tmpdir(), 'open-science-permissions-')) + + const client = createProjectDbClient(storageRoot) + clients.push(client) + await ensureProjectSchema(client) + return client +} + +describe('PermissionGrantRegistry', () => { + it('reacquires the shared client after an exclusive database disconnect', async () => { + const firstClient = await openClient() + let retired = false + const guardedFirstClient = new Proxy(firstClient, { + get(target, property, receiver) { + if (retired && (property === '$transaction' || property === '$queryRawUnsafe')) { + return () => Promise.reject(new Error('retired project database client was reused')) + } + return Reflect.get(target, property, receiver) + } + }) as PrismaClient + let currentClient = guardedFirstClient + let id = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => currentClient, + createId: () => `grant-${++id}` + }) + + await registry.remember({ + capability: { kind: 'file_operation', key: 'file:read' }, + scope: { kind: 'global' } + }) + + retired = true + await firstClient.$disconnect() + clients = clients.filter((client) => client !== firstClient) + currentClient = await openClient() + + await registry.remember({ + capability: { kind: 'file_operation', key: 'file:write' }, + scope: { kind: 'global' } + }) + + const persisted = await currentClient.$queryRawUnsafe>( + 'SELECT "capabilityKey" FROM "PermissionGrant" ORDER BY "capabilityKey"' + ) + expect(persisted.map((row) => row.capabilityKey)).toEqual(['file:read', 'file:write']) + await expect(registry.list()).resolves.toHaveLength(2) + }) + + it('persists a Session grant across registry and database reopen', async () => { + const firstClient = await openClient() + await firstClient.project.create({ + data: { id: 'project-1', name: 'Project one' } + }) + const firstRegistry = await createPermissionGrantRegistry({ + getClient: async () => firstClient, + createId: () => 'grant-1', + now: () => new Date('2026-07-30T00:00:00.000Z') + }) + + const capability = { + kind: 'mcp_tool' as const, + key: 'mcp:open-science-notebook/notebook_execute', + qualifier: { mode: 'category' as const, value: 'python' } + } + + await firstRegistry.remember({ + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + await firstClient.$disconnect() + clients = clients.filter((client) => client !== firstClient) + + const reopenedClient = await openClient() + const reopenedRegistry = await createPermissionGrantRegistry({ + getClient: async () => reopenedClient + }) + + await expect( + reopenedRegistry.resolve(capability, { + projectId: 'project-1', + sessionId: 'session-1' + }) + ).resolves.toMatchObject({ + grant: { + id: 'grant-1', + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }, + matchedScope: 'session' + }) + }) + + it('resolves the most specific matching scope without leaking across targets', async () => { + const client = await openClient() + await client.project.createMany({ + data: [ + { id: 'project-1', name: 'Project one' }, + { id: 'project-2', name: 'Project two' } + ] + }) + let id = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createId: () => `grant-${++id}` + }) + const capability = { + kind: 'file_operation' as const, + key: 'file:write' + } + + await registry.remember({ capability, scope: { kind: 'global' } }) + await registry.remember({ + capability, + scope: { kind: 'project', projectId: 'project-1' } + }) + await registry.remember({ + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-1' }) + ).resolves.toMatchObject({ matchedScope: 'session' }) + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-2' }) + ).resolves.toMatchObject({ matchedScope: 'project' }) + await expect( + registry.resolve(capability, { projectId: 'project-2', sessionId: 'session-1' }) + ).resolves.toMatchObject({ matchedScope: 'global' }) + }) + + it('revokes an exact revision and restores it through a one-time Undo receipt', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + let id = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createId: () => `grant-${++id}`, + createUndoToken: () => 'undo-1', + now: () => new Date('2026-07-30T00:00:00.000Z') + }) + const capability = { kind: 'execution' as const, key: 'exec:local/python' } + await registry.remember({ + capability, + scope: { kind: 'project', projectId: 'project-1' } + }) + const sessionGrant = await registry.remember({ + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + + const revoked = await registry.revoke({ + grants: [{ id: sessionGrant.id, revision: sessionGrant.revision }] + }) + + expect(revoked).toMatchObject({ + receipt: { undoToken: 'undo-1', revokedCount: 1 }, + conflicts: [] + }) + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-1' }) + ).resolves.toMatchObject({ matchedScope: 'project' }) + + const restored = await registry.restore({ undoToken: 'undo-1' }) + expect(restored.conflicts).toEqual([]) + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-1' }) + ).resolves.toMatchObject({ + matchedScope: 'session', + grant: { id: sessionGrant.id, revision: 2 } + }) + + await expect(registry.restore({ undoToken: 'undo-1' })).resolves.toMatchObject({ + conflicts: [] + }) + await expect(registry.list()).resolves.toHaveLength(2) + }) + + it('keeps the concurrently re-created row and its actual revision when Undo converges', async () => { + const client = await openClient() + let id = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createId: () => `grant-${++id}`, + createUndoToken: () => 'undo-race' + }) + const capability = { kind: 'file_operation' as const, key: 'file:write' } + const original = await registry.remember({ capability, scope: { kind: 'global' } }) + const revoked = await registry.revoke({ + grants: [{ id: original.id, revision: original.revision }] + }) + const concurrent = await registry.remember({ capability, scope: { kind: 'global' } }) + + const restored = await registry.restore({ undoToken: revoked.receipt!.undoToken }) + + expect(restored.conflicts).toEqual([]) + expect(restored.grants).toEqual([concurrent]) + const revokedAgain = await registry.revoke({ + grants: [{ id: concurrent.id, revision: concurrent.revision }] + }) + expect(revokedAgain.conflicts).toEqual([]) + expect(revokedAgain.grants).toEqual([]) + }) + + it('serializes database commits with cache updates across concurrent mutations', async () => { + const client = await openClient() + let transactionCount = 0 + let releaseRevocation = (): void => undefined + let reportRevocationCommitted = (): void => undefined + const revocationReleased = new Promise((resolve) => { + releaseRevocation = resolve + }) + const revocationCommitted = new Promise((resolve) => { + reportRevocationCommitted = resolve + }) + const delayedClient = new Proxy(client, { + get(target, property, receiver) { + if (property !== '$transaction') return Reflect.get(target, property, receiver) + return async ( + operation: (transaction: Prisma.TransactionClient) => Promise + ): Promise => { + transactionCount += 1 + const currentTransaction = transactionCount + const result = await client.$transaction(operation) + if (currentTransaction === 2) { + reportRevocationCommitted() + await revocationReleased + } + return result + } + } + }) as PrismaClient + let id = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => delayedClient, + createId: () => `grant-${++id}` + }) + const capability = { kind: 'file_operation' as const, key: 'file:write' } + const original = await registry.remember({ capability, scope: { kind: 'global' } }) + + const revoke = registry.revoke({ + grants: [{ id: original.id, revision: original.revision }] + }) + await revocationCommitted + const remember = registry.remember({ capability, scope: { kind: 'global' } }) + await Promise.resolve() + + expect(transactionCount).toBe(2) + releaseRevocation() + await revoke + const remembered = await remember + + expect(registry.listCached()).toEqual([remembered]) + await expect( + client.permissionGrant.findMany({ select: { id: true, revision: true } }) + ).resolves.toEqual([{ id: remembered.id, revision: remembered.revision }]) + }) + + it('discards expired Undo receipts so their row snapshots cannot become live again', async () => { + const client = await openClient() + let now = 0 + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createUndoToken: () => 'expired-undo', + now: () => new Date(now), + receiptTtlMs: 10 + }) + const capability = { kind: 'execution' as const, key: 'exec:local/python' } + const grant = await registry.remember({ capability, scope: { kind: 'global' } }) + await registry.revoke({ grants: [{ id: grant.id, revision: grant.revision }] }) + + now = 20 + await registry.restore({ undoToken: 'expired-undo' }) + now = 0 + await registry.restore({ undoToken: 'expired-undo' }) + + await expect(registry.list()).resolves.toEqual([]) + }) + + it('rejects raw exact qualifiers at the durable Interface', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client }) + + await expect( + registry.remember({ + capability: { + kind: 'execution', + key: 'exec:agent/shell', + qualifier: { mode: 'exact', value: 'curl https://example.test?token=secret' } + }, + scope: { kind: 'project', projectId: 'project-1' } + }) + ).rejects.toThrow('Exact permission qualifiers must be a versioned SHA-256 digest.') + }) + + it('never authorizes or restores a grant after its Session owner disappears', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + let sessionIsLive = true + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createUndoToken: () => 'undo-session', + isScopeLive: async (scope) => scope.kind !== 'session' || sessionIsLive + }) + const capability = { kind: 'file_operation' as const, key: 'file:write' } + const grant = await registry.remember({ + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + sessionIsLive = false + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-1' }) + ).resolves.toBeUndefined() + + sessionIsLive = true + const revoked = await registry.revoke({ + grants: [{ id: grant.id, revision: grant.revision }] + }) + expect(revoked.receipt?.undoToken).toBe('undo-session') + + sessionIsLive = false + await expect(registry.restore({ undoToken: 'undo-session' })).resolves.toMatchObject({ + conflicts: [{ id: grant.id, reason: 'target-unavailable' }] + }) + + await expect( + registry.resolve(capability, { projectId: 'project-1', sessionId: 'session-1' }) + ).resolves.toBeUndefined() + await expect(registry.list()).resolves.toEqual([]) + await expect( + registry.remember({ + capability, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + ).rejects.toBeInstanceOf(PermissionGrantTargetUnavailableError) + }) + + it('prunes Session and soft-owner grants and publishes cache changes', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client }) + const changed = vi.fn() + const unsubscribe = registry.subscribe(changed) + + await registry.remember({ + capability: { kind: 'mcp_tool', key: 'mcp:pubmed/search', qualifier: { mode: 'any' } }, + scope: { kind: 'session', projectId: 'project-1', sessionId: 'session-1' } + }) + await registry.remember({ + capability: { kind: 'mcp_tool', key: 'mcp:other/search', qualifier: { mode: 'any' } }, + scope: { kind: 'project', projectId: 'project-1' } + }) + + await registry.prune({ kind: 'session', projectId: 'project-1', sessionId: 'session-1' }) + await expect(registry.list()).resolves.toHaveLength(1) + await registry.prune({ kind: 'mcp_server', serverId: 'other' }) + expect(registry.listCached()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(4) + + unsubscribe() + }) + + it('treats LIKE metacharacters in soft-owner ids as literal characters', async () => { + const client = await openClient() + const registry = await createPermissionGrantRegistry({ getClient: async () => client }) + await registry.remember({ + capability: { kind: 'mcp_tool', key: 'mcp:server%_\\name/search' }, + scope: { kind: 'global' } + }) + await registry.remember({ + capability: { kind: 'mcp_tool', key: 'mcp:serverXX\\name/search' }, + scope: { kind: 'global' } + }) + await registry.remember({ + capability: { kind: 'execution', key: 'exec:compute/provider%_\\name/submit' }, + scope: { kind: 'global' } + }) + await registry.remember({ + capability: { kind: 'execution', key: 'exec:compute/providerXX\\name/submit' }, + scope: { kind: 'global' } + }) + + await registry.prune({ kind: 'mcp_server', serverId: 'server%_\\name' }) + await registry.prune({ kind: 'compute_provider', providerId: 'provider%_\\name' }) + + await expect(registry.list()).resolves.toEqual([ + expect.objectContaining({ + capability: expect.objectContaining({ key: 'exec:compute/providerXX\\name/submit' }) + }), + expect.objectContaining({ + capability: expect.objectContaining({ key: 'mcp:serverXX\\name/search' }) + }) + ]) + }) + + it('does not restore pruned Connector or Compute owners from a mixed Undo receipt', async () => { + const client = await openClient() + const registry = await createPermissionGrantRegistry({ + getClient: async () => client, + createUndoToken: () => 'undo-pruned-owners' + }) + const connector = await registry.remember({ + capability: { kind: 'mcp_tool', key: 'mcp:retired-connector/search' }, + scope: { kind: 'global' } + }) + const compute = await registry.remember({ + capability: { kind: 'execution', key: 'exec:compute/retired-provider/submit' }, + scope: { kind: 'global' } + }) + const unrelated = await registry.remember({ + capability: { kind: 'file_operation', key: 'file:write' }, + scope: { kind: 'global' } + }) + await registry.revoke({ + grants: [connector, compute, unrelated].map(({ id, revision }) => ({ id, revision })) + }) + + await registry.prune({ kind: 'mcp_server', serverId: 'retired-connector' }) + await registry.prune({ kind: 'compute_provider', providerId: 'retired-provider' }) + const restored = await registry.restore({ undoToken: 'undo-pruned-owners' }) + + expect(restored.conflicts).toEqual([]) + expect(restored.grants).toEqual([ + expect.objectContaining({ + capability: expect.objectContaining({ kind: 'file_operation', key: 'file:write' }) + }) + ]) + }) + + it('invalidates cached Project grants after the database FK already cascaded them', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + const registry = await createPermissionGrantRegistry({ getClient: async () => client }) + await registry.remember({ + capability: { kind: 'file_operation', key: 'file:read' }, + scope: { kind: 'project', projectId: 'project-1' } + }) + + await client.project.delete({ where: { id: 'project-1' } }) + expect(registry.listCached()).toHaveLength(1) + + await registry.prune({ kind: 'project', projectId: 'project-1' }) + expect(registry.listCached()).toEqual([]) + }) + + it('finalizes a deleted owner after an already-committed remember updates the cache', async () => { + const client = await openClient() + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + let releaseRemember = (): void => undefined + let reportRememberCommitted = (): void => undefined + const rememberReleased = new Promise((resolve) => { + releaseRemember = resolve + }) + const rememberCommitted = new Promise((resolve) => { + reportRememberCommitted = resolve + }) + const delayedClient = new Proxy(client, { + get(target, property, receiver) { + if (property !== '$transaction') return Reflect.get(target, property, receiver) + return async ( + operation: (transaction: Prisma.TransactionClient) => Promise + ): Promise => { + const result = await client.$transaction(operation) + reportRememberCommitted() + await rememberReleased + return result + } + } + }) as PrismaClient + const registry = await createPermissionGrantRegistry({ + getClient: async () => delayedClient, + createId: () => 'late-grant' + }) + const owner = { kind: 'project' as const, projectId: 'project-1' } + await registry.prune(owner) + + const remember = registry.remember({ + capability: { kind: 'file_operation', key: 'file:write' }, + scope: owner + }) + await rememberCommitted + await client.project.delete({ where: { id: 'project-1' } }) + const finalize = registry.finalizeOwnerDeletion(owner) + + releaseRemember() + await remember + await finalize + + expect(registry.listCached()).toEqual([]) + await expect(client.permissionGrant.count()).resolves.toBe(0) + }) +}) diff --git a/src/main/permission-grants/registry.ts b/src/main/permission-grants/registry.ts new file mode 100644 index 000000000..29d4366ca --- /dev/null +++ b/src/main/permission-grants/registry.ts @@ -0,0 +1,507 @@ +import { createHash, randomUUID } from 'node:crypto' + +import type { Prisma, PrismaClient } from '@prisma/client' + +import { + EXACT_PERMISSION_QUALIFIER_PATTERN, + PERMISSION_CAPABILITY_KINDS, + type PermissionCapability, + type PermissionGrantContext, + type PermissionGrantMatch, + type PermissionGrantMutationConflict, + type PermissionGrantMutationResult, + type PermissionGrantOwner, + type PermissionGrantRecord, + type PermissionGrantScope, + type RememberPermissionGrant, + type RestorePermissionGrants, + type RevokePermissionGrants +} from '../../shared/permission-grants' + +type PermissionGrantRow = { + id: string + capabilityKind: string + capabilityKey: string + qualifierMode: string + qualifierValue: string | null + scopeKind: string + projectId: string | null + sessionId: string | null + fingerprint: string + revision: number | bigint + createdAt: Date | string | null +} + +type PermissionGrantRegistryOptions = { + getClient: () => Promise + createId?: () => string + createUndoToken?: () => string + now?: () => Date + receiptTtlMs?: number + isScopeLive?: (scope: PermissionGrantScope) => Promise +} + +type PermissionGrantRegistry = { + resolve( + capability: PermissionCapability, + context: PermissionGrantContext + ): Promise + remember(command: RememberPermissionGrant): Promise + list(): Promise + listCached(): PermissionGrantRecord[] + revoke(command: RevokePermissionGrants): Promise + restore(command: RestorePermissionGrants): Promise + prune(owner: PermissionGrantOwner): Promise + finalizeOwnerDeletion(owner: PermissionGrantOwner): Promise + subscribe(listener: () => void): () => void +} + +class PermissionGrantTargetUnavailableError extends Error { + constructor() { + super('Permission grant target no longer exists.') + this.name = 'PermissionGrantTargetUnavailableError' + } +} + +type RevocationReceipt = { + rows: PermissionGrantRow[] + expiresAt: number +} + +const CAPABILITY_KINDS = new Set(PERMISSION_CAPABILITY_KINDS) + +const qualifierColumns = ( + capability: PermissionCapability +): { mode: 'none' | 'any' | 'category' | 'exact'; value: string | null } => { + const qualifier = capability.qualifier + if (!qualifier) return { mode: 'none', value: null } + if (qualifier.mode === 'any') return { mode: 'any', value: null } + + const value = qualifier.value.trim() + if (!value) throw new Error('Permission capability qualifier value is required.') + if (qualifier.mode === 'exact' && !EXACT_PERMISSION_QUALIFIER_PATTERN.test(value)) { + throw new Error('Exact permission qualifiers must be a versioned SHA-256 digest.') + } + return { mode: qualifier.mode, value } +} + +const validateCapability = (capability: PermissionCapability): void => { + if (!CAPABILITY_KINDS.has(capability.kind)) { + throw new Error(`Unsupported permission capability kind: ${String(capability.kind)}`) + } + if (!capability.key.trim()) throw new Error('Permission capability key is required.') + qualifierColumns(capability) +} + +const scopeColumns = ( + scope: PermissionGrantScope +): { projectId: string | null; sessionId: string | null } => { + if (scope.kind === 'global') return { projectId: null, sessionId: null } + if (!scope.projectId.trim()) throw new Error('Permission Project scope requires projectId.') + if (scope.kind === 'project') return { projectId: scope.projectId, sessionId: null } + if (!scope.sessionId.trim()) throw new Error('Permission Session scope requires sessionId.') + return { projectId: scope.projectId, sessionId: scope.sessionId } +} + +const fingerprintFor = (capability: PermissionCapability, scope: PermissionGrantScope): string => { + validateCapability(capability) + const qualifier = qualifierColumns(capability) + const target = scopeColumns(scope) + const tuple = [ + 'permission-grant:v1', + capability.kind, + capability.key, + qualifier.mode, + qualifier.value, + scope.kind, + target.projectId, + target.sessionId + ] + + return createHash('sha256').update(JSON.stringify(tuple)).digest('hex') +} + +const capabilityFromRow = (row: PermissionGrantRow): PermissionCapability => ({ + kind: row.capabilityKind as PermissionCapability['kind'], + key: row.capabilityKey, + ...(row.qualifierMode === 'any' + ? { qualifier: { mode: 'any' as const } } + : row.qualifierMode === 'category' || row.qualifierMode === 'exact' + ? { + qualifier: { + mode: row.qualifierMode, + value: row.qualifierValue ?? '' + } + } + : {}) +}) + +const scopeFromRow = (row: PermissionGrantRow): PermissionGrantScope => { + if (row.scopeKind === 'global') return { kind: 'global' } + if (row.scopeKind === 'project' && row.projectId) { + return { kind: 'project', projectId: row.projectId } + } + if (row.scopeKind === 'session' && row.projectId && row.sessionId) { + return { kind: 'session', projectId: row.projectId, sessionId: row.sessionId } + } + throw new Error(`Invalid persisted permission scope: ${row.scopeKind}`) +} + +const recordFromRow = (row: PermissionGrantRow): PermissionGrantRecord => ({ + id: row.id, + capability: capabilityFromRow(row), + scope: scopeFromRow(row), + ...(row.createdAt ? { createdAt: new Date(row.createdAt).getTime() } : {}), + revision: Number(row.revision) +}) + +const sameCapability = (left: PermissionCapability, right: PermissionCapability): boolean => { + const leftQualifier = qualifierColumns(left) + const rightQualifier = qualifierColumns(right) + return ( + left.kind === right.kind && + left.key === right.key && + leftQualifier.mode === rightQualifier.mode && + leftQualifier.value === rightQualifier.value + ) +} + +const scopeRank = (scope: PermissionGrantScope, context: PermissionGrantContext): number => { + if ( + scope.kind === 'session' && + context.projectId === scope.projectId && + context.sessionId === scope.sessionId + ) { + return 3 + } + if (scope.kind === 'project' && context.projectId === scope.projectId) return 2 + if (scope.kind === 'global') return 1 + return 0 +} + +const sortedRecords = (records: Iterable): PermissionGrantRecord[] => + Array.from(records).sort((left, right) => { + const scopeOrder = { global: 0, project: 1, session: 2 } + return ( + scopeOrder[left.scope.kind] - scopeOrder[right.scope.kind] || + left.capability.kind.localeCompare(right.capability.kind) || + left.capability.key.localeCompare(right.capability.key) || + left.id.localeCompare(right.id) + ) + }) + +const findConflict = async ( + transaction: Prisma.TransactionClient, + id: string +): Promise => { + const rows = await transaction.$queryRawUnsafe>( + 'SELECT "revision" FROM "PermissionGrant" WHERE "id" = ? LIMIT 1', + id + ) + return { id, reason: rows.length > 0 ? 'stale' : 'missing' } +} + +const recordBelongsToOwner = ( + record: PermissionGrantRecord, + owner: PermissionGrantOwner +): boolean => { + if (owner.kind === 'project') { + return record.scope.kind !== 'global' && record.scope.projectId === owner.projectId + } + if (owner.kind === 'session') { + return ( + record.scope.kind === 'session' && + record.scope.projectId === owner.projectId && + record.scope.sessionId === owner.sessionId + ) + } + if (owner.kind === 'mcp_server') { + return ( + record.capability.kind === 'mcp_tool' && + record.capability.key.startsWith(`mcp:${owner.serverId}/`) + ) + } + return ( + record.capability.kind === 'execution' && + record.capability.key.startsWith(`exec:compute/${owner.providerId}/`) + ) +} + +const escapeLikePattern = (value: string): string => + value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') + +const createPermissionGrantRegistry = async ( + options: PermissionGrantRegistryOptions +): Promise => { + const initialClient = await options.getClient() + const createId = options.createId ?? randomUUID + const createUndoToken = options.createUndoToken ?? randomUUID + const now = options.now ?? (() => new Date()) + const receiptTtlMs = options.receiptTtlMs ?? 8_000 + const rows = await initialClient.$queryRawUnsafe( + 'SELECT * FROM "PermissionGrant"' + ) + const records = new Map(rows.map((row) => [row.fingerprint, recordFromRow(row)])) + const receipts = new Map() + const listeners = new Set<() => void>() + let mutationTail: Promise = Promise.resolve() + const runMutation = (operation: () => Promise): Promise => { + const result = mutationTail.then(operation) + mutationTail = result.then( + () => undefined, + () => undefined + ) + return result + } + const publish = (): void => listeners.forEach((listener) => listener()) + const purgeExpiredReceipts = (at: number): void => { + for (const [token, receipt] of receipts) { + if (receipt.expiresAt <= at) receipts.delete(token) + } + } + const invalidateReceiptsForOwner = (owner: PermissionGrantOwner): void => { + for (const [token, receipt] of receipts) { + const liveRows = receipt.rows.filter( + (row) => !recordBelongsToOwner(recordFromRow(row), owner) + ) + if (liveRows.length === receipt.rows.length) continue + if (liveRows.length === 0) receipts.delete(token) + else receipts.set(token, { ...receipt, rows: liveRows }) + } + } + const invalidateCachedOwner = (owner: PermissionGrantOwner): boolean => { + let changed = false + for (const [fingerprint, record] of records) { + if (!recordBelongsToOwner(record, owner)) continue + records.delete(fingerprint) + changed = true + } + return changed + } + + return { + async resolve(capability, context) { + validateCapability(capability) + const matches = Array.from(records.values()) + .filter((record) => sameCapability(record.capability, capability)) + .map((grant) => ({ grant, rank: scopeRank(grant.scope, context) })) + .filter(({ rank }) => rank > 0) + .sort((left, right) => right.rank - left.rank) + + for (const match of matches) { + if (!options.isScopeLive || (await options.isScopeLive(match.grant.scope))) { + return { grant: match.grant, matchedScope: match.grant.scope.kind } + } + } + + return undefined + }, + + remember(command) { + return runMutation(async () => { + validateCapability(command.capability) + if (options.isScopeLive && !(await options.isScopeLive(command.scope))) { + throw new PermissionGrantTargetUnavailableError() + } + const qualifier = qualifierColumns(command.capability) + const target = scopeColumns(command.scope) + const fingerprint = fingerprintFor(command.capability, command.scope) + const createdAt = now() + const client = await options.getClient() + + const row = await client.$transaction(async (transaction) => { + await transaction.$executeRawUnsafe( + `INSERT OR IGNORE INTO "PermissionGrant" ( + "id", "capabilityKind", "capabilityKey", "qualifierMode", "qualifierValue", + "scopeKind", "projectId", "sessionId", "fingerprint", "revision", "createdAt" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`, + createId(), + command.capability.kind, + command.capability.key, + qualifier.mode, + qualifier.value, + command.scope.kind, + target.projectId, + target.sessionId, + fingerprint, + createdAt + ) + const [persisted] = await transaction.$queryRawUnsafe( + 'SELECT * FROM "PermissionGrant" WHERE "fingerprint" = ? LIMIT 1', + fingerprint + ) + return persisted + }) + if (!row) throw new Error('Permission grant write did not produce a durable record.') + + const record = recordFromRow(row) + records.set(fingerprint, record) + publish() + return record + }) + }, + + async list() { + return sortedRecords(records.values()) + }, + + listCached() { + return sortedRecords(records.values()) + }, + + revoke(command) { + return runMutation(async () => { + const client = await options.getClient() + const requested = Array.from( + new Map(command.grants.map((grant) => [grant.id, grant])).values() + ) + const result = await client.$transaction(async (transaction) => { + const removed: PermissionGrantRow[] = [] + const conflicts: PermissionGrantMutationConflict[] = [] + + for (const grant of requested) { + const rows = await transaction.$queryRawUnsafe( + 'DELETE FROM "PermissionGrant" WHERE "id" = ? AND "revision" = ? RETURNING *', + grant.id, + grant.revision + ) + const row = rows[0] + if (row) removed.push(row) + else conflicts.push(await findConflict(transaction, grant.id)) + } + + return { removed, conflicts } + }) + + for (const row of result.removed) records.delete(row.fingerprint) + + const mutation: PermissionGrantMutationResult = { + grants: sortedRecords(records.values()), + conflicts: result.conflicts + } + if (result.removed.length > 0) { + const revokedAt = now().getTime() + purgeExpiredReceipts(revokedAt) + const undoToken = createUndoToken() + const expiresAt = revokedAt + receiptTtlMs + receipts.set(undoToken, { rows: result.removed, expiresAt }) + mutation.receipt = { undoToken, expiresAt, revokedCount: result.removed.length } + publish() + } + return mutation + }) + }, + + restore(command) { + return runMutation(async () => { + const receipt = receipts.get(command.undoToken) + if (!receipt) { + return { grants: sortedRecords(records.values()), conflicts: [] } + } + if (receipt.expiresAt <= now().getTime()) { + receipts.delete(command.undoToken) + return { grants: sortedRecords(records.values()), conflicts: [] } + } + + const liveRows: PermissionGrantRow[] = [] + const conflicts: PermissionGrantMutationConflict[] = [] + for (const row of receipt.rows) { + if (!options.isScopeLive || (await options.isScopeLive(scopeFromRow(row)))) { + liveRows.push(row) + } else { + conflicts.push({ id: row.id, reason: 'target-unavailable' }) + } + } + + const client = await options.getClient() + const restoredRows = await client.$transaction(async (transaction) => { + const restored: PermissionGrantRow[] = [] + for (const row of liveRows) { + const revision = Number(row.revision) + 1 + await transaction.$executeRawUnsafe( + `INSERT OR IGNORE INTO "PermissionGrant" ( + "id", "capabilityKind", "capabilityKey", "qualifierMode", "qualifierValue", + "scopeKind", "projectId", "sessionId", "fingerprint", "revision", "createdAt" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row.id, + row.capabilityKind, + row.capabilityKey, + row.qualifierMode, + row.qualifierValue, + row.scopeKind, + row.projectId, + row.sessionId, + row.fingerprint, + revision, + row.createdAt + ) + const [persisted] = await transaction.$queryRawUnsafe( + 'SELECT * FROM "PermissionGrant" WHERE "fingerprint" = ? LIMIT 1', + row.fingerprint + ) + if (persisted) restored.push(persisted) + } + return restored + }) + + receipts.delete(command.undoToken) + for (const row of restoredRows) records.set(row.fingerprint, recordFromRow(row)) + if (restoredRows.length > 0) publish() + return { grants: sortedRecords(records.values()), conflicts } + }) + }, + + prune(owner) { + return runMutation(async () => { + const client = await options.getClient() + const clauses: string[] = [] + const values: string[] = [] + if (owner.kind === 'project') { + clauses.push('"projectId" = ?') + values.push(owner.projectId) + } else if (owner.kind === 'session') { + clauses.push('"projectId" = ?', '"sessionId" = ?') + values.push(owner.projectId, owner.sessionId) + } else if (owner.kind === 'mcp_server') { + clauses.push('"capabilityKind" = ?', '"capabilityKey" LIKE ? ESCAPE \'\\\'') + values.push('mcp_tool', `mcp:${escapeLikePattern(owner.serverId)}/%`) + } else { + clauses.push('"capabilityKind" = ?', '"capabilityKey" LIKE ? ESCAPE \'\\\'') + values.push('execution', `exec:compute/${escapeLikePattern(owner.providerId)}/%`) + } + + const removed = await client.$queryRawUnsafe( + `DELETE FROM "PermissionGrant" WHERE ${clauses.join(' AND ')} RETURNING *`, + ...values + ) + const cacheChanged = invalidateCachedOwner(owner) + // A grant can already be absent from the table because it is waiting in an Undo receipt. Remove + // only rows owned by the deleted Connector/Compute/Project/Session so the receipt cannot recreate + // stale authority while unrelated rows in the same batch remain independently restorable. + invalidateReceiptsForOwner(owner) + if (removed.length > 0 || cacheChanged) publish() + return removed.map(recordFromRow) + }) + }, + + finalizeOwnerDeletion(owner) { + // External owner deletion (notably the Project FK cascade) commits outside this Registry. Queue + // the final cache barrier after every mutation that was already in flight, then make the barrier + // best-effort: the owner is already gone, so surfacing a listener failure as a deletion failure + // would violate the durable deletion-intent contract while the authority is already invalidated. + return runMutation(async () => { + const cacheChanged = invalidateCachedOwner(owner) + invalidateReceiptsForOwner(owner) + if (cacheChanged) publish() + }).catch(() => undefined) + }, + + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + } + } +} + +export { createPermissionGrantRegistry, fingerprintFor, PermissionGrantTargetUnavailableError } +export type { PermissionGrantRegistry, PermissionGrantRegistryOptions } diff --git a/src/main/permission-grants/scope-liveness.test.ts b/src/main/permission-grants/scope-liveness.test.ts new file mode 100644 index 000000000..3582afbe1 --- /dev/null +++ b/src/main/permission-grants/scope-liveness.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' + +import { isPermissionGrantScopeLive } from './scope-liveness' + +const createDependencies = (overrides?: { + projectExists?: boolean + persistedSessionExists?: boolean + liveSessionExists?: boolean +}): { + projectExists: ReturnType Promise>> + persistedSessionExists: ReturnType< + typeof vi.fn<(projectId: string, sessionId: string) => Promise> + > + liveSessionExists: ReturnType boolean>> +} => ({ + projectExists: vi + .fn<(projectId: string) => Promise>() + .mockResolvedValue(overrides?.projectExists ?? true), + persistedSessionExists: vi + .fn<(projectId: string, sessionId: string) => Promise>() + .mockResolvedValue(overrides?.persistedSessionExists ?? false), + liveSessionExists: vi + .fn<(projectId: string, sessionId: string) => boolean>() + .mockReturnValue(overrides?.liveSessionExists ?? false) +}) + +describe('isPermissionGrantScopeLive', () => { + it('accepts global and existing project scopes without a session lookup', async () => { + const dependencies = createDependencies() + + await expect(isPermissionGrantScopeLive({ kind: 'global' }, dependencies)).resolves.toBe(true) + await expect( + isPermissionGrantScopeLive({ kind: 'project', projectId: 'project-1' }, dependencies) + ).resolves.toBe(true) + + expect(dependencies.persistedSessionExists).not.toHaveBeenCalled() + expect(dependencies.liveSessionExists).not.toHaveBeenCalled() + }) + + it('rejects every non-global scope whose project no longer exists', async () => { + const dependencies = createDependencies({ projectExists: false, liveSessionExists: true }) + + await expect( + isPermissionGrantScopeLive( + { kind: 'session', projectId: 'missing-project', sessionId: 'session-1' }, + dependencies + ) + ).resolves.toBe(false) + + expect(dependencies.persistedSessionExists).not.toHaveBeenCalled() + expect(dependencies.liveSessionExists).not.toHaveBeenCalled() + }) + + it('accepts a session that is already durable', async () => { + const dependencies = createDependencies({ persistedSessionExists: true }) + + await expect( + isPermissionGrantScopeLive( + { kind: 'session', projectId: 'project-1', sessionId: 'session-1' }, + dependencies + ) + ).resolves.toBe(true) + + expect(dependencies.liveSessionExists).not.toHaveBeenCalled() + }) + + it('accepts an active ACP session while its first durable save is pending', async () => { + const dependencies = createDependencies({ liveSessionExists: true }) + + await expect( + isPermissionGrantScopeLive( + { kind: 'session', projectId: 'project-1', sessionId: 'session-1' }, + dependencies + ) + ).resolves.toBe(true) + + expect(dependencies.liveSessionExists).toHaveBeenCalledWith('project-1', 'session-1') + }) +}) diff --git a/src/main/permission-grants/scope-liveness.ts b/src/main/permission-grants/scope-liveness.ts new file mode 100644 index 000000000..483f72628 --- /dev/null +++ b/src/main/permission-grants/scope-liveness.ts @@ -0,0 +1,20 @@ +import type { PermissionGrantScope } from '../../shared/permission-grants' + +type PermissionScopeLivenessDependencies = { + projectExists: (projectId: string) => Promise + persistedSessionExists: (projectId: string, sessionId: string) => Promise + liveSessionExists: (projectId: string, sessionId: string) => boolean +} + +const isPermissionGrantScopeLive = async ( + scope: PermissionGrantScope, + dependencies: PermissionScopeLivenessDependencies +): Promise => { + if (scope.kind === 'global') return true + if (!(await dependencies.projectExists(scope.projectId))) return false + if (scope.kind === 'project') return true + if (await dependencies.persistedSessionExists(scope.projectId, scope.sessionId)) return true + return dependencies.liveSessionExists(scope.projectId, scope.sessionId) +} + +export { isPermissionGrantScopeLive } diff --git a/src/main/projects/deletion-coordinator.test.ts b/src/main/projects/deletion-coordinator.test.ts index 709a608bb..83273851f 100644 --- a/src/main/projects/deletion-coordinator.test.ts +++ b/src/main/projects/deletion-coordinator.test.ts @@ -36,12 +36,17 @@ describe('ProjectDeletionCoordinator', () => { const preview = { delete: vi.fn().mockResolvedValue(undefined) } const reviews = { deleteReviewsForProject: vi.fn().mockResolvedValue(undefined) } const provenance = { deleteProjectProvenance: vi.fn().mockResolvedValue(undefined) } + const permissionGrants = { + prune: vi.fn().mockResolvedValue([]), + finalizeOwnerDeletion: vi.fn().mockResolvedValue(undefined) + } const coordinator = new ProjectDeletionCoordinator( projects, sessions, preview, reviews, - provenance + provenance, + permissionGrants ) await coordinator.deleteProject('project-1') @@ -53,6 +58,93 @@ describe('ProjectDeletionCoordinator', () => { expect(preview.delete).toHaveBeenCalledWith('project-1') expect(reviews.deleteReviewsForProject).toHaveBeenCalledWith('project-1') expect(provenance.deleteProjectProvenance).toHaveBeenCalledWith('project-1') + expect(permissionGrants.prune).toHaveBeenCalledWith({ + kind: 'project', + projectId: 'project-1' + }) + expect(permissionGrants.finalizeOwnerDeletion).toHaveBeenCalledWith({ + kind: 'project', + projectId: 'project-1' + }) + expect(vi.mocked(permissionGrants.prune).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(projects.delete).mock.invocationCallOrder[0] + ) + expect(vi.mocked(projects.delete).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(permissionGrants.finalizeOwnerDeletion).mock.invocationCallOrder[0] + ) + }) + + it('retains the Project and deletion intent when grant pruning fails, then resumes idempotently', async () => { + let projectExists = true + let intentExists = false + const projects = createProjects() + projects.get = vi.fn(async () => (projectExists ? project : null)) + projects.delete = vi.fn(async () => { + projectExists = false + }) + projects.createDeletionIntent = vi.fn(async () => { + intentExists = true + }) + projects.deleteDeletionIntent = vi.fn(async () => { + intentExists = false + }) + projects.listDeletionIntents = vi.fn(async () => (intentExists ? ['project-1'] : [])) + const sessions = createSessions() + const permissionGrants = { + prune: vi + .fn() + .mockRejectedValueOnce(new Error('permission registry unavailable')) + .mockResolvedValueOnce([]) + } + const coordinator = new ProjectDeletionCoordinator( + projects, + sessions, + { delete: vi.fn().mockResolvedValue(undefined) }, + undefined, + undefined, + permissionGrants + ) + + await expect(coordinator.deleteProject('project-1')).rejects.toThrow( + 'permission registry unavailable' + ) + + expect(projectExists).toBe(true) + expect(intentExists).toBe(true) + expect(projects.delete).not.toHaveBeenCalled() + expect(sessions.completeProjectSessionDeletion).not.toHaveBeenCalled() + + await expect(coordinator.deleteProject('project-1')).resolves.toBeUndefined() + + expect(permissionGrants.prune).toHaveBeenCalledTimes(2) + expect(sessions.deleteProjectSessions).toHaveBeenCalledTimes(2) + expect(projects.delete).toHaveBeenCalledOnce() + expect(sessions.completeProjectSessionDeletion).toHaveBeenCalledOnce() + expect(projectExists).toBe(false) + expect(intentExists).toBe(false) + }) + + it('does not report a false deletion failure after the Project hard delete commits', async () => { + const projects = createProjects() + const sessions = createSessions() + const permissionGrants = { + prune: vi.fn().mockResolvedValue([]), + finalizeOwnerDeletion: vi.fn().mockRejectedValue(new Error('listener unavailable')) + } + const coordinator = new ProjectDeletionCoordinator( + projects, + sessions, + { delete: vi.fn().mockResolvedValue(undefined) }, + undefined, + undefined, + permissionGrants + ) + + await expect(coordinator.deleteProject('project-1')).resolves.toBeUndefined() + + expect(projects.delete).toHaveBeenCalledWith('project-1') + expect(sessions.completeProjectSessionDeletion).toHaveBeenCalledWith('project-1') + expect(projects.deleteDeletionIntent).toHaveBeenCalledWith('project-1') }) it('keeps the project row and clears intent when session and index cleanup fails', async () => { diff --git a/src/main/projects/deletion-coordinator.ts b/src/main/projects/deletion-coordinator.ts index 6ad58d23d..2824071ee 100644 --- a/src/main/projects/deletion-coordinator.ts +++ b/src/main/projects/deletion-coordinator.ts @@ -35,6 +35,11 @@ type ProjectProvenanceDeletion = { deleteProjectProvenance(projectId: string): Promise } +type ProjectPermissionGrantDeletion = { + prune(owner: { kind: 'project'; projectId: string }): Promise + finalizeOwnerDeletion?(owner: { kind: 'project'; projectId: string }): Promise +} + // Persists deletion intent so a crash cannot strand an absent project with active session data. The // same sticky recovery gate is shared by project CRUD, session persistence, and Files queries. class ProjectDeletionCoordinator { @@ -47,7 +52,8 @@ class ProjectDeletionCoordinator { private readonly sessions: ProjectSessionDeletion, private readonly preview: PreviewDeletion, private readonly reviews?: ProjectReviewDeletion, - private readonly provenance?: ProjectProvenanceDeletion + private readonly provenance?: ProjectProvenanceDeletion, + private readonly permissionGrants?: ProjectPermissionGrantDeletion ) {} // Enqueues before yielding so two callers in the same event-loop turn cannot publish competing @@ -190,10 +196,20 @@ class ProjectDeletionCoordinator { } } - // The project row is removed only after session/index deletion succeeds; deleting the intent last - // makes this tail idempotent if the app crashes between either statement. + // The Project row is removed only after every fallible authority cleanup succeeds. Keeping both + // the row and deletion intent through Permission Grant pruning lets the renderer contract report + // the failure without publishing a false success; replaying this tail is idempotent. private async finishDeletion(projectId: string): Promise { + // Prune is transactional and idempotent. Run it before the hard delete so a Registry/database + // failure retains the visible Project plus its durable intent for an explicit or startup retry. + await this.permissionGrants?.prune({ kind: 'project', projectId }) if (await this.projects.get(projectId)) await this.projects.delete(projectId) + // The Project FK cascade commits outside the Registry mutation queue. A remember/restore that + // was already in flight may have updated its cache around that commit, so enqueue one non-failing + // cache barrier after the hard delete. Later mutations fail owner-liveness validation. + await this.permissionGrants + ?.finalizeOwnerDeletion?.({ kind: 'project', projectId }) + .catch(() => undefined) // Preview state is derived UI state; a cleanup failure must not resurrect deleted chat data. await this.preview.delete(projectId).catch(() => undefined) @@ -222,5 +238,6 @@ export type { ProjectDeletionRepository, ProjectReviewDeletion, ProjectProvenanceDeletion, + ProjectPermissionGrantDeletion, ProjectSessionDeletion } diff --git a/src/main/projects/prisma-client.test.ts b/src/main/projects/prisma-client.test.ts index 8c1ea12e9..5f0a9e09b 100644 --- a/src/main/projects/prisma-client.test.ts +++ b/src/main/projects/prisma-client.test.ts @@ -53,6 +53,45 @@ describe('project prisma client (integration)', () => { } }) + it('creates the Permission Grant authority table with constrained scopes and owner cascade', async () => { + storageRoot = await mkdtemp(join(tmpdir(), 'open-science-permission-grant-schema-')) + const client = createProjectDbClient(storageRoot) + disconnect = () => client.$disconnect() + await ensureProjectSchema(client) + + const columns = await client.$queryRawUnsafe>( + 'PRAGMA table_info("PermissionGrant")' + ) + expect(columns.map((column) => column.name)).toEqual([ + 'id', + 'capabilityKind', + 'capabilityKey', + 'qualifierMode', + 'qualifierValue', + 'scopeKind', + 'projectId', + 'sessionId', + 'fingerprint', + 'revision', + 'createdAt' + ]) + + await client.project.create({ data: { id: 'project-1', name: 'Project one' } }) + await client.$executeRawUnsafe( + `INSERT INTO "PermissionGrant" ("id", "capabilityKind", "capabilityKey", "scopeKind", "projectId", "fingerprint") VALUES ('grant-1', 'execution', 'exec:agent/shell', 'project', 'project-1', 'fingerprint-1')` + ) + await expect( + client.$executeRawUnsafe( + `INSERT INTO "PermissionGrant" ("id", "capabilityKind", "capabilityKey", "scopeKind", "projectId", "sessionId", "fingerprint") VALUES ('grant-invalid', 'execution', 'exec:agent/shell', 'global', 'project-1', 'session-1', 'fingerprint-invalid')` + ) + ).rejects.toThrow() + + await client.project.delete({ where: { id: 'project-1' } }) + await expect( + client.$queryRawUnsafe>('SELECT "id" FROM "PermissionGrant"') + ).resolves.toEqual([]) + }) + it('creates an unread-task table that rejects duplicate session IDs', async () => { storageRoot = await mkdtemp(join(tmpdir(), 'open-science-unread-task-schema-')) diff --git a/src/main/projects/prisma-client.ts b/src/main/projects/prisma-client.ts index 4ed395b61..f24444355 100644 --- a/src/main/projects/prisma-client.ts +++ b/src/main/projects/prisma-client.ts @@ -26,6 +26,39 @@ const PROJECT_TABLE_DDL = `CREATE TABLE IF NOT EXISTS "Project" ( "updatedAt" DATETIME NOT NULL );` +const PERMISSION_GRANT_TABLE_DDL = `CREATE TABLE IF NOT EXISTS "PermissionGrant" ( + "id" TEXT NOT NULL PRIMARY KEY, + "capabilityKind" TEXT NOT NULL, + "capabilityKey" TEXT NOT NULL, + "qualifierMode" TEXT NOT NULL DEFAULT 'none', + "qualifierValue" TEXT, + "scopeKind" TEXT NOT NULL, + "projectId" TEXT, + "sessionId" TEXT, + "fingerprint" TEXT NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "createdAt" DATETIME, + CONSTRAINT "PermissionGrant_capabilityKind_check" CHECK ("capabilityKind" IN ('customize_mutation', 'mcp_tool', 'execution', 'file_operation', 'skill_operation', 'builtin_tool')), + CONSTRAINT "PermissionGrant_capabilityKey_check" CHECK (length(trim("capabilityKey")) > 0), + CONSTRAINT "PermissionGrant_qualifier_check" CHECK ( + ("qualifierMode" IN ('none', 'any') AND "qualifierValue" IS NULL) OR + ("qualifierMode" IN ('category', 'exact') AND "qualifierValue" IS NOT NULL AND length(trim("qualifierValue")) > 0) + ), + CONSTRAINT "PermissionGrant_scope_check" CHECK ( + ("scopeKind" = 'global' AND "projectId" IS NULL AND "sessionId" IS NULL) OR + ("scopeKind" = 'project' AND "projectId" IS NOT NULL AND "sessionId" IS NULL) OR + ("scopeKind" = 'session' AND "projectId" IS NOT NULL AND "sessionId" IS NOT NULL) + ), + CONSTRAINT "PermissionGrant_revision_check" CHECK ("revision" >= 1), + CONSTRAINT "PermissionGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE +);` + +const PERMISSION_GRANT_INDEX_DDLS = [ + `CREATE UNIQUE INDEX IF NOT EXISTS "PermissionGrant_fingerprint_key" ON "PermissionGrant"("fingerprint")`, + `CREATE INDEX IF NOT EXISTS "PermissionGrant_capabilityKind_capabilityKey_qualifierMode_qualifierValue_scopeKind_projectId_sessionId_idx" ON "PermissionGrant"("capabilityKind", "capabilityKey", "qualifierMode", "qualifierValue", "scopeKind", "projectId", "sessionId")`, + `CREATE INDEX IF NOT EXISTS "PermissionGrant_projectId_sessionId_idx" ON "PermissionGrant"("projectId", "sessionId")` +] + // Same runtime-DDL approach for the per-project preview panel state table. const PREVIEW_STATE_TABLE_DDL = `CREATE TABLE IF NOT EXISTS "ProjectPreviewState" ( "projectId" TEXT NOT NULL PRIMARY KEY, @@ -547,6 +580,10 @@ const addColumnIfMissing = async ( // Creates the schema if missing. Idempotent; no projects are seeded, so a fresh install starts empty. const ensureProjectSchema = async (client: PrismaClient): Promise => { await client.$executeRawUnsafe(PROJECT_TABLE_DDL) + await client.$executeRawUnsafe(PERMISSION_GRANT_TABLE_DDL) + for (const ddl of PERMISSION_GRANT_INDEX_DDLS) { + await client.$executeRawUnsafe(ddl) + } await client.$executeRawUnsafe(PREVIEW_STATE_TABLE_DDL) await client.$executeRawUnsafe(UNREAD_TASK_SESSION_TABLE_DDL) await client.$executeRawUnsafe(UNREAD_TASK_SESSION_SESSION_ID_INDEX_DDL) diff --git a/src/main/session-persistence/artifact-alias-repair.test.ts b/src/main/session-persistence/artifact-alias-repair.test.ts new file mode 100644 index 000000000..688cf09d7 --- /dev/null +++ b/src/main/session-persistence/artifact-alias-repair.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import type { PersistedArtifact, PersistedChatSession } from '../../shared/session-persistence' +import { repairHistoricalArtifactAliases } from './artifact-alias-repair' + +const nativeVersion: PersistedArtifact = { + id: 'version-1', + artifactId: 'artifact-1', + versionId: 'version-1', + versionNumber: 1, + kind: 'managed-file', + path: '/managed/result.csv', + name: 'result.csv', + mimeType: 'text/csv', + size: 12, + mtimeMs: 2, + sha256: 'a'.repeat(64) +} + +const legacyAlias: PersistedArtifact = { + ...nativeVersion, + id: 'session-1:message-1:result.csv' +} + +const createSession = (): PersistedChatSession => ({ + id: 'session-1', + projectId: 'project-1', + title: 'Session', + cwd: '/workspace', + status: 'idle', + messages: [ + { + id: 'message-1', + role: 'agent', + content: 'Historical content must remain byte-for-byte stable.', + status: 'complete', + eventIds: ['event-1'], + artifactIds: [legacyAlias.id, nativeVersion.id], + createdAt: 10, + updatedAt: 20 + } + ], + artifacts: [legacyAlias, nativeVersion], + filesRevision: 4, + createdAt: 1, + updatedAt: 30 +}) + +describe('historical Artifact alias repair', () => { + it('canonicalizes only the duplicate descriptor and reference mapping', () => { + const session = createSession() + + const repaired = repairHistoricalArtifactAliases(session) + + expect(repaired.messages[0]).toEqual({ + ...session.messages[0], + artifactIds: ['version-1'] + }) + expect(repaired.messages[0].content).toBe(session.messages[0].content) + expect(repaired.messages[0].createdAt).toBe(10) + expect(repaired.messages[0].updatedAt).toBe(20) + expect(repaired.artifacts).toEqual([nativeVersion]) + expect(repaired.filesRevision).toBe(5) + expect(repaired.updatedAt).toBe(30) + }) + + it('is a no-op by identity after the repair has been applied', () => { + const repaired = repairHistoricalArtifactAliases(createSession()) + + expect(repairHistoricalArtifactAliases(repaired)).toBe(repaired) + }) +}) diff --git a/src/main/session-persistence/artifact-alias-repair.ts b/src/main/session-persistence/artifact-alias-repair.ts new file mode 100644 index 000000000..cedc09d42 --- /dev/null +++ b/src/main/session-persistence/artifact-alias-repair.ts @@ -0,0 +1,77 @@ +import type { PersistedArtifact, PersistedChatSession } from '../../shared/session-persistence' + +const canonicalArtifactAliases = (artifacts: readonly PersistedArtifact[]): Map => { + const canonicalIdsByVersion = new Map() + for (const artifact of artifacts) { + if (artifact.versionId && artifact.id === artifact.versionId) { + canonicalIdsByVersion.set(artifact.versionId, artifact.id) + } + } + + const aliases = new Map() + for (const artifact of artifacts) { + if (!artifact.versionId) continue + const canonicalId = canonicalIdsByVersion.get(artifact.versionId) + if (canonicalId && artifact.id !== canonicalId) aliases.set(artifact.id, canonicalId) + } + return aliases +} + +const rewriteArtifactIds = ( + artifactIds: string[] | undefined, + aliases: ReadonlyMap +): string[] | undefined => { + if (!artifactIds) return undefined + + const seen = new Set() + const rewritten: string[] = [] + let changed = false + for (const artifactId of artifactIds) { + const canonicalId = aliases.get(artifactId) ?? artifactId + if (canonicalId !== artifactId || seen.has(canonicalId)) changed = true + if (seen.has(canonicalId)) continue + seen.add(canonicalId) + rewritten.push(canonicalId) + } + return changed ? rewritten : artifactIds +} + +// Repairs one historical projection bug: a message-scoped compatibility descriptor could be saved +// beside the native immutable Artifact Version descriptor. The Version is the authority, so the +// repair removes only its duplicate alias and rewrites references to the same Version id. Message +// content/timestamps and Artifact Version content/metadata are never recomputed or deleted. +const repairHistoricalArtifactAliases = ( + session: PersistedChatSession, + options: { advanceFilesRevision?: boolean } = {} +): PersistedChatSession => { + const artifacts = session.artifacts ?? [] + const aliases = canonicalArtifactAliases(artifacts) + if (aliases.size === 0) return session + + const messages = session.messages.map((message) => { + const artifactIds = rewriteArtifactIds(message.artifactIds, aliases) + return artifactIds === message.artifactIds ? message : { ...message, artifactIds } + }) + const conversationGraph = session.conversationGraph + ? { + ...session.conversationGraph, + messages: session.conversationGraph.messages.map((message) => { + const artifactIds = rewriteArtifactIds(message.artifactIds, aliases) + return artifactIds === message.artifactIds ? message : { ...message, artifactIds } + }) + } + : undefined + + return { + ...session, + artifacts: artifacts.filter((artifact) => !aliases.has(artifact.id)), + messages, + conversationGraph, + filesRevision: + options.advanceFilesRevision === false + ? session.filesRevision + : (session.filesRevision ?? 0) + 1 + } +} + +export { repairHistoricalArtifactAliases } diff --git a/src/main/session-persistence/coordinator.test.ts b/src/main/session-persistence/coordinator.test.ts index 1d5739c56..e3bee840f 100644 --- a/src/main/session-persistence/coordinator.test.ts +++ b/src/main/session-persistence/coordinator.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ArtifactVersionFile } from '../../shared/artifact-provenance' import { materializeSessionConversationGraph, + type PersistedArtifact, type PersistedChatSession } from '../../shared/session-persistence' import type { ArtifactProjectReconciliationSnapshot } from '../artifacts/provenance-repository' @@ -101,6 +102,40 @@ const createRecoveredArtifact = ( ...overrides }) +const createPersistedRecoveredArtifact = ( + overrides: Partial = {} +): PersistedArtifact => { + const artifact = createRecoveredArtifact(overrides) + return { + id: artifact.id, + artifactId: artifact.artifactId, + versionId: artifact.versionId, + versionNumber: artifact.versionNumber, + kind: 'managed-file', + path: artifact.path, + fileUrl: artifact.fileUrl, + name: artifact.name, + mimeType: artifact.mimeType, + size: artifact.size, + mtimeMs: artifact.mtimeMs, + sha256: artifact.checksum + } +} + +const createLegacyArtifactAlias = (): PersistedArtifact => ({ + id: 'session-1:message-1:result.csv', + artifactId: 'artifact-lineage-1', + versionId: 'artifact-version-1', + versionNumber: 1, + kind: 'managed-file', + path: '/managed/message-1/result.csv', + fileUrl: 'file:///managed/message-1/result.csv', + name: 'result.csv', + mimeType: 'text/csv', + size: 12, + mtimeMs: 2 +}) + const createProjectReconciliationSnapshot = (): ArtifactProjectReconciliationSnapshot => ({}) as ArtifactProjectReconciliationSnapshot @@ -774,6 +809,58 @@ describe('SessionPersistenceCoordinator', () => { expect(artifactStorage.reconcileSession).not.toHaveBeenCalled() }) + it('reconciles durable Session grants only on the first complete startup scan', async () => { + const session = createSession() + const result = { sessions: [session], manifest: { version: 1 as const } } + const repository = createSessionRepository({ + loadAllWithDiagnostics: vi.fn().mockResolvedValue({ result, isComplete: true }) + }) + const reconcileSessions = vi.fn().mockResolvedValue(undefined) + const coordinator = new SessionPersistenceCoordinator( + repository, + createFileIndex(), + undefined, + undefined, + undefined, + undefined, + { reconcileSessions } + ) + + await coordinator.loadAll() + await coordinator.loadAll() + + expect(reconcileSessions).toHaveBeenCalledOnce() + expect(reconcileSessions).toHaveBeenCalledWith([ + { projectId: 'project-1', sessionId: 'session-1' } + ]) + }) + + it('does not prune durable Session grants from a partial startup scan', async () => { + const session = createSession() + const result = { sessions: [session], manifest: { version: 1 as const } } + const repository = createSessionRepository({ + loadAllWithDiagnostics: vi + .fn() + .mockResolvedValueOnce({ result, isComplete: false }) + .mockResolvedValueOnce({ result, isComplete: true }) + }) + const reconcileSessions = vi.fn().mockResolvedValue(undefined) + const coordinator = new SessionPersistenceCoordinator( + repository, + createFileIndex(), + undefined, + undefined, + undefined, + undefined, + { reconcileSessions } + ) + + await coordinator.loadAll() + await coordinator.loadAll() + + expect(reconcileSessions).not.toHaveBeenCalled() + }) + it('reconciles path-free Upload copies only on the first complete load from multiple clients', async () => { const root = await mkdtemp(join(tmpdir(), 'open-science-upload-startup-reconcile-')) const client = createProjectDbClient(root) @@ -1365,6 +1452,202 @@ describe('SessionPersistenceCoordinator', () => { expect(repository.saveSession).toHaveBeenCalledOnce() }) + it('replaces a recovered Artifact Version legacy alias instead of preserving two projections', async () => { + const legacyAlias = createLegacyArtifactAlias() + const originalSession = materializeSessionConversationGraph( + createSession({ + filesRevision: 4, + messages: [ + { + id: 'message-1', + role: 'agent', + content: 'result', + status: 'complete', + eventIds: [], + artifactIds: [legacyAlias.id], + createdAt: 1, + updatedAt: 2 + } + ], + artifacts: [legacyAlias] + }) + ) + const repository = createSessionRepository({ + loadAllWithDiagnostics: vi.fn().mockResolvedValue({ + result: { sessions: [originalSession], manifest: { version: 1 as const } }, + isComplete: true + }) + }) + const artifactStorage = { + prepareProjectReconciliation: vi + .fn() + .mockResolvedValue(createProjectReconciliationSnapshot()), + reconcileSession: vi.fn().mockResolvedValue({ + recoveredMessageArtifacts: [ + { messageId: 'message-1', artifacts: [createRecoveredArtifact()] } + ] + }) + } + const coordinator = new SessionPersistenceCoordinator( + repository, + createFileIndex(), + undefined, + undefined, + undefined, + artifactStorage + ) + + const loaded = await coordinator.loadAll() + const recoveredSession = loaded.sessions[0] + + expect(recoveredSession.messages[0].artifactIds).toEqual(['artifact-version-1']) + expect(recoveredSession.conversationGraph?.messages[0].artifactIds).toEqual([ + 'artifact-version-1' + ]) + expect(recoveredSession.artifacts).toEqual([ + expect.objectContaining({ + id: 'artifact-version-1', + versionId: 'artifact-version-1', + sha256: 'a'.repeat(64) + }) + ]) + expect(recoveredSession.filesRevision).toBe(5) + expect(repository.saveSession).toHaveBeenCalledOnce() + }) + + it('normalizes an already duplicated historical Artifact Version without recovery input', async () => { + const legacyAlias = createLegacyArtifactAlias() + const nativeVersion = createPersistedRecoveredArtifact() + const originalSession = materializeSessionConversationGraph( + createSession({ + filesRevision: 7, + messages: [ + { + id: 'message-1', + role: 'agent', + content: 'result', + status: 'complete', + eventIds: [], + artifactIds: [legacyAlias.id, nativeVersion.id], + createdAt: 1, + updatedAt: 2 + } + ], + artifacts: [legacyAlias, nativeVersion] + }) + ) + let durableSession = originalSession + const repository = createSessionRepository({ + loadAllWithDiagnostics: vi.fn(async () => ({ + result: { sessions: [durableSession], manifest: { version: 1 as const } }, + isComplete: true + })), + saveSession: vi.fn(async (session) => { + durableSession = structuredClone(session) + }) + }) + const artifactStorage = { + prepareProjectReconciliation: vi + .fn() + .mockResolvedValue(createProjectReconciliationSnapshot()), + reconcileSession: vi.fn().mockResolvedValue(undefined) + } + const coordinator = new SessionPersistenceCoordinator( + repository, + createFileIndex(), + undefined, + undefined, + undefined, + artifactStorage + ) + + const loaded = await coordinator.loadAll() + const normalizedSession = loaded.sessions[0] + + expect(normalizedSession.messages[0].artifactIds).toEqual(['artifact-version-1']) + expect(normalizedSession.conversationGraph?.messages[0].artifactIds).toEqual([ + 'artifact-version-1' + ]) + expect(normalizedSession.artifacts).toEqual([nativeVersion]) + expect(normalizedSession.filesRevision).toBe(8) + expect(repository.saveSession).toHaveBeenCalledOnce() + + const replayed = await coordinator.loadAll() + expect(replayed.sessions[0]).toEqual(normalizedSession) + expect(repository.saveSession).toHaveBeenCalledOnce() + }) + + it('retries a failed historical Artifact alias write-back without replay churn', async () => { + const legacyAlias = createLegacyArtifactAlias() + const nativeVersion = createPersistedRecoveredArtifact() + const originalSession = materializeSessionConversationGraph( + createSession({ + filesRevision: 7, + messages: [ + { + id: 'message-1', + role: 'agent', + content: 'historical result', + status: 'complete', + eventIds: ['event-1'], + artifactIds: [legacyAlias.id, nativeVersion.id], + createdAt: 1, + updatedAt: 2 + } + ], + artifacts: [legacyAlias, nativeVersion] + }) + ) + let durableSession = originalSession + let writeAttempt = 0 + const repository = createSessionRepository({ + loadAllWithDiagnostics: vi.fn(async () => ({ + result: { sessions: [durableSession], manifest: { version: 1 as const } }, + isComplete: true + })), + saveSession: vi.fn(async (session) => { + writeAttempt += 1 + if (writeAttempt === 1) throw new Error('session json is read-only') + durableSession = structuredClone(session) + }) + }) + const markReconciliationIncomplete = vi.fn() + const artifactStorage = { + prepareProjectReconciliation: vi + .fn() + .mockResolvedValue(createProjectReconciliationSnapshot()), + reconcileSession: vi.fn().mockResolvedValue(undefined) + } + const coordinator = new SessionPersistenceCoordinator( + repository, + createFileIndex({ markReconciliationIncomplete }), + undefined, + undefined, + undefined, + artifactStorage + ) + + const failedWriteView = await coordinator.loadAll() + expect(failedWriteView.sessions[0].messages[0]).toMatchObject({ + content: 'historical result', + eventIds: ['event-1'], + artifactIds: ['artifact-version-1'], + createdAt: 1, + updatedAt: 2 + }) + expect(failedWriteView.sessions[0].artifacts).toEqual([nativeVersion]) + expect(failedWriteView.diagnostics?.failure).toBe('startup-reconciliation-failed') + expect(markReconciliationIncomplete).toHaveBeenCalledOnce() + + const retried = await coordinator.loadAll() + expect(retried.sessions[0].messages[0].artifactIds).toEqual(['artifact-version-1']) + expect(repository.saveSession).toHaveBeenCalledTimes(2) + + const replayed = await coordinator.loadAll() + expect(replayed.sessions[0]).toEqual(retried.sessions[0]) + expect(repository.saveSession).toHaveBeenCalledTimes(2) + }) + it('returns the recovered Session view and retries when its JSON save fails', async () => { const session = materializeSessionConversationGraph( createSession({ diff --git a/src/main/session-persistence/coordinator.ts b/src/main/session-persistence/coordinator.ts index f78073196..244c617c5 100644 --- a/src/main/session-persistence/coordinator.ts +++ b/src/main/session-persistence/coordinator.ts @@ -20,6 +20,7 @@ import { type SessionDeletionReceipt } from '../artifacts/provenance-message-snapshot' import type { ArtifactProjectReconciliationSnapshot } from '../artifacts/provenance-repository' +import { repairHistoricalArtifactAliases } from './artifact-alias-repair' import { OrphanLegacyUploadAuthorityMissingError, UnsafeLegacyUploadResidualError @@ -87,6 +88,12 @@ type SessionProvenancePersistence = { abortSessionDeletion(receipt: SessionDeletionReceipt): Promise } +type SessionPermissionGrantReconciliation = { + reconcileSessions( + sessions: ReadonlyArray<{ projectId: string; sessionId: string }> + ): Promise +} + type SessionUploadPersistence = { upgradeLegacySessionUploads( session: PersistedChatSession, @@ -324,7 +331,8 @@ class SessionPersistenceCoordinator { private readonly onFilesChanged?: (event: ProjectFilesChangedEvent) => void, private readonly provenance?: SessionProvenancePersistence, private readonly uploads?: SessionUploadPersistence, - private readonly artifactStorage?: ArtifactStorageReconciler + private readonly artifactStorage?: ArtifactStorageReconciler, + private readonly permissionGrants?: SessionPermissionGrantReconciliation ) {} // Binds unread cleanup to authoritative Session mutations. Reconciliation is called only with a @@ -393,6 +401,18 @@ class SessionPersistenceCoordinator { console.error('[session-persistence] unread deletion reconciliation failed', error) } + if (mayRunDestructiveStartupCleanup) { + try { + await this.permissionGrants?.reconcileSessions( + sessions.map((session) => ({ projectId: session.projectId, sessionId: session.id })) + ) + } catch (error) { + // Chat hydration remains available. The Registry is still fail-closed by exact live scope + // matching, and the complete scan will retry cleanup on the next process startup. + console.error('[session-persistence] permission grant reconciliation failed', error) + } + } + try { if (this.uploads) { for (let index = 0; index < sessions.length; index += 1) { @@ -449,10 +469,15 @@ class SessionPersistenceCoordinator { projectReconciliation: projectReconciliations.get(session.projectId)! } ) - const recoveredSession = attachRecoveredMessageArtifacts( + const attachedSession = attachRecoveredMessageArtifacts( session, artifactRecovery?.recoveredMessageArtifacts ?? [] ) + const recoveredSession = repairHistoricalArtifactAliases(attachedSession, { + // One reconciliation pass writes one JSON revision even when recovery and historical + // alias repair both contribute to the same atomic Session update. + advanceFilesRevision: attachedSession === session + }) if (recoveredSession !== session) { sessions = sessions.map((candidate, candidateIndex) => candidateIndex === index ? recoveredSession : candidate diff --git a/src/main/settings/claude-config-provision.ts b/src/main/settings/claude-config-provision.ts index 6ebae326f..7db17b73f 100644 --- a/src/main/settings/claude-config-provision.ts +++ b/src/main/settings/claude-config-provision.ts @@ -26,7 +26,7 @@ const GUARDED_FILE_TOOLS = ['Read', 'Edit', 'Glob', 'Grep'] as const // reach is a path for conversation/workspace data to leave the app). Re-opening it accepts that risk // in exchange for the model's built-in web reach. Subscription WebFetch additionally relies on the // CLI's claude.ai domain-safety preflight. Custom API-key sessions cannot reach that hard-coded check, -// so they force WebFetch through the app broker and scope conversation grants to one hostname. +// so they force every WebFetch call through the app broker for explicit Once-only approval. const DENIED_BUILTIN_TOOLS = [] as const // Built-in tool deny entries this module OWNS across versions — the full set it has ever written into diff --git a/src/main/settings/ipc.test.ts b/src/main/settings/ipc.test.ts index e6a59f136..4c82ce39c 100644 --- a/src/main/settings/ipc.test.ts +++ b/src/main/settings/ipc.test.ts @@ -70,7 +70,8 @@ type FakeSettingsService = Record< | 'listAgentHomeSkills' | 'previewAgentHomeSkill' | 'importAgentHomeSkills' - | 'setConnectorEnabled', + | 'setConnectorEnabled' + | 'updateCustomServer', ReturnType > @@ -155,7 +156,8 @@ const createFakeService = (): FakeSettingsService => ({ listAgentHomeSkills: vi.fn().mockResolvedValue([]), previewAgentHomeSkill: vi.fn().mockResolvedValue({ name: 'Installed preview' }), importAgentHomeSkills: vi.fn().mockResolvedValue({ results: [], skills: [] }), - setConnectorEnabled: vi.fn().mockResolvedValue({ connectors: [] }) + setConnectorEnabled: vi.fn().mockResolvedValue({ connectors: [] }), + updateCustomServer: vi.fn().mockResolvedValue({ connectors: [], customServers: [] }) }) // Adapts the spy bag into the SettingsService shape the registration function expects. @@ -344,6 +346,28 @@ describe('settings IPC handlers', () => { expect(onConnectorsChanged).toHaveBeenCalledOnce() }) + it('passes the custom-server security invalidation gate through before refreshing connectors', async () => { + handlers.clear() + const service = createFakeService() + const onCustomServerSecurityChanged = vi.fn().mockResolvedValue(undefined) + const onConnectorsChanged = vi.fn() + registerSettingsIpcHandlers({ + service: asService(service), + onCustomServerSecurityChanged, + onConnectorsChanged + }) + const request = { + id: 'server-id', + transport: 'streamable_http' as const, + url: 'https://replacement.example/mcp' + } + + await invoke('settings:update-custom-server', request) + + expect(service.updateCustomServer).toHaveBeenCalledWith(request, onCustomServerSecurityChanged) + expect(onConnectorsChanged).toHaveBeenCalledOnce() + }) + it('drops the agent connection when the active provider changes', async () => { handlers.clear() const service = createFakeService() diff --git a/src/main/settings/ipc.ts b/src/main/settings/ipc.ts index 09d4b5281..d252b56b6 100644 --- a/src/main/settings/ipc.ts +++ b/src/main/settings/ipc.ts @@ -48,7 +48,11 @@ import { type ValidateProviderRequest } from '../../shared/settings' import type { ResolvedReasoningEffort } from '../../shared/reasoning-effort' -import { createDefaultSettingsService, SettingsService } from './service' +import { + createDefaultSettingsService, + SettingsService, + type CustomServerSecurityChangeGuard +} from './service' import { createLogger } from '../logger' import { broadcastToRenderers } from '../renderer-broadcast' @@ -73,6 +77,13 @@ export type SettingsIpcOptions = { onSkillsChanged?: () => void // Called after a connector/tool/credential change so bundled + custom skill docs re-sync. onConnectorsChanged?: () => void + // Hard owner deletion prunes grants after the settings mutation succeeds. + onCustomServerRemoved?: (serverId: string) => Promise + // Security-sensitive endpoint/executable edits revoke remembered authority before the new + // configuration is persisted. Failure leaves the old server configuration in place. + onCustomServerSecurityChanged?: ( + serverId: string + ) => Promise // Called after the app-icon variant changes so the main process applies it live to the window and // dock/taskbar. Absent (e.g. web mode) means only the persisted value changes. onAppIconVariantChanged?: (variant: AppIconVariant) => void @@ -95,6 +106,8 @@ const registerSettingsIpcHandlers = ({ onReasoningEffortChanged, onSkillsChanged, onConnectorsChanged, + onCustomServerRemoved, + onCustomServerSecurityChanged, onAppIconVariantChanged, listAppIconPreviews }: SettingsIpcOptions = {}): void => { @@ -568,7 +581,11 @@ const registerSettingsIpcHandlers = ({ ipcMainHandle( 'settings:remove-custom-server', async (_event, request: RemoveCustomServerRequest) => { + const serverId = (await service.getConnectors())?.customMcpServers?.find( + (server) => server.id === request.id + )?.id const snapshot = await service.removeCustomServer(request) + if (serverId) await onCustomServerRemoved?.(serverId) onConnectorsChanged?.() return snapshot } @@ -576,7 +593,7 @@ const registerSettingsIpcHandlers = ({ ipcMainHandle( 'settings:update-custom-server', async (_event, request: UpdateCustomServerRequest) => { - const snapshot = await service.updateCustomServer(request) + const snapshot = await service.updateCustomServer(request, onCustomServerSecurityChanged) onConnectorsChanged?.() return snapshot } diff --git a/src/main/settings/repository.ts b/src/main/settings/repository.ts index 14d9a6d39..e3d0c4c2d 100644 --- a/src/main/settings/repository.ts +++ b/src/main/settings/repository.ts @@ -1180,16 +1180,23 @@ class SettingsRepository { }) } - // Removes a custom MCP server by id (and any stale per-tool blocks under its name). + // Removes a custom MCP server by id and every policy alias owned by its immutable id/editable name. async removeCustomServer(id: string): Promise { return this.mutateConnectors((connectors) => { const removed = (connectors.customMcpServers ?? []).find((s) => s.id === id) connectors.customMcpServers = (connectors.customMcpServers ?? []).filter((s) => s.id !== id) - if (removed && connectors.blockedToolIds) { - const prefix = `${removed.name}/` - const kept = connectors.blockedToolIds.filter((t) => !t.startsWith(prefix)) - connectors.blockedToolIds = kept.length > 0 ? kept : undefined + if (!removed) return + + const aliases = new Set([removed.id, removed.name]) + connectors.autoAllowIds = connectors.autoAllowIds.filter((entry) => !aliases.has(entry)) + const withoutToolAliases = (entries: string[] | undefined): string[] | undefined => { + const kept = (entries ?? []).filter( + (entry) => !Array.from(aliases).some((alias) => entry.startsWith(`${alias}/`)) + ) + return kept.length > 0 ? kept : undefined } + connectors.blockedToolIds = withoutToolAliases(connectors.blockedToolIds) + connectors.askToolIds = withoutToolAliases(connectors.askToolIds) }) } @@ -1263,6 +1270,18 @@ class SettingsRepository { ) } + async listComputeGrants(): Promise { + return [...((await this.getSettings()).computeGrants ?? [])] + } + + async clearComputeGrants(): Promise { + await this.mutate((settings) => { + const next = { ...settings } + delete next.computeGrants + return next + }) + } + // Serializes a read-modify-write cycle so concurrent callers cannot clobber each other. private mutate(update: (settings: StoredSettings) => StoredSettings): Promise { const run = this.saveQueue.then(async () => { diff --git a/src/main/settings/service.connectors.test.ts b/src/main/settings/service.connectors.test.ts index 66c5a6c97..227e021b9 100644 --- a/src/main/settings/service.connectors.test.ts +++ b/src/main/settings/service.connectors.test.ts @@ -258,6 +258,109 @@ describe('SettingsService connectors', () => { expect(stored?.env).toEqual({ TOKEN: 'keep-me' }) }) + it('invalidates remembered authority before persisting a security-sensitive server edit', async () => { + const added = await service.addCustomServer({ + name: 'mutable-endpoint', + transport: 'stdio', + command: 'old-command', + args: ['serve'] + }) + const id = added.customServers[0].id + const commit = vi.fn() + const rollback = vi.fn() + const invalidate = vi.fn(async (serverId: string) => { + const stored = (await service.getConnectors())?.customMcpServers?.find( + (server) => server.id === serverId + ) + expect(stored?.command).toBe('old-command') + return { commit, rollback } + }) + + await service.updateCustomServer( + { + id, + transport: 'streamable_http', + url: 'https://new.example/mcp', + headers: { Authorization: 'Bearer replacement' } + }, + invalidate + ) + + expect(invalidate).toHaveBeenCalledOnce() + expect(invalidate).toHaveBeenCalledWith(id) + expect(commit).toHaveBeenCalledOnce() + expect(commit.mock.calls[0]?.[0]).toMatchObject({ id, url: 'https://new.example/mcp' }) + expect(rollback).not.toHaveBeenCalled() + const stored = (await service.getConnectors())?.customMcpServers?.find( + (server) => server.id === id + ) + expect(stored?.transport).toBe('streamable_http') + expect(stored?.url).toBe('https://new.example/mcp') + }) + + it('keeps grants for display-only edits and fails closed when invalidation fails', async () => { + const added = await service.addCustomServer({ + name: 'stable-endpoint', + description: 'Before', + transport: 'stdio', + command: 'stable-command' + }) + const id = added.customServers[0].id + const invalidate = vi.fn().mockResolvedValue(undefined) + + await service.updateCustomServer( + { + id, + description: 'After', + transport: 'stdio', + command: 'stable-command' + }, + invalidate + ) + expect(invalidate).not.toHaveBeenCalled() + + invalidate.mockRejectedValueOnce(new Error('grant cleanup failed')) + await expect( + service.updateCustomServer( + { + id, + description: 'After', + transport: 'stdio', + command: 'replacement-command' + }, + invalidate + ) + ).rejects.toThrow('grant cleanup failed') + + const stored = (await service.getConnectors())?.customMcpServers?.find( + (server) => server.id === id + ) + expect(stored?.description).toBe('After') + expect(stored?.command).toBe('stable-command') + }) + + it('rolls back the custom-server security barrier when persistence fails', async () => { + const added = await service.addCustomServer({ + name: 'rollback-endpoint', + transport: 'stdio', + command: 'old-command' + }) + const id = added.customServers[0].id + const commit = vi.fn() + const rollback = vi.fn() + vi.spyOn(repository, 'updateCustomServer').mockRejectedValueOnce(new Error('write failed')) + + await expect( + service.updateCustomServer({ id, transport: 'stdio', command: 'new-command' }, async () => ({ + commit, + rollback + })) + ).rejects.toThrow('write failed') + + expect(commit).not.toHaveBeenCalled() + expect(rollback).toHaveBeenCalledOnce() + }) + it('rejects editing an unknown custom server', async () => { await expect( service.updateCustomServer({ id: 'nope', transport: 'stdio', command: 'x' }) diff --git a/src/main/settings/service.ts b/src/main/settings/service.ts index 234e1a313..20a8679b3 100644 --- a/src/main/settings/service.ts +++ b/src/main/settings/service.ts @@ -241,6 +241,7 @@ import { type CodexAuthControllerPort, type CodexAuthStatus } from './codex-auth' + import { resolveSystemProxyEnvironment, type SystemProxyEnvironment } from './system-proxy' import { ClaudeIsolatedAuthController, @@ -253,6 +254,11 @@ import { type ClaudeSharedAuthStatus } from './claude-shared-auth' +type CustomServerSecurityChangeGuard = { + commit(server: StoredCustomMcpServer): void + rollback(): void +} + export type AgentBackendSelection = { frameworkId: AgentFrameworkId } @@ -3319,8 +3325,8 @@ class SettingsService { return this.connectorsSnapshot() } - // Sets one tool's permission (allow = run without a prompt [default], ask = prompt each call, - // block = denied) and returns the connector's refreshed detail. + // Sets one tool's policy (allow = run without a prompt [default], ask = require approval when no + // remembered Broker grant applies, block = denied) and returns the refreshed detail. async setToolPermission(request: SetToolPermissionRequest): Promise { await this.repository.setToolPolicy( request.toolId, @@ -3394,8 +3400,16 @@ class SettingsService { } // Edits an existing custom MCP server, keeping its immutable identity (id, name, enabled, trust). - // Omitted env/headers keep the stored secret values; providing them replaces the set. - async updateCustomServer(request: UpdateCustomServerRequest): Promise { + // Omitted env/headers keep the stored secret values; providing them replaces the set. A caller can + // invalidate remembered authority after validation but before persistence whenever the executable, + // endpoint, transport, arguments, or credentials change. If invalidation fails, the old server + // configuration remains authoritative. + async updateCustomServer( + request: UpdateCustomServerRequest, + beforeSecuritySensitiveUpdate?: ( + serverId: string + ) => Promise + ): Promise { const existing = (await this.getConnectors())?.customMcpServers?.find( (s) => s.id === request.id ) @@ -3429,7 +3443,25 @@ class SettingsService { if (!server) throw new Error('Invalid custom connector configuration') - await this.repository.updateCustomServer(request.id, server) + const securitySensitiveConfigChanged = + existing.transport !== server.transport || + existing.command !== server.command || + !isDeepStrictEqual(existing.args ?? [], server.args ?? []) || + existing.url !== server.url || + request.env !== undefined || + request.headers !== undefined + + const securityChangeGuard = securitySensitiveConfigChanged + ? await beforeSecuritySensitiveUpdate?.(request.id) + : undefined + + try { + await this.repository.updateCustomServer(request.id, server) + securityChangeGuard?.commit(server) + } catch (error) { + securityChangeGuard?.rollback() + throw error + } return this.connectorsSnapshot() } @@ -3521,8 +3553,8 @@ class SettingsService { // Custom Anthropic-compatible gateways may work while Anthropic's domain preflight // endpoint is unreachable. Keep this override scoped to the ACP session so neither // project/user settings nor the isolated Claude runtime configuration are mutated. - // WebFetch remains an explicit app-brokered permission, whose conversation grants are - // scoped to the approved hostname rather than the whole tool. + // V1 keeps provider-native WebFetch/WebSearch Once-only. This bypass removes Claude's + // remote preflight dependency but does not create Session, Project, or Global grants. settings: { skipWebFetchPreflight: true, permissions: { ask: ['WebFetch'] } @@ -4373,3 +4405,4 @@ class SettingsService { const createDefaultSettingsService = (): SettingsService => new SettingsService() export { SettingsService, createDefaultSettingsService } +export type { CustomServerSecurityChangeGuard } diff --git a/src/main/settings/types.ts b/src/main/settings/types.ts index 47ca7104b..11719ee6d 100644 --- a/src/main/settings/types.ts +++ b/src/main/settings/types.ts @@ -104,7 +104,7 @@ export type StoredConnectors = { // Fully-qualified "/" ids denied by policy; allow by default otherwise. blockedToolIds?: string[] // Fully-qualified "/" ids that require per-call approval (opt-in). Tools default - // to allow (no prompt); this is the set the user switched to "Ask each time". + // to allow (no prompt); this is the set the user switched to "Require approval". askToolIds?: string[] // Ids of bundled connectors the user turned OFF. Absent/empty means every bundled connector is // enabled (default-on), mirroring disabledSkillIds. This is the authoritative bundled gate. @@ -180,14 +180,12 @@ export type StoredSettings = { // Pinned bookmark folders for the remote file browser, keyed by provider_id. // Each value is an ordered array of absolute paths the user has pinned via Go-to. computeBookmarks?: Record - // Persisted project-scope compute approval grants (design.md §6). Each grant means - // calls matching (projectId, operation, providerId) skip the approval card for that project. - // Conversation-scope grants are session-only (in-memory broker) and are NOT stored here. + // Legacy project-scope compute grants, read only for one-time migration into PermissionGrant. + // Production authorization never appends to this field; it is removed after a successful import. computeGrants?: StoredComputeGrant[] } -// A single project-scope compute approval grant. The key is the triple (projectId, operation, providerId). -// Stored in settings.json rather than the DB so it does not require a schema migration. +// Legacy settings.json shape retained only so existing installations can migrate without data loss. export type StoredComputeGrant = { projectId: string operation: string diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8d0f2cadd..87d225656 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -62,6 +62,13 @@ import type { SessionDeletedEvent, SessionUpsertEvent } from '../shared/lifecycle-events' +import type { + PermissionGrantMutationView, + PermissionGrantRestoreRequest, + PermissionGrantRevokeRequest, + PermissionGrantSnapshot, + PermissionGrantsChangedEvent +} from '../shared/permission-grants' import type { AppendNotebookCodeCellRequest, BeginNotebookCodeCellRequest, @@ -280,6 +287,12 @@ interface OpenScienceAPI { onEvent(listener: AcpListener): RemoveListener onPermissionRequest(listener: AcpListener): RemoveListener } + permissions: { + list(): Promise + revoke(request: PermissionGrantRevokeRequest): Promise + restore(request: PermissionGrantRestoreRequest): Promise + onChanged(listener: AcpListener): RemoveListener + } sessions: { loadAll(): Promise saveSession( diff --git a/src/preload/index.ts b/src/preload/index.ts index 92d939c5f..56a659210 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -64,6 +64,13 @@ import type { SessionDeletedEvent, SessionUpsertEvent } from '../shared/lifecycle-events' +import type { + PermissionGrantMutationView, + PermissionGrantRestoreRequest, + PermissionGrantRevokeRequest, + PermissionGrantSnapshot, + PermissionGrantsChangedEvent +} from '../shared/permission-grants' import type { AppendNotebookCodeCellRequest, BeginNotebookCodeCellRequest, @@ -318,6 +325,12 @@ type OpenScienceAPI = { onEvent: (listener: AcpListener) => RemoveListener onPermissionRequest: (listener: AcpListener) => RemoveListener } + permissions: { + list: () => Promise + revoke: (request: PermissionGrantRevokeRequest) => Promise + restore: (request: PermissionGrantRestoreRequest) => Promise + onChanged: (listener: AcpListener) => RemoveListener + } sessions: { loadAll: () => Promise saveSession: ( @@ -793,6 +806,14 @@ const api: OpenScienceAPI = { onEvent: (listener) => onIpcMessage('acp:event', listener), onPermissionRequest: (listener) => onIpcMessage('acp:permission-request', listener) }, + permissions: { + list: () => ipcRenderer.invoke('permissions:list') as Promise, + revoke: (request) => + ipcRenderer.invoke('permissions:revoke', request) as Promise, + restore: (request) => + ipcRenderer.invoke('permissions:restore', request) as Promise, + onChanged: (listener) => onIpcMessage('permissions:changed', listener) + }, sessions: { // Loads every per-session file plus the last-open manifest from the main process. loadAll: () => ipcRenderer.invoke('sessions:load-all') as Promise, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index faa8efad9..38f1c278a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -185,8 +185,23 @@ vi.mock('@/pages/settings/SkillImportApprovalDialog', () => ({ SkillImportApprovalDialog: (): React.JSX.Element =>
})) vi.mock('@/pages/settings/SettingsPage', () => ({ - SettingsPage: ({ open }: { open: boolean }): React.JSX.Element => ( -
{open ? 'open' : 'closed'}
+ SettingsPage: ({ + open, + onOpenSession + }: { + open: boolean + onOpenSession?: (sessionId: string) => void + }): React.JSX.Element => ( +
+ {open ? 'open' : 'closed'} + +
) })) vi.mock('@/pages/workspace/EnvStatusBanner', () => ({ @@ -224,6 +239,7 @@ describe('App startup routing', () => { mocks.settings.isSettingsOpen = false mocks.settings.load.mockReset().mockResolvedValue(true) mocks.settings.checkEnvironment.mockReset().mockResolvedValue(undefined) + mocks.settings.closeSettings.mockClear() mocks.skillImport.enqueue.mockClear() mocks.skillImport.dismiss.mockClear() mocks.navigation.view = 'home' @@ -265,7 +281,8 @@ describe('App startup routing', () => { onApprovalRequest: vi.fn(() => vi.fn()), onJobUpdated: vi.fn(() => vi.fn()), enabledHostsSet: vi.fn(() => Promise.resolve()) - } + }, + permissions: { onChanged: vi.fn(() => vi.fn()) } } as unknown as Window['api'] mocks.openSessionById.mockClear() mocks.sessions = [] @@ -432,6 +449,26 @@ describe('App startup routing', () => { }) }) + it('opens a remembered permission session from Settings and keeps missing sessions safe', async () => { + mocks.settings.isLoaded = true + mocks.settings.isSettingsOpen = true + await render() + + const openSession = container.querySelector( + '[data-testid="open-settings-session"]' + ) + await act(async () => openSession?.click()) + + expect(mocks.openSessionById).not.toHaveBeenCalled() + expect(mocks.settings.closeSettings).not.toHaveBeenCalled() + + mocks.sessions = [{ id: 'settings-session' }] + await act(async () => openSession?.click()) + + expect(mocks.openSessionById).toHaveBeenCalledWith('settings-session', 'user') + expect(mocks.settings.closeSettings).toHaveBeenCalledOnce() + }) + it('reports retained session content as hidden during retry loading and hard failure', async () => { mocks.settings.isLoaded = true mocks.navigation.view = 'workspace' @@ -502,6 +539,7 @@ describe('App startup routing', () => { expect(mocks.settings.load).toHaveBeenCalled() expect(mocks.settings.checkEnvironment).toHaveBeenCalled() expect(mocks.getInfo).toHaveBeenCalled() + expect(window.api.permissions.onChanged).toHaveBeenCalledOnce() }) it('surfaces a session load failure with a retry action', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fab016640..982cba6a1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import { CloseConfirmModal } from '@/components/CloseConfirmModal' import { DataRootMissingDialog } from '@/components/DataRootMissingDialog' import { LegacyDataMoveDialog } from '@/components/LegacyDataMoveDialog' import { LifecycleToast } from '@/components/LifecycleToast' +import { PermissionUndoSnackbar } from '@/components/PermissionUndoSnackbar' import { SessionPersistenceAlert } from '@/components/SessionPersistenceAlert' import { UpdateDialog } from '@/components/UpdateDialog' import { Button } from '@/components/ui/button' @@ -33,6 +34,7 @@ import { useComputeStore } from '@/stores/compute-store' import { useSessionJobStore } from '@/stores/session-job-store' import { useSkillImportStore } from '@/stores/skill-import-store' import { useUpdateStore } from '@/stores/update-store' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' type NotificationOpenIntent = { generation: number @@ -75,7 +77,20 @@ const App = (): React.JSX.Element | null => { const isUpdateDialogOpen = useUpdateStore((state) => state.isDialogOpen) const initEnv = useNotebookEnvStore((state) => state.init) const envUi = useNotebookEnvStore((state) => state.ui) + const listenForPermissionChanges = usePermissionGrantsStore((state) => state.listen) const retryEnv = useNotebookEnvStore((state) => state.retry) + const openPermissionSession = useCallback( + (sessionId: string): void => { + const sessionExists = useSessionStore + .getState() + .sessions.some((session) => session.id === sessionId) + if (!sessionExists) return + + useNavigationStore.getState().openSessionById(sessionId, 'user') + closeSettings() + }, + [closeSettings] + ) // §20.4: settings.dataRoot configured but the folder is gone (deleted or an unmounted drive). const [missingDataRoot, setMissingDataRoot] = useState(undefined) // Legacy (pre-§20) install whose data still lives in the hidden config root: offer the one-time @@ -121,6 +136,8 @@ const App = (): React.JSX.Element | null => { initUpdates() }, [initUpdates]) + useEffect(() => listenForPermissionChanges(), [listenForPermissionChanges]) + // Mirrors the main-process provisioner once at launch (Plan A auto-runs upgradeIfNeeded and // broadcasts progress); the returned `ui` drives the top-level upgrade/error banner below. useEffect(() => { @@ -410,7 +427,11 @@ const App = (): React.JSX.Element | null => { canDeleteConversations={sessionPersistence.canDeleteSessionsAndProjects} /> )} - + { onDismiss={lifecycleSync.dismissNotice} onView={lifecycleSync.viewNotice} /> + diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 5caa4b703..df0cc3657 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -60,6 +60,7 @@ --shadow-menu: var(--shadow-menu-value); --shadow-dialog: var(--shadow-dialog-value); --z-index-modal: 50; + --z-index-toast: 70; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/src/renderer/src/components/PermissionUndoSnackbar.test.tsx b/src/renderer/src/components/PermissionUndoSnackbar.test.tsx new file mode 100644 index 000000000..b37142ea7 --- /dev/null +++ b/src/renderer/src/components/PermissionUndoSnackbar.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' +import { PermissionUndoSnackbar } from './PermissionUndoSnackbar' + +describe('PermissionUndoSnackbar', () => { + let container: HTMLDivElement + let root: Root + const restore = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + restore.mockReset().mockResolvedValue({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [] + }) + window.api = { permissions: { restore } } as unknown as Window['api'] + usePermissionGrantsStore.setState({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + status: 'ready', + error: undefined, + undo: { + token: 'undo-1', + expiresAt: Date.now() + 8_000, + message: 'Revoked Local compute · Shell' + }, + undoQueue: [], + isRestoring: false + }) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + document.body.style.removeProperty('pointer-events') + vi.useRealTimers() + }) + + it('restores from the app-root action and prevents a duplicate activation', async () => { + await act(async () => root.render()) + const undo = container.querySelector('button:not([aria-label])') + + await act(async () => undo?.click()) + + expect(restore).toHaveBeenCalledOnce() + expect(restore).toHaveBeenCalledWith({ undoToken: 'undo-1' }) + expect(container.querySelector('[data-testid="permission-undo-snackbar"]')).toBeNull() + }) + + it('supports explicit dismiss and expiry after Settings has closed', async () => { + await act(async () => root.render()) + await act(async () => + container.querySelector('[aria-label="Dismiss permission Undo"]')?.click() + ) + expect(container.querySelector('[data-testid="permission-undo-snackbar"]')).toBeNull() + + await act(async () => + usePermissionGrantsStore.setState({ + undo: { + token: 'undo-2', + expiresAt: Date.now() + 8_000, + message: 'Revoked Python' + } + }) + ) + expect(container.textContent).toContain('Revoked Python') + + await act(async () => vi.advanceTimersByTime(8_000)) + expect(container.querySelector('[data-testid="permission-undo-snackbar"]')).toBeNull() + }) + + it('pauses automatic dismissal while the snackbar is hovered', async () => { + await act(async () => root.render()) + const snackbar = container.querySelector( + '[data-testid="permission-undo-snackbar"]' + ) + + await act(async () => snackbar?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))) + await act(async () => vi.advanceTimersByTime(8_000)) + expect(container.querySelector('[data-testid="permission-undo-snackbar"]')).not.toBeNull() + + await act(async () => snackbar?.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))) + await act(async () => vi.advanceTimersByTime(0)) + expect(container.querySelector('[data-testid="permission-undo-snackbar"]')).toBeNull() + }) + + it('renders every unexpired receipt as an independently operable Undo action', async () => { + usePermissionGrantsStore.setState({ + undoQueue: [ + { + token: 'undo-2', + expiresAt: Date.now() + 8_000, + message: 'Revoked Python' + }, + { + token: 'undo-3', + expiresAt: Date.now() + 8_000, + message: 'Revoked Shell' + }, + { + token: 'undo-4', + expiresAt: Date.now() + 8_000, + message: 'Revoked Connector' + } + ] + }) + await act(async () => root.render()) + + const snackbars = container.querySelectorAll('[data-testid="permission-undo-snackbar"]') + expect(snackbars).toHaveLength(4) + expect(container.textContent).toContain('Revoked Local compute · Shell') + expect(container.textContent).toContain('Revoked Python') + expect(container.textContent).toContain('Revoked Shell') + expect(container.textContent).toContain('Revoked Connector') + expect(container.textContent).not.toContain('queued') + expect( + container.querySelector('[data-testid="permission-undo-stack"]')?.getAttribute('data-slot') + ).toBe('scroll-area') + + const fourthUndo = container.querySelector( + '[data-undo-token="undo-4"] button:not([aria-label])' + ) + await act(async () => fourthUndo?.click()) + + expect(restore).toHaveBeenCalledWith({ undoToken: 'undo-4' }) + expect(container.querySelector('[data-undo-token="undo-4"]')).toBeNull() + expect(container.querySelector('[data-undo-token="undo-1"]')).not.toBeNull() + }) + + it('remains interactive while a modal has locked pointer events on the document body', async () => { + document.body.style.pointerEvents = 'none' + await act(async () => root.render()) + + const snackbar = container.querySelector( + '[data-testid="permission-undo-snackbar"]' + ) + const buttons = Array.from(container.querySelectorAll('button')) + + expect(snackbar?.className).toContain('pointer-events-auto') + expect(buttons).toHaveLength(2) + buttons.forEach((button) => { + expect(button.className).toContain('hover:bg-muted') + expect(button.className).toContain('focus-visible:ring-3') + }) + }) +}) diff --git a/src/renderer/src/components/PermissionUndoSnackbar.tsx b/src/renderer/src/components/PermissionUndoSnackbar.tsx new file mode 100644 index 000000000..b968a3520 --- /dev/null +++ b/src/renderer/src/components/PermissionUndoSnackbar.tsx @@ -0,0 +1,129 @@ +import { KeyRound, LoaderCircle, RotateCcw, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' +import type { PermissionUndo } from '@/stores/permission-grants-store' + +const PermissionUndoItem = ({ + undo, + restore, + dismiss, + isRestoring +}: { + undo: PermissionUndo + restore: (token: string) => Promise + dismiss: (token: string) => void + isRestoring: boolean +}): React.JSX.Element => { + const [pointerPaused, setPointerPaused] = useState(false) + const [focusPaused, setFocusPaused] = useState(false) + const paused = pointerPaused || focusPaused + + useEffect(() => { + if (paused) return + const remaining = Math.max(0, undo.expiresAt - Date.now()) + const timer = window.setTimeout(() => dismiss(undo.token), remaining) + return () => window.clearTimeout(timer) + }, [dismiss, paused, undo.expiresAt, undo.token]) + + return ( +
setPointerPaused(true)} + onMouseLeave={() => setPointerPaused(false)} + onFocusCapture={() => setFocusPaused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocusPaused(false) + }} + onKeyDown={(event) => { + if (event.key === 'Escape') dismiss(undo.token) + }} + className="pointer-events-auto flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-xl border border-border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-xl" + > +
+ ) +} + +// App-root ownership keeps Undo available when Settings closes. The Registry receipt remains the +// authority; this component only owns its short-lived renderer presentation. +const PermissionUndoSnackbar = (): React.JSX.Element | null => { + const undo = usePermissionGrantsStore((state) => state.undo) + const undoQueue = usePermissionGrantsStore((state) => state.undoQueue) + const restore = usePermissionGrantsStore((state) => state.restore) + const dismiss = usePermissionGrantsStore((state) => state.dismissUndo) + const isRestoring = usePermissionGrantsStore((state) => state.isRestoring) + + const items = useMemo( + () => [undo, ...undoQueue].filter((item): item is PermissionUndo => Boolean(item)), + [undo, undoQueue] + ) + if (items.length === 0) return null + + return ( + +
+ {items.map((item) => ( + + ))} +
+
+ ) +} + +export { PermissionUndoSnackbar } diff --git a/src/renderer/src/lib/acp/useAcpRuntime.test.ts b/src/renderer/src/lib/acp/useAcpRuntime.test.ts index f220aac2f..384e03e45 100644 --- a/src/renderer/src/lib/acp/useAcpRuntime.test.ts +++ b/src/renderer/src/lib/acp/useAcpRuntime.test.ts @@ -158,6 +158,26 @@ describe('useAcpRuntime respondToPermission', () => { cancelled: false }) }) + + it('reports and rethrows a permission persistence failure', async () => { + acpApi.respondToPermission.mockRejectedValueOnce( + new Error('Permission approval could not be saved; the tool call was cancelled.') + ) + const { result } = await mountRuntime() + + let caught: unknown + await act(async () => { + try { + await result.current.respondToPermission('request-1', 'allow-project') + } catch (error) { + caught = error + } + }) + + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).toContain('Permission approval could not be saved') + expect(result.current.actionError).toContain('Permission approval could not be saved') + }) }) describe('useAcpRuntime snapshot action failures', () => { diff --git a/src/renderer/src/lib/acp/useAcpRuntime.ts b/src/renderer/src/lib/acp/useAcpRuntime.ts index e65659ab0..9c6a0a4d0 100644 --- a/src/renderer/src/lib/acp/useAcpRuntime.ts +++ b/src/renderer/src/lib/acp/useAcpRuntime.ts @@ -81,10 +81,7 @@ const useAcpRuntime = (): { resumeFallback?: AcpPromptRequest['resumeFallback'], provenanceContext?: AcpPromptRequest['provenanceContext'] ) => Promise - respondToPermission: ( - requestId: string, - optionId?: string - ) => Promise + respondToPermission: (requestId: string, optionId?: string) => Promise setPermissionProfile: ( sessionId: string, profile: PermissionProfileId @@ -321,16 +318,23 @@ const useAcpRuntime = (): { // Converts a UI permission click into the response shape expected by IPC. const respondToPermission = useCallback( - (requestId: string, optionId?: string) => { + async (requestId: string, optionId?: string): Promise => { const response: AcpPermissionResponse = { requestId, optionId, cancelled: !optionId } - - return runSnapshotAction(undefined, () => window.api.acp.respondToPermission(response)) + setActionError(null) + try { + const snapshot = await window.api.acp.respondToPermission(response) + setState(snapshot) + return snapshot + } catch (error) { + setActionError(getErrorMessage(error)) + throw error + } }, - [runSnapshotAction] + [] ) const setPermissionProfile = useCallback( diff --git a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.ts b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.ts index ec43c0343..5b30be039 100644 --- a/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.ts +++ b/src/renderer/src/lib/acp/useWorkspaceAgentRuntime.ts @@ -1607,10 +1607,10 @@ const useWorkspaceAgentRuntime = (): { const respondToPermission = useCallback( async (requestId: string, optionId?: string): Promise => { const request = runtime.state.pendingPermissions.find((item) => item.requestId === requestId) - const snapshot = await runtime.respondToPermission(requestId, optionId) - - if (!snapshot && request) { - useSessionStore.getState().failRun(request.sessionId, 'Permission response failed') + try { + await runtime.respondToPermission(requestId, optionId) + } catch (error) { + if (request) useSessionStore.getState().failRun(request.sessionId, getErrorMessage(error)) } }, [runtime] diff --git a/src/renderer/src/pages/settings/ComputeApprovalDialog.render.test.tsx b/src/renderer/src/pages/settings/ComputeApprovalDialog.render.test.tsx index 046f7e672..bdbad620d 100644 --- a/src/renderer/src/pages/settings/ComputeApprovalDialog.render.test.tsx +++ b/src/renderer/src/pages/settings/ComputeApprovalDialog.render.test.tsx @@ -95,8 +95,7 @@ describe('ComputeApprovalDialog', () => { it.each([ ['Deny', 'deny'], ['Once', 'once'], - ['This conversation', 'conversation'], - ['This project', 'project'] + ['This session', 'conversation'] ] as const)('keeps the %s approval decision', (label, decision) => { useComputeStore.setState({ pendingApprovals: [request] }) act(() => root.render()) @@ -105,4 +104,25 @@ describe('ComputeApprovalDialog', () => { expect(useComputeStore.getState().respondApproval).toHaveBeenCalledWith(request.id, decision) }) + + it.each([ + ['This project', 'project', 'for this project'], + ['Always', 'global', 'globally'] + ] as const)('requires confirmation before %s is remembered', (label, decision, scopePhrase) => { + useComputeStore.setState({ pendingApprovals: [request] }) + act(() => root.render()) + + act(() => findButton(label)?.click()) + + expect(useComputeStore.getState().respondApproval).not.toHaveBeenCalled() + expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(scopePhrase) + + act(() => + document.body + .querySelector('[data-testid="permission-scope-confirm"]') + ?.click() + ) + + expect(useComputeStore.getState().respondApproval).toHaveBeenCalledWith(request.id, decision) + }) }) diff --git a/src/renderer/src/pages/settings/ComputeApprovalDialog.tsx b/src/renderer/src/pages/settings/ComputeApprovalDialog.tsx index ebc218a2f..e72efb4ce 100644 --- a/src/renderer/src/pages/settings/ComputeApprovalDialog.tsx +++ b/src/renderer/src/pages/settings/ComputeApprovalDialog.tsx @@ -12,27 +12,39 @@ import { } from '@/components/ui/dialog-chrome' import { useRetainedDialogValue } from '@/components/ui/use-retained-dialog-value' import { cn } from '@/lib/utils' +import { + PermissionScopeConfirmationDialog, + type BroadPermissionScope +} from '@/pages/workspace/PermissionScopeConfirmationDialog' import { useComputeStore } from '@/stores/compute-store' // A modal approval card for a pending compute call_command. The card cannot be dismissed without // a decision — the call is held open in main until the user responds (or a 5-minute timeout fires). // -// Three scope buttons (design.md §6, no Global): +// Four approval scopes; Broker persists Session/Project/Global and the compute adapter receives a +// one-call allow decision only after that write succeeds. // Once — approve this call only; card shown every time -// This conversation — approve for (provider, operation) for the rest of this session +// This session — approve for (provider, operation) for this persisted session // This project — approve for (provider, operation) for all future calls in this project +// Always — approve for (provider, operation) across projects export function ComputeApprovalDialog(): React.JSX.Element | null { const request = useComputeStore((state) => state.pendingApprovals[0]) const respondApproval = useComputeStore((state) => state.respondApproval) const [expandedRequestId, setExpandedRequestId] = useState(null) + const [pendingBroadScope, setPendingBroadScope] = useState() const dialogRequest = useRetainedDialogValue(request) if (!dialogRequest) return null const deny = (): void => void respondApproval(dialogRequest.id, 'deny') const approveOnce = (): void => void respondApproval(dialogRequest.id, 'once') - const approveConversation = (): void => void respondApproval(dialogRequest.id, 'conversation') - const approveProject = (): void => void respondApproval(dialogRequest.id, 'project') + const approveSession = (): void => void respondApproval(dialogRequest.id, 'conversation') + const confirmBroadScope = (): void => { + if (!pendingBroadScope) return + const scope = pendingBroadScope + setPendingBroadScope(undefined) + void respondApproval(dialogRequest.id, scope) + } const isLongCommand = dialogRequest.command_preview !== dialogRequest.command_full const showFull = expandedRequestId === dialogRequest.id @@ -119,15 +131,31 @@ export function ComputeApprovalDialog(): React.JSX.Element | null { - - +
+ setPendingBroadScope(undefined)} + onConfirm={confirmBroadScope} + /> ) } diff --git a/src/renderer/src/pages/settings/ConnectorApprovalDialog.render.test.tsx b/src/renderer/src/pages/settings/ConnectorApprovalDialog.render.test.tsx index 01a28f2da..9187e3265 100644 --- a/src/renderer/src/pages/settings/ConnectorApprovalDialog.render.test.tsx +++ b/src/renderer/src/pages/settings/ConnectorApprovalDialog.render.test.tsx @@ -52,7 +52,13 @@ describe('ConnectorApprovalDialog', () => { it('shows the oldest request with the resolved connector name and tool', () => { useSettingsStore.setState({ pendingApprovals: [ - { id: 'r1', connector: 'biomart', method: 'get_data', argsPreview: '{"x":1}' } + { + id: 'r1', + connector: 'biomart', + method: 'get_data', + argsPreview: '{"x":1}', + availableScopes: ['once', 'session', 'project', 'global'] + } ] }) act(() => root.render()) @@ -62,37 +68,99 @@ describe('ConnectorApprovalDialog', () => { expect(document.body.textContent).toContain('{"x":1}') expect(button('Deny')?.getAttribute('data-slot')).toBe('button') expect(button('Deny')?.getAttribute('data-variant')).toBe('destructive') - expect(button('Always allow')?.getAttribute('data-variant')).toBe('outline') + expect(button('This session')?.getAttribute('data-variant')).toBe('outline') + expect(button('This project')?.getAttribute('data-variant')).toBe('outline') + expect(button('Global')?.getAttribute('data-variant')).toBe('outline') expect(button('Allow once')?.getAttribute('data-variant')).toBe('default') expect(document.body.querySelector('[role="dialog"]')?.className).toContain( 'overscroll-contain' ) }) - it('Allow once responds allow without pre-trusting the connector', () => { + it('Allow once responds with one-call scope without changing Connector policy', () => { useSettingsStore.setState({ - pendingApprovals: [{ id: 'r1', connector: 'biomart', method: 'get_data', argsPreview: '{}' }] + pendingApprovals: [ + { + id: 'r1', + connector: 'biomart', + method: 'get_data', + argsPreview: '{}', + availableScopes: ['once'] + } + ] }) act(() => root.render()) act(() => button('Allow once')?.click()) - expect(useSettingsStore.getState().respondApproval).toHaveBeenCalledWith('r1', 'allow') + expect(useSettingsStore.getState().respondApproval).toHaveBeenCalledWith('r1', 'once') expect(useSettingsStore.getState().setConnectorAutoAllow).not.toHaveBeenCalled() }) - it('Always allow pre-trusts the connector then allows', () => { + it.each([['This session', 'session']] as const)( + '%s returns the remembered Broker scope without changing Connector policy', + (label, scope) => { + useSettingsStore.setState({ + pendingApprovals: [ + { + id: 'r1', + connector: 'biomart', + method: 'get_data', + argsPreview: '{}', + availableScopes: ['once', 'session', 'project', 'global'] + } + ] + }) + act(() => root.render()) + + act(() => button(label)?.click()) + expect(useSettingsStore.getState().respondApproval).toHaveBeenCalledWith('r1', scope) + expect(useSettingsStore.getState().setConnectorAutoAllow).not.toHaveBeenCalled() + } + ) + + it.each([ + ['This project', 'project', 'for this project'], + ['Global', 'global', 'globally'] + ] as const)('requires confirmation before %s is remembered', (label, scope, scopePhrase) => { useSettingsStore.setState({ - pendingApprovals: [{ id: 'r1', connector: 'biomart', method: 'get_data', argsPreview: '{}' }] + pendingApprovals: [ + { + id: 'r1', + connector: 'biomart', + method: 'get_data', + argsPreview: '{}', + availableScopes: ['once', 'session', 'project', 'global'] + } + ] }) act(() => root.render()) - act(() => button('Always allow')?.click()) - expect(useSettingsStore.getState().setConnectorAutoAllow).toHaveBeenCalledWith('biomart', true) + act(() => button(label)?.click()) + + expect(useSettingsStore.getState().respondApproval).not.toHaveBeenCalled() + expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(scopePhrase) + + act(() => + document.body + .querySelector('[data-testid="permission-scope-confirm"]') + ?.click() + ) + + expect(useSettingsStore.getState().respondApproval).toHaveBeenCalledWith('r1', scope) + expect(useSettingsStore.getState().setConnectorAutoAllow).not.toHaveBeenCalled() }) it('Deny responds deny', () => { useSettingsStore.setState({ - pendingApprovals: [{ id: 'r1', connector: 'biomart', method: 'get_data', argsPreview: '{}' }] + pendingApprovals: [ + { + id: 'r1', + connector: 'biomart', + method: 'get_data', + argsPreview: '{}', + availableScopes: ['once'] + } + ] }) act(() => root.render()) diff --git a/src/renderer/src/pages/settings/ConnectorApprovalDialog.tsx b/src/renderer/src/pages/settings/ConnectorApprovalDialog.tsx index 82a588f9b..8baab33f5 100644 --- a/src/renderer/src/pages/settings/ConnectorApprovalDialog.tsx +++ b/src/renderer/src/pages/settings/ConnectorApprovalDialog.tsx @@ -1,7 +1,12 @@ import { ShieldAlert } from 'lucide-react' import { Dialog } from 'radix-ui' +import { useState } from 'react' import { Button } from '@/components/ui/button' +import { + PermissionScopeConfirmationDialog, + type BroadPermissionScope +} from '@/pages/workspace/PermissionScopeConfirmationDialog' import { useSettingsStore } from '@/stores/settings-store' // A modal approval card for an un-trusted connector call. A connector tool sends data to an external @@ -12,23 +17,30 @@ export function ConnectorApprovalDialog(): React.JSX.Element | null { const connectors = useSettingsStore((state) => state.connectors) const customServers = useSettingsStore((state) => state.customServers) const respondApproval = useSettingsStore((state) => state.respondApproval) - const setConnectorAutoAllow = useSettingsStore((state) => state.setConnectorAutoAllow) + const [pendingBroadScope, setPendingBroadScope] = useState() if (!request) return null + const availableScopes = request.availableScopes ?? ['once'] const displayName = connectors.find((c) => c.id === request.connector)?.displayName ?? customServers.find((s) => s.name === request.connector)?.name ?? request.connector - const allowOnce = (): void => void respondApproval(request.id, 'allow') - const deny = (): void => void respondApproval(request.id, 'deny') - // Pre-trust the whole connector ("Skip approvals"), then allow this call. - const allowAlways = (): void => { - void setConnectorAutoAllow(request.connector, true).finally( - () => void respondApproval(request.id, 'allow') - ) + const allow = (scope: 'once' | 'session' | 'project' | 'global'): void => { + if (scope === 'project' || scope === 'global') { + setPendingBroadScope(scope) + return + } + void respondApproval(request.id, scope) + } + const confirmBroadScope = (): void => { + if (!pendingBroadScope) return + const scope = pendingBroadScope + setPendingBroadScope(undefined) + void respondApproval(request.id, scope) } + const deny = (): void => void respondApproval(request.id, 'deny') return ( @@ -46,7 +58,7 @@ export function ConnectorApprovalDialog(): React.JSX.Element | null { Allow external request? - Claude wants to call a connector tool that sends data to an external service. + The agent wants to call a connector tool that sends data to an external service. Approve only if you trust this connector with the current request. @@ -69,19 +81,44 @@ export function ConnectorApprovalDialog(): React.JSX.Element | null { -
+
- - + ) : null} + {availableScopes.includes('project') ? ( + + ) : null} + {availableScopes.includes('global') ? ( + + ) : null} +
+ setPendingBroadScope(undefined)} + onConfirm={confirmBroadScope} + /> ) } diff --git a/src/renderer/src/pages/settings/ConnectorDetailView.render.test.tsx b/src/renderer/src/pages/settings/ConnectorDetailView.render.test.tsx index 0405d4aeb..e7f5e5285 100644 --- a/src/renderer/src/pages/settings/ConnectorDetailView.render.test.tsx +++ b/src/renderer/src/pages/settings/ConnectorDetailView.render.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectorDetailView as ConnectorDetail } from '../../../../shared/settings' import { ConnectorDetailView } from './ConnectorDetailView' import { createInitialSettingsState, useSettingsStore } from '@/stores/settings-store' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' let container: HTMLDivElement let root: Root @@ -45,7 +46,13 @@ const updatedDetail: ConnectorDetail = { beforeEach(() => { ;(window as unknown as { api: unknown }).api = { - settings: { getConnectorDetail: vi.fn().mockResolvedValue(detail) } + settings: { getConnectorDetail: vi.fn().mockResolvedValue(detail) }, + permissions: { + list: vi.fn().mockResolvedValue({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 } + }) + } } useSettingsStore.setState({ ...createInitialSettingsState(), @@ -53,6 +60,12 @@ beforeEach(() => { setConnectorAutoAllow: vi.fn().mockResolvedValue(undefined), setToolPermission: vi.fn().mockResolvedValue(updatedDetail) }) + usePermissionGrantsStore.setState({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + status: 'idle', + error: undefined + }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -183,4 +196,42 @@ describe('ConnectorDetailView', () => { true ) }) + + it('discloses remembered scopes and links to Permissions from an expanded tool', async () => { + const onManagePermissions = vi.fn() + ;(window.api.permissions.list as ReturnType).mockResolvedValue({ + grants: [ + { + id: 'grant-1', + revision: 1, + family: 'connectors', + capabilityKind: 'mcp_tool', + capabilityLabel: 'Lookup gene', + scopeKind: 'project', + scopeLabel: 'Project: Research', + connectorServerId: 'ensembl', + connectorToolName: 'lookup_gene', + effectiveState: 'covered_by_policy' + } + ], + counts: { all: 1, global: 0, project: 1, session: 0 } + }) + await act(async () => { + root.render() + await Promise.resolve() + }) + const toolButton = Array.from( + document.body.querySelectorAll('button[aria-expanded]') + ).find((button) => button.textContent?.includes('lookup_gene')) + await act(async () => toolButton?.click()) + + expect(document.body.textContent).toContain('Remembered approvals: 1 · currently unnecessary') + expect(document.body.textContent).toContain('Project') + await act(async () => + Array.from(document.body.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Manage permissions')) + ?.click() + ) + expect(onManagePermissions).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/pages/settings/ConnectorDetailView.tsx b/src/renderer/src/pages/settings/ConnectorDetailView.tsx index 01680778e..e2d7a86a4 100644 --- a/src/renderer/src/pages/settings/ConnectorDetailView.tsx +++ b/src/renderer/src/pages/settings/ConnectorDetailView.tsx @@ -6,12 +6,15 @@ import type { ToolPermission } from '../../../../shared/settings' import { useSettingsStore } from '@/stores/settings-store' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' +import { Button } from '@/components/ui/button' import { ConnectorGlyph } from './connector-icons' import { SettingsToggle } from './SettingsLayout' import { ToolPermissionControl } from './ToolPermissionControl' type ConnectorDetailViewProps = { id: string + onManagePermissions?: () => void } // One label/value row in the Details section. @@ -31,7 +34,10 @@ const DetailRow = ({ // Detail view for one bundled connector: header (name + Featured badge + enable toggle + description), // a "Skip approvals" row, the per-tool permission list, and connector metadata under "Details". The // breadcrumb and back control live in the settings header, not here. -const ConnectorDetailView = ({ id }: ConnectorDetailViewProps): React.JSX.Element => { +const ConnectorDetailView = ({ + id, + onManagePermissions +}: ConnectorDetailViewProps): React.JSX.Element => { const setConnectorEnabled = useSettingsStore((state) => state.setConnectorEnabled) const setConnectorAutoAllow = useSettingsStore((state) => state.setConnectorAutoAllow) const setToolPermission = useSettingsStore((state) => state.setToolPermission) @@ -40,6 +46,8 @@ const ConnectorDetailView = ({ id }: ConnectorDetailViewProps): React.JSX.Elemen // enabled/autoAllow from the store (falling back to the initial detail) keeps the two header // switches live after a toggle, mirroring how SkillDetailView derives enabled from the store. const storeConnector = useSettingsStore((state) => state.connectors.find((c) => c.id === id)) + const permissionGrants = usePermissionGrantsStore((state) => state.grants) + const loadPermissionGrants = usePermissionGrantsStore((state) => state.load) const [detail, setDetail] = useState(null) // Ids of tools whose description is expanded. const [expanded, setExpanded] = useState>(new Set()) @@ -62,6 +70,10 @@ const ConnectorDetailView = ({ id }: ConnectorDetailViewProps): React.JSX.Elemen } }, [id]) + useEffect(() => { + void loadPermissionGrants() + }, [loadPermissionGrants]) + // Persist one tool's permission, folding the refreshed detail back into local state. const handleToolChange = async (toolId: string, permission: ToolPermission): Promise => { await setToolPermission(toolId, permission).then(setDetail) @@ -105,8 +117,8 @@ const ConnectorDetailView = ({ id }: ConnectorDetailViewProps): React.JSX.Elemen

Skip approvals

- Allow Claude to use every tool from this connector without showing an approval card each - time. + Allow the agent to use every tool from this connector without showing an approval card + each time.

Tools

-

What Claude can do with this connector

+

What the agent can do with this connector

{detail.tools.length === 0 ? (

This connector has no tools.

) : (
{detail.tools.map((tool) => { const isExpanded = expanded.has(tool.id) + const remembered = permissionGrants.filter( + (grant) => grant.connectorServerId === id && grant.connectorToolName === tool.method + ) return (
@@ -151,9 +166,47 @@ const ConnectorDetailView = ({ id }: ConnectorDetailViewProps): React.JSX.Elemen />
{isExpanded ? ( -

- {tool.description || 'No description provided for this tool.'} -

+
+

+ {tool.description || 'No description provided for this tool.'} +

+ {tool.permission === 'ask' ? ( +

Ask when no Session, Project, or Global permission applies.

+ ) : null} + {remembered.length > 0 ? ( +
+ + Remembered approvals: {remembered.length} + {tool.permission === 'block' + ? ' · currently blocked' + : tool.permission === 'allow' || autoAllow + ? ' · currently unnecessary' + : ''} + + {remembered.map((grant) => ( + + {grant.scopeKind === 'global' + ? 'Global' + : grant.scopeKind === 'project' + ? 'Project' + : 'Session'} + + ))} + +
+ ) : null} +
) : null}
) diff --git a/src/renderer/src/pages/settings/PermissionsPanel.render.test.tsx b/src/renderer/src/pages/settings/PermissionsPanel.render.test.tsx new file mode 100644 index 000000000..d21795b6c --- /dev/null +++ b/src/renderer/src/pages/settings/PermissionsPanel.render.test.tsx @@ -0,0 +1,260 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PermissionGrantSnapshot } from '../../../../shared/permission-grants' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' +import { PermissionsPanel } from './PermissionsPanel' + +let container: HTMLDivElement +let root: Root + +const snapshot: PermissionGrantSnapshot = { + version: 1, + incompleteStores: [], + grants: [ + { + id: 'grant-1', + revision: 1, + family: 'local_compute', + capabilityKind: 'execution', + capabilityLabel: 'Shell', + qualifierLabel: 'Specific input', + scopeKind: 'session', + scopeLabel: 'Session: Analyze samples', + coveredBy: 'project', + projectId: 'project-1', + sessionId: 'session-1' + } + ], + counts: { all: 1, global: 0, project: 0, session: 1 } +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + usePermissionGrantsStore.setState({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + incompleteStores: [], + status: 'idle', + error: undefined, + undo: undefined, + undoQueue: [], + isRestoring: false + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.innerHTML = '' +}) + +const setPermissionApi = (api: Partial): void => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { permissions: api } + }) +} + +describe('PermissionsPanel', () => { + it('keeps the default empty state visually quiet while exposing all scope counts', async () => { + setPermissionApi({ + list: vi.fn().mockResolvedValue({ + version: 1, + incompleteStores: [], + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 } + }) + }) + + await act(async () => root.render()) + + const trigger = document.body.querySelector( + '[aria-label="Filter permissions by scope"]' + ) + expect(trigger?.textContent).toContain('All (0)') + const emptyStatus = document.body.querySelector('[role="status"]') + expect(emptyStatus?.textContent).toContain('No remembered permissions for this scope.') + expect(emptyStatus?.classList.contains('sr-only')).toBe(true) + expect(document.body.querySelector('.border-dashed')).toBeNull() + }) + + it('uses the shared Settings danger banner for load failures', async () => { + setPermissionApi({ list: vi.fn().mockRejectedValue(new Error('Permission load failed')) }) + + await act(async () => root.render()) + + await vi.waitFor(() => { + const alert = document.body.querySelector('[role="alert"]') + expect(alert?.textContent).toContain('Permission load failed') + expect(alert?.className).toContain('border-danger-000/30') + expect(alert?.className).toContain('bg-danger-000/10') + expect(alert?.className).toContain('text-danger-000') + }) + }) + + it('renders a Project grant with its project label and compact family description', async () => { + const projectSnapshot: PermissionGrantSnapshot = { + version: 1, + incompleteStores: [], + grants: [ + { + id: 'project-grant', + revision: 1, + family: 'local_compute', + capabilityKind: 'execution', + capabilityLabel: 'python', + qualifierLabel: 'any call', + scopeKind: 'project', + scopeLabel: 'Project: Example project', + projectId: 'project-1' + } + ], + counts: { all: 1, global: 0, project: 1, session: 0 } + } + setPermissionApi({ list: vi.fn().mockResolvedValue(projectSnapshot) }) + + await act(async () => root.render()) + + expect(document.body.textContent).toContain('Local compute') + expect(document.body.textContent).toContain('Sandbox tools that run without preview') + expect(document.body.textContent).toContain('python') + expect(document.body.textContent).toContain('any call') + expect(document.body.textContent).toContain('Project: Example project') + expect(document.body.querySelector('[aria-label="Revoke python"]')).not.toBeNull() + expect(document.body.querySelector('h3')?.className).toContain('text-base') + const permissionRow = document.body.querySelector('[data-slot="permission-row"]') + expect(permissionRow?.className).toContain('min-h-11') + expect(permissionRow?.className).toContain('py-1.5') + const filterTrigger = document.body.querySelector( + '[aria-label="Filter permissions by scope"]' + ) + expect(filterTrigger?.parentElement?.className).toContain('mb-2') + }) + + it('renders grouped grants with a scope filter and per-row revoke control', async () => { + setPermissionApi({ list: vi.fn().mockResolvedValue(snapshot) }) + + await act(async () => root.render()) + + expect(document.body.textContent).toContain('All (1)') + expect(document.body.textContent).toContain('Local compute') + expect(document.body.textContent).toContain('Shell') + expect(document.body.textContent).toContain('Session: Analyze samples') + expect(document.body.textContent).toContain('Also allowed for this project') + expect(document.body.querySelector('[aria-label="Revoke Shell"]')).not.toBeNull() + }) + + it('opens the owning session from a session scope chip', async () => { + const onOpenSession = vi.fn() + setPermissionApi({ list: vi.fn().mockResolvedValue(snapshot) }) + + await act(async () => root.render()) + + const sessionButton = document.body.querySelector( + '[aria-label="Open Session: Analyze samples"]' + ) + expect(sessionButton).not.toBeNull() + expect(sessionButton?.className).toContain('hover:bg-accent') + expect(sessionButton?.className).toContain('focus-visible:ring-3') + + await act(async () => sessionButton?.click()) + + expect(onOpenSession).toHaveBeenCalledOnce() + expect(onOpenSession).toHaveBeenCalledWith('session-1') + }) + + it('revokes one exact revision and retains an app-root Undo receipt', async () => { + const revoke = vi.fn().mockResolvedValue({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-1', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + setPermissionApi({ list: vi.fn().mockResolvedValue(snapshot), revoke }) + await act(async () => root.render()) + + await act(async () => { + document.body.querySelector('[aria-label="Revoke Shell"]')?.click() + }) + + expect(revoke).toHaveBeenCalledWith({ grants: [{ id: 'grant-1', revision: 1 }] }) + expect(usePermissionGrantsStore.getState().undo).toMatchObject({ + token: 'undo-1', + message: 'Revoked Local compute · Shell' + }) + }) + + it('keeps the restored permission list visible when revoke persistence fails', async () => { + setPermissionApi({ + list: vi.fn().mockResolvedValue(snapshot), + revoke: vi.fn().mockRejectedValue(new Error('database locked')) + }) + await act(async () => root.render()) + + await act(async () => { + document.body.querySelector('[aria-label="Revoke Shell"]')?.click() + }) + + expect(document.body.querySelector('[role="alert"]')?.textContent).toContain('database locked') + expect(document.body.textContent).toContain('Shell') + expect(document.body.querySelector('[aria-label="Revoke Shell"]')).not.toBeNull() + }) + + it('shows Connector policy coverage and links to the owning Connector', async () => { + const onOpenConnector = vi.fn() + const connectorSnapshot: PermissionGrantSnapshot = { + version: 1, + incompleteStores: [], + grants: [ + { + id: 'connector-grant', + revision: 1, + family: 'connectors', + capabilityKind: 'mcp_tool', + capabilityLabel: 'Search', + scopeKind: 'global', + scopeLabel: 'Global', + connectorServerId: 'chemistry', + connectorToolName: 'search', + effectiveState: 'covered_by_policy', + policyHint: 'Allowed by Connector policy even without this permission' + } + ], + counts: { all: 1, global: 1, project: 0, session: 0 } + } + setPermissionApi({ list: vi.fn().mockResolvedValue(connectorSnapshot) }) + await act(async () => root.render()) + + const policyLink = Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Allowed by Connector policy') + ) + expect(policyLink?.className).toContain('focus-visible:ring-3') + await act(async () => policyLink?.click()) + expect(onOpenConnector).toHaveBeenCalledWith('chemistry') + }) + + it('names incomplete stores and disables bulk revoke while retaining row revoke', async () => { + setPermissionApi({ + list: vi.fn().mockResolvedValue({ + ...snapshot, + incompleteStores: ['sessions', 'connector_policy'] + }) + }) + await act(async () => root.render()) + + expect(document.body.textContent).toContain( + 'Session names, Connector policy could not be loaded' + ) + expect( + document.body.querySelector('[aria-label*="Revoke all"]')?.disabled + ).toBe(true) + expect( + document.body.querySelector('[aria-label="Revoke Shell"]')?.disabled + ).toBeFalsy() + }) +}) diff --git a/src/renderer/src/pages/settings/PermissionsPanel.tsx b/src/renderer/src/pages/settings/PermissionsPanel.tsx new file mode 100644 index 000000000..9d29e77fe --- /dev/null +++ b/src/renderer/src/pages/settings/PermissionsPanel.tsx @@ -0,0 +1,281 @@ +import { AlertTriangle, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import type { + PermissionGrantFamily, + PermissionGrantSnapshot, + PermissionGrantScope, + PermissionGrantView +} from '../../../../shared/permission-grants' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' +import { usePermissionGrantsStore } from '@/stores/permission-grants-store' +import { SettingsIconAction, SettingsSection } from './SettingsLayout' + +type ScopeFilter = 'all' | PermissionGrantScope['kind'] + +const FAMILY_DETAILS: ReadonlyArray<{ + id: PermissionGrantFamily + title: string + description: string +}> = [ + { + id: 'registry_writes', + title: 'Registry writes', + description: 'Agent and skill registry changes that persist across sessions' + }, + { + id: 'local_compute', + title: 'Local compute', + description: 'Sandbox tools that run without preview' + }, + { + id: 'connectors', + title: 'Connectors', + description: 'Approved MCP connector tools and calls' + }, + { + id: 'file_operations', + title: 'File operations', + description: 'Approved reads, writes, moves, and deletions' + }, + { + id: 'skills', + title: 'Skills', + description: 'Skill invocation and package operations' + }, + { + id: 'built_in_tools', + title: 'Built-in tools', + description: 'Application-owned tools that do not belong to another family' + } +] + +const FILTER_LABELS: Record = { + all: 'All', + global: 'Global', + project: 'Project', + session: 'Session' +} + +const INCOMPLETE_STORE_LABELS: Record = + { + projects: 'Project names', + sessions: 'Session names', + connector_policy: 'Connector policy' + } + +const PermissionRow = ({ + grant, + onRevoke, + onOpenConnector, + onOpenSession +}: { + grant: PermissionGrantView + onRevoke: (grant: PermissionGrantView) => void + onOpenConnector?: (serverId: string) => void + onOpenSession?: (sessionId: string) => void +}): React.JSX.Element => { + const sessionId = grant.scopeKind === 'session' ? grant.sessionId : undefined + const scopeClassName = + 'col-start-1 row-start-2 max-w-full justify-self-start truncate rounded-md bg-muted px-2 py-1 text-sm text-muted-foreground sm:col-start-2 sm:row-start-1 sm:max-w-80' + + return ( +
+
+
+ {grant.capabilityLabel} + {grant.qualifierLabel ? ( + {grant.qualifierLabel} + ) : null} +
+ {grant.coveredBy ? ( +

+ Also allowed {grant.coveredBy === 'global' ? 'globally' : 'for this project'} +

+ ) : null} + {grant.policyHint ? ( + + ) : null} +
+ {sessionId && onOpenSession ? ( + + ) : ( + + {grant.scopeLabel} + + )} + onRevoke(grant)} + /> +
+ ) +} + +const PermissionsPanel = ({ + onOpenConnector, + onOpenSession +}: { + onOpenConnector?: (serverId: string) => void + onOpenSession?: (sessionId: string) => void +}): React.JSX.Element => { + const grants = usePermissionGrantsStore((state) => state.grants) + const counts = usePermissionGrantsStore((state) => state.counts) + const incompleteStores = usePermissionGrantsStore((state) => state.incompleteStores) + const status = usePermissionGrantsStore((state) => state.status) + const error = usePermissionGrantsStore((state) => state.error) + const load = usePermissionGrantsStore((state) => state.load) + const revoke = usePermissionGrantsStore((state) => state.revoke) + const [filter, setFilter] = useState('all') + + useEffect(() => { + void load() + }, [load]) + + const visible = useMemo( + () => grants.filter((grant) => filter === 'all' || grant.scopeKind === filter), + [filter, grants] + ) + + return ( +
+
+ +
+ + {incompleteStores.length > 0 ? ( +
+

Some permission details are unavailable

+

+ {incompleteStores.map((store) => INCOMPLETE_STORE_LABELS[store]).join(', ')} could not + be loaded. Individual grants remain revocable; Revoke all is disabled until the complete + set is known. +

+
+ ) : null} + + {error ? ( +
+
+
+ +
+ ) : null} + +
+ {status === 'loading' && grants.length === 0 ? ( +
+ {[0, 1, 2].map((row) => ( +
+ ))} +
+ ) : visible.length === 0 ? ( +

+ No remembered permissions for this scope. +

+ ) : ( +
+ {FAMILY_DETAILS.map(({ id, title, description }) => { + const familyGrants = visible.filter((grant) => grant.family === id) + if (familyGrants.length === 0) return null + + return ( + 0} + className="whitespace-nowrap" + onClick={() => void revoke(familyGrants)} + > + Revoke all + + } + aria-labelledby={`permission-family-${id}`} + className="border-b border-border pb-4 last:border-b-0 last:pb-0" + headerClassName="flex-col gap-3 sm:flex-row sm:items-start" + actionClassName="self-start" + contentClassName="mt-1" + > +
+ {familyGrants.map((grant) => ( + void revoke([item])} + onOpenConnector={onOpenConnector} + onOpenSession={onOpenSession} + /> + ))} +
+
+ ) + })} +
+ )} +
+
+ ) +} + +export { PermissionsPanel } diff --git a/src/renderer/src/pages/settings/SettingsLayout.tsx b/src/renderer/src/pages/settings/SettingsLayout.tsx index be281e392..1bddcbd05 100644 --- a/src/renderer/src/pages/settings/SettingsLayout.tsx +++ b/src/renderer/src/pages/settings/SettingsLayout.tsx @@ -8,21 +8,27 @@ import { cn } from '@/lib/utils' type SettingsSectionProps = ComponentProps<'section'> & { title: string + titleId?: string // Optional decorative glyph rendered just before the title (e.g. a language logo). icon?: ReactNode description?: ReactNode action?: ReactNode separated?: boolean + headerClassName?: string + actionClassName?: string contentClassName?: string } // Keeps first-level settings groups aligned without turning every group into a card. const SettingsSection = ({ title, + titleId, icon, description, action, separated = false, + headerClassName, + actionClassName, contentClassName, className, children, @@ -33,9 +39,12 @@ const SettingsSection = ({ className={cn(separated && 'border-t border-border pt-5', className)} {...props} > -
+
-

+

{icon ? ( ) : null}

- {action ?
{action}
: null} + {action ?
{action}
: null}
{children}
diff --git a/src/renderer/src/pages/settings/SettingsPage.render.test.tsx b/src/renderer/src/pages/settings/SettingsPage.render.test.tsx index faf7350dd..7fa580725 100644 --- a/src/renderer/src/pages/settings/SettingsPage.render.test.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.render.test.tsx @@ -95,6 +95,24 @@ const installApi = (): void => { onState: vi.fn().mockReturnValue(() => {}), cancel: vi.fn() }, + permissions: { + list: vi.fn().mockResolvedValue({ + grants: [ + { + id: 'grant-1', + revision: 1, + family: 'connectors', + capabilityKind: 'mcp_tool', + capabilityLabel: 'Search articles', + scopeKind: 'project', + scopeLabel: 'Project: Oncology review', + projectId: 'project-1' + } + ], + counts: { all: 1, global: 0, project: 1, session: 0 } + }), + onChanged: vi.fn().mockReturnValue(() => undefined) + }, logs: { getPath: vi.fn().mockResolvedValue('/Users/x/Library/Logs/Open Science/main.log'), openFile: vi.fn().mockResolvedValue({ opened: true }), @@ -192,7 +210,7 @@ describe('SettingsPage layout', () => { expect(dialog?.className).toContain('overscroll-contain') // Left navigation grouped as Capabilities (Skills, Connectors, Specialists, Compute, Network) - // and Workspace (Model with its Agent sub-item, Runtimes, Storage, General). + // and Workspace (Model with its Agent sub-item, Permissions, Runtimes, Storage, General). const nav = document.body.querySelector('nav[aria-label="Settings"]') expect(nav).not.toBeNull() expect(nav?.className).toContain('bg-background') @@ -200,7 +218,7 @@ describe('SettingsPage layout', () => { expect(nav?.textContent).toContain('Capabilities') expect(nav?.textContent).toContain('Workspace') const navItems = nav?.querySelectorAll('li') ?? [] - expect(navItems).toHaveLength(10) + expect(navItems).toHaveLength(11) expect(navItems[0]?.textContent).toContain('Skills') expect(navItems[1]?.textContent).toContain('Connectors') expect(navItems[2]?.textContent).toContain('Specialists') @@ -208,9 +226,10 @@ describe('SettingsPage layout', () => { expect(navItems[4]?.textContent).toContain('Network') expect(navItems[5]?.textContent).toContain('Model') expect(navItems[6]?.textContent).toContain('Agent') - expect(navItems[7]?.textContent).toContain('Runtimes') - expect(navItems[8]?.textContent).toContain('Storage') - expect(navItems[9]?.textContent).toContain('General') + expect(navItems[7]?.textContent).toContain('Permissions') + expect(navItems[8]?.textContent).toContain('Runtimes') + expect(navItems[9]?.textContent).toContain('Storage') + expect(navItems[10]?.textContent).toContain('General') // Model is the default active panel. expect(nav?.querySelector('[aria-current="page"]')?.textContent).toContain('Model') @@ -239,6 +258,60 @@ describe('SettingsPage layout', () => { expect(addRow?.className).toContain('border-dashed') }) + it('opens the Permissions panel from Workspace navigation', async () => { + await act(async () => root.render()) + await act(async () => navButton('Permissions')?.click()) + + expect(document.body.querySelector('h2:not(.sr-only)')?.textContent).toBe('Permissions') + expect( + document.body.querySelector('[aria-label="Filter permissions by scope"]') + ?.textContent + ).toContain('All (1)') + expect(document.body.textContent).toContain('Search articles') + expect(document.body.textContent).toContain('Project: Oncology review') + expect( + document.body.querySelector('[data-slot="settings-content-scroll"]')?.className + ).toContain('overflow-y-auto') + expect( + document.body.querySelector('[aria-label="Filter permissions by scope"]') + ?.parentElement?.className + ).toContain('sticky') + }) + + it('forwards session scope navigation from the Permissions panel', async () => { + const onOpenSession = vi.fn() + vi.mocked(window.api.permissions.list).mockResolvedValue({ + version: 1, + incompleteStores: [], + grants: [ + { + id: 'session-grant', + revision: 1, + family: 'file_operations', + capabilityKind: 'file_operation', + capabilityLabel: 'Edit', + scopeKind: 'session', + scopeLabel: 'Session: Analyze samples', + projectId: 'project-1', + sessionId: 'session-1' + } + ], + counts: { all: 1, global: 0, project: 0, session: 1 } + }) + + await act(async () => + root.render() + ) + await act(async () => navButton('Permissions')?.click()) + await act(async () => + document.body + .querySelector('[aria-label="Open Session: Analyze samples"]') + ?.click() + ) + + expect(onOpenSession).toHaveBeenCalledWith('session-1') + }) + it('shows the agent framework on the Agent sub-panel', async () => { await act(async () => { root.render() diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 8e5377181..1005b99d4 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -3,6 +3,7 @@ import { ArrowRight, Cloud, Globe, + LockKeyhole, Maximize2, Minimize2, ScrollText, @@ -44,6 +45,7 @@ import { ConnectorsNavIcon } from './connector-icons' import { ComputePanel, type ComputeView } from './ComputePanel' import { ComputeAddForm } from './ComputeAddForm' import { ComputeHostDetail } from './ComputeHostDetail' +import { PermissionsPanel } from './PermissionsPanel' import { resolveVendorModelsUrl } from '../../../../shared/provider-registry' import { ProviderForm } from './ProviderForm' import { @@ -60,6 +62,7 @@ import { type SettingsPageProps = { open: boolean onClose: () => void + onOpenSession?: (sessionId: string) => void } // The model panel sub-view, driven by the settings navigation history so add/edit is a breadcrumb page. @@ -134,6 +137,7 @@ const SETTINGS_GROUPS: ReadonlyArray<{ label: string; panels: ReadonlyArray { +const SettingsPage = ({ open, onClose, onOpenSession }: SettingsPageProps): React.JSX.Element => { const providers = useSettingsStore((state) => state.providers) const agentFrameworkId = useSettingsStore((state) => state.agentFrameworkId) const frameworkEndpoints = useSettingsStore(selectFrameworkApiEndpoints) @@ -783,7 +787,7 @@ const SettingsPage = ({ open, onClose }: SettingsPageProps): React.JSX.Element =
-
+
{activePanel === 'skills' ? ( ) : activePanel === 'connectors' ? ( connectorsView.kind === 'detail' ? ( - + navigatePanel('permissions')} + /> ) : connectorsView.kind === 'add' ? ( + ) : activePanel === 'permissions' ? ( + + navigateConnectors( + customServers.some((server) => server.id === id) + ? { kind: 'edit', id } + : { kind: 'detail', id } + ) + } + /> ) : activePanel === 'runtimes' ? ( { expect(document.body.querySelectorAll('[role="radio"]')).toHaveLength(3) expect(radio('Always allow')?.getAttribute('aria-checked')).toBe('false') - expect(radio('Ask each time')?.getAttribute('aria-checked')).toBe('false') + expect(radio('Require approval')?.getAttribute('aria-checked')).toBe('false') expect(radio('Block')?.getAttribute('aria-checked')).toBe('true') }) @@ -69,7 +69,7 @@ describe('ToolPermissionControl', () => { expect(onChange).toHaveBeenCalledWith('block') }) - it('calls onChange("ask") when Ask each time is clicked', () => { + it('calls onChange("ask") when Require approval is clicked', () => { const onChange = vi.fn() act(() => { root.render( @@ -81,7 +81,7 @@ describe('ToolPermissionControl', () => { ) }) - act(() => radio('Ask each time')?.click()) + act(() => radio('Require approval')?.click()) expect(onChange).toHaveBeenCalledWith('ask') }) }) diff --git a/src/renderer/src/pages/settings/ToolPermissionControl.tsx b/src/renderer/src/pages/settings/ToolPermissionControl.tsx index 4ab6dccd9..d7c1f5cb1 100644 --- a/src/renderer/src/pages/settings/ToolPermissionControl.tsx +++ b/src/renderer/src/pages/settings/ToolPermissionControl.tsx @@ -9,8 +9,8 @@ type ToolPermissionControlProps = { label: string // accessible group label, e.g. "Permission for list_marts" } -// A 3-segment permission pill: "Always allow / Ask each time / Block". Each segment shows a hover -// tooltip. "Ask each time" (the secure default) requires per-call approval before the tool runs. +// A 3-segment permission pill: "Always allow / Require approval / Block". The middle policy asks +// only when the Broker cannot resolve an existing Global, Project, or Session approval. export function ToolPermissionControl({ value, onChange, @@ -53,14 +53,14 @@ export function ToolPermissionControl({ type="button" role="radio" aria-checked={value === 'ask'} - aria-label="Ask each time" + aria-label="Require approval" onClick={() => onChange('ask')} className={segment(value === 'ask', false)} > - Ask each time + Require approval diff --git a/src/renderer/src/pages/settings/settings-navigation.ts b/src/renderer/src/pages/settings/settings-navigation.ts index 6ca5dce84..4afe2f507 100644 --- a/src/renderer/src/pages/settings/settings-navigation.ts +++ b/src/renderer/src/pages/settings/settings-navigation.ts @@ -7,6 +7,7 @@ export type SettingsPanelId = | 'connectors' | 'specialists' | 'compute' + | 'permissions' | 'general' | 'storage' | 'network' diff --git a/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.interaction.test.tsx b/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.interaction.test.tsx index cf3a19fd0..4ad416841 100644 --- a/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.interaction.test.tsx +++ b/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.interaction.test.tsx @@ -298,12 +298,11 @@ describe('ComposerAgentControlsMenu', () => { ) }) - expect(container.textContent).toContain('Allowed this conversation') + expect(container.textContent).toContain('Allowed this session') expect(container.textContent).toContain('git status') const revokeButton = Array.from(container.querySelectorAll('button')).find( - (candidate) => - candidate.getAttribute('aria-label') === 'Revoke conversation grant for git status' + (candidate) => candidate.getAttribute('aria-label') === 'Revoke session grant for git status' ) if (!revokeButton) throw new Error('revoke button not found') @@ -335,7 +334,7 @@ describe('ComposerAgentControlsMenu', () => { ) }) - expect(container.textContent).toContain('Allowed this conversation') + expect(container.textContent).toContain('Allowed this session') expect(container.textContent).toContain('Notebook REPL (Python)') } ) @@ -360,7 +359,7 @@ describe('ComposerAgentControlsMenu', () => { }) const clearButton = Array.from(container.querySelectorAll('button')).find( - (candidate) => candidate.getAttribute('aria-label') === 'Clear all conversation grants' + (candidate) => candidate.getAttribute('aria-label') === 'Clear all session grants' ) if (!clearButton) throw new Error('clear button not found') @@ -541,11 +540,10 @@ describe('ComposerAgentControlsMenu', () => { ).toBe(true) const clearButton = Array.from(container.querySelectorAll('button')).find( - (candidate) => candidate.getAttribute('aria-label') === 'Clear all conversation grants' + (candidate) => candidate.getAttribute('aria-label') === 'Clear all session grants' ) const revokeButton = Array.from(container.querySelectorAll('button')).find( - (candidate) => - candidate.getAttribute('aria-label') === 'Revoke conversation grant for git status' + (candidate) => candidate.getAttribute('aria-label') === 'Revoke session grant for git status' ) expect(clearButton?.disabled).toBe(true) expect(revokeButton?.disabled).toBe(true) @@ -584,11 +582,11 @@ describe('ComposerAgentControlsMenu', () => { ).toBe(true) const clearButton = Array.from(container.querySelectorAll('button')).find( - (candidate) => candidate.getAttribute('aria-label') === 'Clear all conversation grants' + (candidate) => candidate.getAttribute('aria-label') === 'Clear all session grants' ) const revokeButton = Array.from(container.querySelectorAll('button')).find( (candidate) => - candidate.getAttribute('aria-label') === 'Revoke conversation grant for Shell commands' + candidate.getAttribute('aria-label') === 'Revoke session grant for Shell commands' ) expect(clearButton?.disabled).toBe(false) expect(revokeButton?.disabled).toBe(false) diff --git a/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.tsx b/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.tsx index f9cb7d3f3..3007c9ec3 100644 --- a/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.tsx +++ b/src/renderer/src/pages/workspace/ComposerAgentControlsMenu.tsx @@ -217,7 +217,7 @@ const ComposerAgentControlsMenu = ({ {hasGrants ? ( {grants!.length} @@ -344,13 +344,13 @@ const ComposerAgentControlsMenu = ({
- Allowed this conversation + Allowed this session {/* One-click clear for a long session; swallow the event so the menu stays open. */} {hasScopePicker ? ( <> @@ -730,6 +829,11 @@ const PermissionApprovalControls = ({ Deny
+
) } diff --git a/src/renderer/src/pages/workspace/PermissionScopeConfirmationDialog.tsx b/src/renderer/src/pages/workspace/PermissionScopeConfirmationDialog.tsx new file mode 100644 index 000000000..5e4b80325 --- /dev/null +++ b/src/renderer/src/pages/workspace/PermissionScopeConfirmationDialog.tsx @@ -0,0 +1,91 @@ +import { AlertDialog } from 'radix-ui' + +import { Button } from '@/components/ui/button' +import { + dialogDescriptionClassName, + dialogFooterClassName, + dialogOverlayClassName, + dialogPanelClassName, + dialogTitleClassName +} from '@/components/ui/dialog-chrome' +import { useRetainedDialogValue } from '@/components/ui/use-retained-dialog-value' + +type BroadPermissionScope = 'project' | 'global' + +type PermissionScopeConfirmation = { + scope: BroadPermissionScope + subject: string + codeExecution: boolean +} + +type PermissionScopeConfirmationDialogProps = { + confirmation: PermissionScopeConfirmation | undefined + onCancel: () => void + onConfirm: () => void +} + +const PermissionScopeConfirmationDialog = ({ + confirmation, + onCancel, + onConfirm +}: PermissionScopeConfirmationDialogProps): React.JSX.Element => { + const retainedConfirmation = useRetainedDialogValue(confirmation) + const scope = retainedConfirmation?.scope ?? 'project' + const subject = retainedConfirmation?.subject ?? 'this permission' + const isProject = scope === 'project' + const scopePhrase = isProject ? 'for this project' : 'globally' + const coveragePhrase = isProject + ? 'for every session in this project' + : 'for every session in every project' + const effect = retainedConfirmation?.codeExecution + ? `Code will run without preview ${coveragePhrase}.` + : `Matching actions can run without another approval ${coveragePhrase}.` + + return ( + { + if (!open) onCancel() + }} + > + + + + + Allow {subject} {scopePhrase}? + + + {effect} You can revoke it in{' '} + Settings → Permissions. + +
+ + + + + + +
+
+
+
+ ) +} + +export { + PermissionScopeConfirmationDialog, + type BroadPermissionScope, + type PermissionScopeConfirmation +} diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx index d962dc74f..9404bcd8d 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.render.test.tsx @@ -326,6 +326,57 @@ describe('WorkspaceMessageScroller loading render', () => { expect(html).toContain('TXT') }) + it('renders one generated card for legacy and native ids of the same Artifact Version', async () => { + const html = await renderScroller( + createSession({ + status: 'idle', + messages: [ + createMessage({ id: 'prompt-1' }), + createMessage({ + id: 'reply-1', + role: 'agent', + content: 'Saved the script', + artifactIds: ['session-1:reply-1:test_script.r', 'artifact-version-1'] + }) + ], + artifacts: [ + { + id: 'session-1:reply-1:test_script.r', + artifactId: 'artifact-lineage-1', + versionId: 'artifact-version-1', + versionNumber: 1, + kind: 'managed-file', + path: '/managed/reply-1/test_script.r', + name: 'test_script.r', + mimeType: 'text/x-r', + size: 227, + mtimeMs: 1710000000100 + }, + { + id: 'artifact-version-1', + artifactId: 'artifact-lineage-1', + versionId: 'artifact-version-1', + versionNumber: 1, + kind: 'managed-file', + path: '/managed/.provenance/artifact-lineage-1/artifact-version-1/content', + name: 'test_script.r', + mimeType: 'text/x-r', + size: 227, + mtimeMs: 1710000000101, + sha256: 'a'.repeat(64) + } + ] + }) + ) + + expect(html).toContain('GENERATED · 1') + expect(html.match(/aria-label="Preview generated file test_script\.r"/g)).toHaveLength(1) + expect(html).toContain( + 'title="/managed/.provenance/artifact-lineage-1/artifact-version-1/content"' + ) + expect(html).not.toContain('title="/managed/reply-1/test_script.r"') + }) + it('renders image cards and a more button for larger generated artifact sets without file urls', async () => { const artifacts = Array.from({ length: 7 }, (_, index) => ({ id: `artifact-${index + 1}`, diff --git a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx index dbfd0e539..eea802834 100644 --- a/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx +++ b/src/renderer/src/pages/workspace/WorkspaceMessageScroller.tsx @@ -69,10 +69,24 @@ const getMessageArtifacts = ( if (!message.artifactIds || !session.artifacts) return [] const artifactsById = new Map(session.artifacts.map((artifact) => [artifact.id, artifact])) + const artifactsByLogicalId = new Map() + + for (const artifactId of message.artifactIds) { + const artifact = artifactsById.get(artifactId) + if (!artifact) continue + + const logicalId = artifact.versionId + ? `version:${artifact.versionId}` + : `artifact:${artifact.id}` + const current = artifactsByLogicalId.get(logicalId) + const isNativeVersion = Boolean(artifact.versionId && artifact.id === artifact.versionId) + const currentIsNativeVersion = Boolean(current?.versionId && current.id === current.versionId) + if (!current || (isNativeVersion && !currentIsNativeVersion)) { + artifactsByLogicalId.set(logicalId, artifact) + } + } - return message.artifactIds - .map((artifactId) => artifactsById.get(artifactId)) - .filter((artifact): artifact is MessageArtifact => Boolean(artifact)) + return Array.from(artifactsByLogicalId.values()) } // Sends an app-managed generated file to the preview workbench instead of opening it locally. diff --git a/src/renderer/src/stores/permission-grants-store.test.ts b/src/renderer/src/stores/permission-grants-store.test.ts new file mode 100644 index 000000000..d86e7dea5 --- /dev/null +++ b/src/renderer/src/stores/permission-grants-store.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PermissionGrantSnapshot } from '../../../shared/permission-grants' +import { usePermissionGrantsStore } from './permission-grants-store' + +const snapshot: PermissionGrantSnapshot = { + version: 1, + incompleteStores: [], + grants: [ + { + id: 'grant-1', + revision: 1, + family: 'local_compute', + capabilityKind: 'execution', + capabilityLabel: 'Shell', + scopeKind: 'global', + scopeLabel: 'Global' + } + ], + counts: { all: 1, global: 1, project: 0, session: 0 } +} + +const setPermissionApi = (api: Partial): void => { + ;(globalThis as unknown as { window: { api: { permissions: unknown } } }).window = { + api: { permissions: api } + } as never +} + +beforeEach(() => { + usePermissionGrantsStore.setState({ + ...snapshot, + status: 'ready', + error: undefined, + undo: undefined, + undoQueue: [], + isRestoring: false + }) +}) + +describe('permission grants store', () => { + it('optimistically removes a row and records the durable Undo receipt', async () => { + let resolveMutation: ((result: unknown) => void) | undefined + setPermissionApi({ + revoke: vi.fn( + () => + new Promise((resolve) => { + resolveMutation = resolve + }) + ) as never + }) + + const pending = usePermissionGrantsStore.getState().revoke(snapshot.grants) + expect(usePermissionGrantsStore.getState().grants).toEqual([]) + + resolveMutation?.({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-1', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + await pending + + expect(usePermissionGrantsStore.getState().undo).toMatchObject({ + token: 'undo-1', + message: 'Revoked Local compute · Shell' + }) + }) + + it('reports the exact successful subset when a batch has a revision conflict', async () => { + const second = { ...snapshot.grants[0], id: 'grant-2', revision: 2 } + setPermissionApi({ + revoke: vi.fn().mockResolvedValue({ + ...snapshot, + conflicts: [{ id: 'grant-2', reason: 'stale' }], + receipt: { undoToken: 'undo-partial', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + }) + + await usePermissionGrantsStore.getState().revoke([snapshot.grants[0], second]) + + expect(usePermissionGrantsStore.getState().undo?.message).toBe( + 'Revoked Local compute · Shell; 1 changed before it could be revoked' + ) + }) + + it('rolls the optimistic removal back when persistence fails', async () => { + setPermissionApi({ revoke: vi.fn().mockRejectedValue(new Error('database locked')) }) + + await usePermissionGrantsStore.getState().revoke(snapshot.grants) + + expect(usePermissionGrantsStore.getState()).toMatchObject({ + grants: snapshot.grants, + counts: snapshot.counts, + status: 'error', + error: 'database locked' + }) + }) + + it('does not resurrect a concurrently revoked grant when another revoke fails', async () => { + const second = { ...snapshot.grants[0], id: 'grant-2', capabilityLabel: 'Python' } + usePermissionGrantsStore.setState({ + ...snapshot, + grants: [snapshot.grants[0], second], + counts: { all: 2, global: 2, project: 0, session: 0 } + }) + let rejectFirst: ((error: Error) => void) | undefined + let resolveSecond: ((result: unknown) => void) | undefined + setPermissionApi({ + list: vi.fn().mockResolvedValue({ + ...snapshot, + version: 2, + grants: [snapshot.grants[0]], + counts: { all: 1, global: 1, project: 0, session: 0 } + }), + revoke: vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }) + ) as never + }) + + const failed = usePermissionGrantsStore.getState().revoke([snapshot.grants[0]]) + const successful = usePermissionGrantsStore.getState().revoke([second]) + + resolveSecond?.({ + ...snapshot, + version: 2, + grants: [snapshot.grants[0]], + counts: { all: 1, global: 1, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-2', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + await successful + rejectFirst?.(new Error('database locked')) + await failed + + expect(usePermissionGrantsStore.getState()).toMatchObject({ + version: 2, + grants: [snapshot.grants[0]], + counts: { all: 1, global: 1, project: 0, session: 0 }, + status: 'error', + error: 'database locked' + }) + }) + + it('keeps an optimistic removal hidden when a list response races the revoke', async () => { + let resolveMutation: ((result: unknown) => void) | undefined + setPermissionApi({ + list: vi.fn().mockResolvedValue(snapshot), + revoke: vi.fn( + () => + new Promise((resolve) => { + resolveMutation = resolve + }) + ) as never + }) + + const revoke = usePermissionGrantsStore.getState().revoke(snapshot.grants) + await usePermissionGrantsStore.getState().load() + expect(usePermissionGrantsStore.getState().grants).toEqual([]) + + resolveMutation?.({ + ...snapshot, + version: 2, + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-race', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + await revoke + + expect(usePermissionGrantsStore.getState()).toMatchObject({ version: 2, grants: [] }) + }) + + it('ignores an older successful revoke response that arrives after a newer snapshot', async () => { + const second = { ...snapshot.grants[0], id: 'grant-2', capabilityLabel: 'Python' } + usePermissionGrantsStore.setState({ + ...snapshot, + grants: [snapshot.grants[0], second], + counts: { all: 2, global: 2, project: 0, session: 0 } + }) + let resolveFirst: ((result: unknown) => void) | undefined + let resolveSecond: ((result: unknown) => void) | undefined + setPermissionApi({ + revoke: vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }) + ) as never + }) + + const first = usePermissionGrantsStore.getState().revoke([snapshot.grants[0]]) + const secondRevoke = usePermissionGrantsStore.getState().revoke([second]) + resolveSecond?.({ + ...snapshot, + version: 3, + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-new', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + await secondRevoke + resolveFirst?.({ + ...snapshot, + version: 2, + grants: [second], + counts: { all: 1, global: 1, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-old', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + await first + + expect(usePermissionGrantsStore.getState()).toMatchObject({ version: 3, grants: [] }) + }) + + it('restores a receipt once and clears the snackbar state', async () => { + const restore = vi.fn().mockResolvedValue({ ...snapshot, conflicts: [] }) + setPermissionApi({ restore }) + usePermissionGrantsStore.setState({ + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + undo: { token: 'undo-1', expiresAt: Date.now() + 8_000, message: 'Revoked Shell' } + }) + + await usePermissionGrantsStore.getState().restore() + + expect(restore).toHaveBeenCalledWith({ undoToken: 'undo-1' }) + expect(usePermissionGrantsStore.getState()).toMatchObject({ + grants: snapshot.grants, + undo: undefined, + isRestoring: false + }) + }) + + it('consumes consecutive revoke receipts FIFO while each receipt is still valid', async () => { + const second = { ...snapshot.grants[0], id: 'grant-2', capabilityLabel: 'Python' } + usePermissionGrantsStore.setState({ + ...snapshot, + grants: [snapshot.grants[0], second], + counts: { all: 2, global: 2, project: 0, session: 0 } + }) + setPermissionApi({ + revoke: vi + .fn() + .mockResolvedValueOnce({ + ...snapshot, + grants: [second], + counts: { all: 1, global: 1, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-1', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + .mockResolvedValueOnce({ + ...snapshot, + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [], + receipt: { undoToken: 'undo-2', expiresAt: Date.now() + 8_000, revokedCount: 1 } + }) + }) + + await usePermissionGrantsStore.getState().revoke([snapshot.grants[0]]) + await usePermissionGrantsStore.getState().revoke([second]) + + expect(usePermissionGrantsStore.getState().undo?.token).toBe('undo-1') + expect(usePermissionGrantsStore.getState().undoQueue.map((item) => item.token)).toEqual([ + 'undo-2' + ]) + usePermissionGrantsStore.getState().dismissUndo() + expect(usePermissionGrantsStore.getState().undo?.token).toBe('undo-2') + }) + + it('keeps a failed restore actionable as Retry', async () => { + setPermissionApi({ restore: vi.fn().mockRejectedValue(new Error('database locked')) }) + usePermissionGrantsStore.setState({ + undo: { token: 'undo-1', expiresAt: Date.now() + 8_000, message: 'Revoked Shell' } + }) + + await usePermissionGrantsStore.getState().restore() + + expect(usePermissionGrantsStore.getState().undo).toMatchObject({ + token: 'undo-1', + retry: true, + message: "Couldn't restore permission. Retry." + }) + }) + + it('explains when an owner disappeared before restore', async () => { + setPermissionApi({ + restore: vi.fn().mockResolvedValue({ + ...snapshot, + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 }, + conflicts: [{ id: 'grant-1', reason: 'target-unavailable' }] + }) + }) + usePermissionGrantsStore.setState({ + undo: { token: 'undo-1', expiresAt: Date.now() + 8_000, message: 'Revoked Shell' } + }) + + await usePermissionGrantsStore.getState().restore() + + expect(usePermissionGrantsStore.getState().undo).toMatchObject({ + canRestore: false, + message: "Couldn't restore permission: owner no longer exists" + }) + }) + + it('does not let an older list response overwrite a newer snapshot', async () => { + usePermissionGrantsStore.setState({ ...snapshot, version: 2 }) + setPermissionApi({ + list: vi.fn().mockResolvedValue({ + ...snapshot, + version: 1, + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 } + }) + }) + + await usePermissionGrantsStore.getState().load() + + expect(usePermissionGrantsStore.getState()).toMatchObject({ + version: 2, + grants: snapshot.grants + }) + }) +}) diff --git a/src/renderer/src/stores/permission-grants-store.ts b/src/renderer/src/stores/permission-grants-store.ts new file mode 100644 index 000000000..82bb933aa --- /dev/null +++ b/src/renderer/src/stores/permission-grants-store.ts @@ -0,0 +1,342 @@ +import { create } from 'zustand' + +import type { + PermissionGrantMutationView, + PermissionGrantSnapshot, + PermissionGrantView +} from '../../../shared/permission-grants' + +const EMPTY_SNAPSHOT: PermissionGrantSnapshot = { + version: 0, + incompleteStores: [], + grants: [], + counts: { all: 0, global: 0, project: 0, session: 0 } +} + +type PermissionUndo = { + token: string + expiresAt: number + message: string + canRestore?: boolean + retry?: boolean +} + +type PermissionGrantsStore = PermissionGrantSnapshot & { + status: 'idle' | 'loading' | 'ready' | 'error' + error?: string + undo?: PermissionUndo + undoQueue: PermissionUndo[] + isRestoring: boolean + load: () => Promise + revoke: (grants: PermissionGrantView[]) => Promise + restore: (token?: string) => Promise + dismissUndo: (token?: string) => void + listen: () => () => void +} + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Permission grants could not be updated.' + +const FAMILY_LABELS: Record = { + registry_writes: 'Registry writes', + local_compute: 'Local compute', + connectors: 'Connectors', + file_operations: 'File operations', + skills: 'Skills', + built_in_tools: 'Built-in tools' +} + +const nextUndoState = ( + queue: PermissionUndo[] +): Pick => { + const remaining = queue.filter((item) => item.expiresAt > Date.now()) + const [undo, ...undoQueue] = remaining + return { undo, undoQueue } +} + +const mutationState = ( + result: PermissionGrantMutationView +): Pick< + PermissionGrantsStore, + 'version' | 'incompleteStores' | 'grants' | 'counts' | 'status' | 'error' +> => ({ + version: result.version ?? 0, + incompleteStores: result.incompleteStores ?? [], + grants: result.grants, + counts: result.counts, + status: 'ready', + error: undefined +}) + +const snapshotFromState = (state: PermissionGrantSnapshot): PermissionGrantSnapshot => ({ + version: state.version, + incompleteStores: state.incompleteStores, + grants: state.grants, + counts: state.counts +}) + +const withoutGrants = ( + snapshot: PermissionGrantSnapshot, + removed: PermissionGrantView[] +): PermissionGrantSnapshot => { + const ids = new Set(removed.map((grant) => grant.id)) + const grants = snapshot.grants.filter((grant) => !ids.has(grant.id)) + return { + version: snapshot.version, + incompleteStores: snapshot.incompleteStores, + grants, + counts: { + all: grants.length, + global: grants.filter((grant) => grant.scopeKind === 'global').length, + project: grants.filter((grant) => grant.scopeKind === 'project').length, + session: grants.filter((grant) => grant.scopeKind === 'session').length + } + } +} + +const withRestoredGrants = ( + snapshot: PermissionGrantSnapshot, + restored: PermissionGrantView[] +): PermissionGrantSnapshot => { + const byId = new Map(snapshot.grants.map((grant) => [grant.id, grant])) + for (const grant of restored) { + if (!byId.has(grant.id)) byId.set(grant.id, grant) + } + const grants = Array.from(byId.values()) + return { + version: snapshot.version, + incompleteStores: snapshot.incompleteStores, + grants, + counts: { + all: grants.length, + global: grants.filter((grant) => grant.scopeKind === 'global').length, + project: grants.filter((grant) => grant.scopeKind === 'project').length, + session: grants.filter((grant) => grant.scopeKind === 'session').length + } + } +} + +let latestLoadRequest = 0 +let revokeRequestSequence = 0 +const pendingRevocations = new Map>() + +const pendingGrantIds = (): Set => + new Set(Array.from(pendingRevocations.values()).flatMap((ids) => Array.from(ids))) + +const withoutPendingRevocations = (snapshot: PermissionGrantSnapshot): PermissionGrantSnapshot => { + const ids = pendingGrantIds() + return ids.size === 0 + ? snapshot + : withoutGrants( + snapshot, + snapshot.grants.filter((grant) => ids.has(grant.id)) + ) +} + +const applyAuthoritativeSnapshot = ( + state: PermissionGrantSnapshot, + incoming: PermissionGrantSnapshot +): PermissionGrantSnapshot => + withoutPendingRevocations(incoming.version < state.version ? snapshotFromState(state) : incoming) + +const undoItems = (state: Pick): PermissionUndo[] => + [state.undo, ...state.undoQueue].filter((item): item is PermissionUndo => + Boolean(item && item.expiresAt > Date.now()) + ) + +const withoutUndoToken = ( + state: Pick, + token: string +): Pick => + nextUndoState(undoItems(state).filter((item) => item.token !== token)) + +const usePermissionGrantsStore = create((set, get) => ({ + ...EMPTY_SNAPSHOT, + status: 'idle', + undoQueue: [], + isRestoring: false, + + load: async () => { + const requestId = ++latestLoadRequest + set({ status: 'loading', error: undefined }) + try { + const snapshot = await window.api.permissions.list() + if (requestId !== latestLoadRequest) return + const normalized = { + ...snapshot, + version: snapshot.version ?? 0, + incompleteStores: snapshot.incompleteStores ?? [] + } + set((state) => + normalized.version < state.version + ? { status: 'ready', error: undefined } + : { + ...withoutPendingRevocations(normalized), + status: 'ready', + error: undefined + } + ) + } catch (error) { + if (requestId !== latestLoadRequest) return + set({ status: 'error', error: errorMessage(error) }) + } + }, + + revoke: async (grants) => { + if (grants.length === 0) return + const requestId = ++revokeRequestSequence + pendingRevocations.set(requestId, new Set(grants.map((grant) => grant.id))) + const previous: PermissionGrantSnapshot = { + version: get().version, + incompleteStores: get().incompleteStores, + grants: get().grants, + counts: get().counts + } + set({ ...withoutGrants(previous, grants), error: undefined }) + try { + const result = await window.api.permissions.revoke({ + grants: grants.map(({ id, revision }) => ({ id, revision })) + }) + const revokedCount = result.receipt?.revokedCount ?? 0 + const conflictCount = result.conflicts.length + const conflictIds = new Set(result.conflicts.map((conflict) => conflict.id)) + const revoked = grants.filter((grant) => !conflictIds.has(grant.id)) + const oneFamily = new Set(revoked.map((grant) => grant.family)).size === 1 + const message = `${ + revokedCount === 1 + ? `Revoked ${FAMILY_LABELS[revoked[0].family]} · ${revoked[0].capabilityLabel}` + : `Revoked ${revokedCount} permissions${oneFamily && revoked[0] ? ` in ${FAMILY_LABELS[revoked[0].family]}` : ''}` + }${conflictCount > 0 ? `; ${conflictCount} changed before it could be revoked` : ''}` + pendingRevocations.delete(requestId) + set((state) => { + const current = + state.undo?.expiresAt && state.undo.expiresAt > Date.now() ? state.undo : undefined + const queued = state.undoQueue.filter((item) => item.expiresAt > Date.now()) + const next = result.receipt + ? { + token: result.receipt.undoToken, + expiresAt: result.receipt.expiresAt, + message + } + : undefined + const authoritative = applyAuthoritativeSnapshot(state, { + version: result.version ?? 0, + incompleteStores: result.incompleteStores ?? [], + grants: result.grants, + counts: result.counts + }) + return { + ...authoritative, + status: 'ready', + error: undefined, + undoQueue: current && next ? [...queued, next] : queued, + undo: current ?? next, + ...(revokedCount === 0 && conflictCount > 0 + ? { error: 'The selected permission changed before it could be revoked.' } + : {}) + } + }) + } catch (error) { + pendingRevocations.delete(requestId) + try { + const snapshot = await window.api.permissions.list() + set((state) => ({ + ...applyAuthoritativeSnapshot(state, { + ...snapshot, + version: snapshot.version ?? 0, + incompleteStores: snapshot.incompleteStores ?? [] + }), + status: 'error', + error: errorMessage(error) + })) + return + } catch { + // If the authoritative refresh also fails, only roll back against the request's unchanged + // starting version. A newer local snapshot may already reflect another actor's revoke. + } + set((state) => { + const current: PermissionGrantSnapshot = { + version: state.version, + incompleteStores: state.incompleteStores, + grants: state.grants, + counts: state.counts + } + // A newer server snapshot already includes the authoritative outcome of any concurrent + // mutation. Only merge this request's optimistic removals back while its starting version is + // still current, otherwise a stale rollback can resurrect a grant another request revoked. + const rollback = + state.version === previous.version ? withRestoredGrants(current, grants) : current + return { + ...withoutPendingRevocations(rollback), + status: 'error', + error: errorMessage(error) + } + }) + } + }, + + restore: async (token) => { + const undo = undoItems(get()).find((item) => item.token === (token ?? get().undo?.token)) + if (!undo || undo.canRestore === false || undo.expiresAt <= Date.now()) { + if (token) set((state) => withoutUndoToken(state, token)) + else set((state) => nextUndoState(state.undoQueue)) + return + } + set({ isRestoring: true, error: undefined }) + try { + const result = await window.api.permissions.restore({ undoToken: undo.token }) + const targetUnavailable = result.conflicts.filter( + (conflict) => conflict.reason === 'target-unavailable' + ).length + set((state) => + targetUnavailable > 0 + ? { + ...applyAuthoritativeSnapshot(state, mutationState(result)), + ...(() => { + const items = undoItems(state).map((item) => + item.token === undo.token + ? { + token: undo.token, + expiresAt: Date.now() + 5_000, + message: `Couldn't restore ${targetUnavailable === 1 ? 'permission' : `${targetUnavailable} permissions`}: owner no longer exists`, + canRestore: false + } + : item + ) + return nextUndoState(items) + })(), + isRestoring: false + } + : { + ...applyAuthoritativeSnapshot(state, mutationState(result)), + ...withoutUndoToken(state, undo.token), + status: 'ready', + error: undefined, + isRestoring: false + } + ) + } catch (error) { + set((state) => { + const items = undoItems(state).map((item) => + item.token === undo.token + ? { ...undo, message: "Couldn't restore permission. Retry.", retry: true } + : item + ) + return { + ...nextUndoState(items), + status: 'error', + error: errorMessage(error), + isRestoring: false + } + }) + } + }, + + dismissUndo: (token) => + set((state) => (token ? withoutUndoToken(state, token) : nextUndoState(state.undoQueue))), + + listen: () => window.api.permissions?.onChanged?.(() => void get().load()) ?? (() => undefined) +})) + +export { usePermissionGrantsStore } +export type { PermissionUndo } diff --git a/src/renderer/src/stores/settings-store.test.ts b/src/renderer/src/stores/settings-store.test.ts index 73319f945..b13a7487d 100644 --- a/src/renderer/src/stores/settings-store.test.ts +++ b/src/renderer/src/stores/settings-store.test.ts @@ -1303,8 +1303,8 @@ describe('settings store: connectors slice', () => { useSettingsStore.getState().enqueueApproval(request) expect(useSettingsStore.getState().pendingApprovals).toHaveLength(1) - await useSettingsStore.getState().respondApproval('req-1', 'allow') - expect(api.respondConnectorApproval).toHaveBeenCalledWith({ id: 'req-1', decision: 'allow' }) + await useSettingsStore.getState().respondApproval('req-1', 'once') + expect(api.respondConnectorApproval).toHaveBeenCalledWith({ id: 'req-1', decision: 'once' }) expect(useSettingsStore.getState().pendingApprovals).toEqual([]) }) }) diff --git a/src/shared/compute.ts b/src/shared/compute.ts index e9c83412f..040b082aa 100644 --- a/src/shared/compute.ts +++ b/src/shared/compute.ts @@ -96,9 +96,9 @@ export type ComputeCallError = { retry_after_user_action: boolean } -// The three approval scopes available in the compute approval card. 'deny' is the negative outcome. -// No 'global' scope per design.md §6 — deliberately omitted. -export type ComputeApprovalScope = 'once' | 'conversation' | 'project' +// Broker-owned durable scopes. `conversation` remains the wire value for compatibility, but is +// presented and persisted as a Session grant; Agent ACP adapters still receive only allow-once. +export type ComputeApprovalScope = 'once' | 'conversation' | 'project' | 'global' export type ComputeApprovalDecision = ComputeApprovalScope | 'deny' // Approval request broadcast from main to the renderer for a compute:call_command invocation. diff --git a/src/shared/permission-grants.ts b/src/shared/permission-grants.ts new file mode 100644 index 000000000..46e4f252e --- /dev/null +++ b/src/shared/permission-grants.ts @@ -0,0 +1,141 @@ +export const PERMISSION_CAPABILITY_KINDS = [ + 'customize_mutation', + 'mcp_tool', + 'execution', + 'file_operation', + 'skill_operation', + 'builtin_tool' +] as const + +export const EXACT_PERMISSION_QUALIFIER_PATTERN = /^sha256:v1:[a-f0-9]{64}$/ + +export type PermissionCapabilityKind = (typeof PERMISSION_CAPABILITY_KINDS)[number] + +export type PermissionCapabilityQualifier = + { mode: 'any' } | { mode: 'category' | 'exact'; value: string } + +export type PermissionCapability = { + kind: PermissionCapabilityKind + key: string + qualifier?: PermissionCapabilityQualifier +} + +export type PermissionGrantScope = + | { kind: 'global' } + | { kind: 'project'; projectId: string } + | { kind: 'session'; projectId: string; sessionId: string } + +export type PermissionGrantRecord = { + id: string + capability: PermissionCapability + scope: PermissionGrantScope + createdAt?: number + revision: number +} + +export type PermissionGrantContext = { + projectId?: string + sessionId?: string +} + +export type PermissionGrantMatch = { + grant: PermissionGrantRecord + matchedScope: PermissionGrantScope['kind'] +} + +export type RememberPermissionGrant = { + capability: PermissionCapability + scope: PermissionGrantScope +} + +export type RevokePermissionGrants = { + grants: Array<{ id: string; revision: number }> +} + +export type RestorePermissionGrants = { + undoToken: string +} + +export type PermissionGrantOwner = + | { kind: 'project'; projectId: string } + | { kind: 'session'; projectId: string; sessionId: string } + | { kind: 'mcp_server'; serverId: string } + | { kind: 'compute_provider'; providerId: string } + +export type PermissionGrantMutationConflict = { + id: string + reason: 'stale' | 'missing' | 'target-unavailable' +} + +export type PermissionGrantMutationResult = { + grants: PermissionGrantRecord[] + receipt?: { + undoToken: string + expiresAt: number + revokedCount: number + } + conflicts: PermissionGrantMutationConflict[] +} + +export const PERMISSION_GRANT_FAMILIES = [ + 'registry_writes', + 'local_compute', + 'connectors', + 'file_operations', + 'skills', + 'built_in_tools' +] as const + +export type PermissionGrantFamily = (typeof PERMISSION_GRANT_FAMILIES)[number] + +// Renderer-safe projection. Exact qualifier digests and raw tool inputs never cross IPC. +export type PermissionGrantView = { + id: string + revision: number + family: PermissionGrantFamily + capabilityKind: PermissionCapabilityKind + capabilityLabel: string + qualifierLabel?: string + scopeKind: PermissionGrantScope['kind'] + scopeLabel: string + // A broader matching grant that remains effective after this exact row is revoked. + coveredBy?: 'global' | 'project' + effectiveState?: 'active' | 'covered_by_policy' | 'blocked_by_policy' + policyHint?: string + connectorServerId?: string + connectorToolName?: string + projectId?: string + sessionId?: string + createdAt?: number +} + +export type PermissionGrantSnapshot = { + version: number + incompleteStores: Array<'projects' | 'sessions' | 'connector_policy'> + grants: PermissionGrantView[] + counts: { + all: number + global: number + project: number + session: number + } +} + +export type PermissionGrantRevokeRequest = { + grants: Array<{ id: string; revision: number }> +} + +export type PermissionGrantRestoreRequest = { + undoToken: string +} + +export type PermissionGrantMutationView = PermissionGrantSnapshot & { + receipt?: { + undoToken: string + expiresAt: number + revokedCount: number + } + conflicts: PermissionGrantMutationConflict[] +} + +export type PermissionGrantsChangedEvent = { revision: number } diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 6f41f9701..93db5daeb 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -1039,8 +1039,7 @@ export type ImportSkillResult = { // --- Connectors --------------------------------------------------------------------------------- -// Per-tool permission. 'ask' is reserved for the future per-call approval flow and is not yet -// functional — the UI renders it disabled and only 'allow'/'block' persist (to blockedToolIds). +// Per-tool Connector policy. `ask` requires a matching Broker grant or a scoped approval prompt. export type ToolPermission = 'allow' | 'ask' | 'block' // One tool within a connector, with its current permission (derived: 'block' if blocklisted). @@ -1146,8 +1145,11 @@ export type ConnectorApprovalRequest = { // The session that triggered the connector call, so a desktop notification can surface and open // that conversation. Absent for call paths that don't carry one. sessionId?: string + // Missing only when talking to an older main process during development; renderer falls back to Once. + availableScopes?: ConnectorApprovalScope[] } -export type ApprovalDecision = 'allow' | 'deny' +export type ConnectorApprovalScope = 'once' | 'session' | 'project' | 'global' +export type ApprovalDecision = ConnectorApprovalScope | 'deny' export type RespondApprovalRequest = { id: string; decision: ApprovalDecision } // Minimal settings slice the remote-file-browser bookmark helpers depend on. Declared here in diff --git a/src/shared/web-api-map.generated.ts b/src/shared/web-api-map.generated.ts index 19e3e4f11..b081d1dc4 100644 --- a/src/shared/web-api-map.generated.ts +++ b/src/shared/web-api-map.generated.ts @@ -70,6 +70,9 @@ export const WEB_INVOKE_CHANNELS = { 'notebookEnv.repair': 'notebook-env:repair', 'notifications.peekPendingOpenSession': 'notifications:peek-pending-open-session', 'notifications.takePendingOpenSession': 'notifications:take-pending-open-session', + 'permissions.list': 'permissions:list', + 'permissions.restore': 'permissions:restore', + 'permissions.revoke': 'permissions:revoke', 'preview.delete': 'preview:delete', 'preview.load': 'preview:load', 'preview.save': 'preview:save', @@ -215,6 +218,7 @@ export const WEB_EVENT_CHANNELS = { 'notebookEnv.onProgress': 'notebook-env:progress', 'notifications.onOpenSession': 'notifications:open-session', 'notifications.onViewProbe': 'notifications:probe-unread-view', + 'permissions.onChanged': 'permissions:changed', 'projectFiles.onChanged': 'project-files:changed', 'projects.onCreated': 'project:created', 'projects.onDeleted': 'project:deleted',