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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 7 additions & 1 deletion resources/notebook/repl_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({}))
Expand Down
26 changes: 25 additions & 1 deletion src/main/acp/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const registerWithFakes = (overrides?: {
onSessionCancellationRequested?: (sessionId: string) => void
onSessionUnavailable?: (sessionId: string) => void
onAllSessionsCancellationRequested?: () => void
beforeSessionDelete?: (sessionId: string) => Promise<void>
profileService?: { getById: (id: string) => Promise<unknown> }
specialistSkillCatalog?: Array<{ id: string; frameworkName: string; displayName: string }>
provisionedConnectorSkillNames?: string[]
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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' })
Expand All @@ -303,19 +310,36 @@ 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(
onSessionUnavailable.mock.invocationCallOrder[0]
)
})

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()
Expand Down
20 changes: 17 additions & 3 deletions src/main/acp/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<unknown>
// Observes prompt starts and terminal turn events for desktop notifications. Optional so tests
// and headless setups can run without a notification surface.
Expand All @@ -90,6 +93,7 @@ type AcpIpcOptions = AcpIpcArtifacts & {
onSessionCancellationRequested?: (sessionId: string) => void
onSessionUnavailable?: (sessionId: string) => void
onAllSessionsCancellationRequested?: () => void
beforeSessionDelete?: (sessionId: string) => Promise<void>
// 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
Expand Down Expand Up @@ -120,6 +124,7 @@ const createRuntime = ({
notebookRpcServer,
authorizeSkillImportReferencedUploads,
settingsService,
permissionGrantRegistry,
initializationBarrier,
taskNotifications,
onSessionTurnStarted,
Expand All @@ -128,6 +133,7 @@ const createRuntime = ({
onSessionCancellationRequested,
onSessionUnavailable,
onAllSessionsCancellationRequested,
beforeSessionDelete,
profileService
}: AcpIpcOptions): AcpRuntimeCoordinator => {
const configRoot = resolveConfigRoot()
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) => {
Expand Down Expand Up @@ -279,8 +289,12 @@ const createRuntime = ({
onSessionTurnEnded,
onSkillImportAttachmentEligible,
onSessionCancellationRequested,
onAllSessionsCancellationRequested
}
onAllSessionsCancellationRequested,
beforeSessionDelete
},
permissionGrantRegistry
? () => projectRegistrySessionGrants(permissionGrantRegistry.listCached())
: undefined
)
}

Expand Down
Loading
Loading