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
1 change: 0 additions & 1 deletion apps/buddy/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export default defineConfig({
},
output: {
format: 'es',
banner: chunk => chunk.name === 'extension-compiler' ? 'import { fileURLToPath as compilerFilePath } from "node:url"; import { dirname as compilerDirname } from "node:path"; const __filename = compilerFilePath(import.meta.url); const __dirname = compilerDirname(__filename);' : '',
},
},
},
Expand Down
9 changes: 9 additions & 0 deletions apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { extensionActionRpc } from '../../../shared/extensions/extensionActionAp
import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent'
import { extensionJsonSchema } from '../../../shared/extensions/extensionApi'
import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring'
import { extensionCapabilitiesRpc, extensionCapabilityApplyRpc, extensionCapabilityImpactSchema } from '../../../shared/extensions/extensionCapabilities'
import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext'
import { extensionShellRpc } from '../../../shared/extensions/extensionShell'
import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills'
Expand Down Expand Up @@ -160,6 +161,11 @@ export class DesktopIntegrations {
agentChanged: catalog => service.notify(extensionAgentRpc.changed, catalog),
shellsChanged: () => service.notify(extensionShellRpc.changed, {}),
skillsChanged: () => service.notify(extensionSkillsRpc.changed, {}),
capabilitiesChanged: () => service.notify(extensionCapabilitiesRpc.changed, {}),
capabilityImpact: async extensionId => extensionCapabilityImpactSchema.parse(await service.request(extensionCapabilitiesRpc.impact, { extensionId })),
syncCapabilities: async (descriptors, clearExtensionId) => {
extensionCapabilityApplyRpc.response.parse(await service.request(extensionCapabilitiesRpc.apply, { descriptors, clearExtensionId }))
},
agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: null })),
readResource: (target, signal, request) => readSpaceFileResource(target, signal, async input => spaceFilesRpc.locate.response.parse(await service.request(spaceFilesRpc.locate.method, input, { signal })), request),
readTaskFileResource: async (target, signal, request) => {
Expand Down Expand Up @@ -191,14 +197,17 @@ export class DesktopIntegrations {
runtime.extensionStorage = extensions
runtime.extensionShells = extensions.shells
runtime.extensionSkills = extensions.skills
runtime.extensionCapabilities = extensions.capabilities
service.notify(extensionShellRpc.changed, {})
service.notify(extensionSkillsRpc.changed, {})
service.notify(extensionCapabilitiesRpc.changed, {})
this.#subscriptions.push(() => {
runtime.inspectExtension = null
runtime.extensionAgent = null
runtime.extensionStorage = null
runtime.extensionShells = null
runtime.extensionSkills = null
runtime.extensionCapabilities = null
})
this.#subscriptions.push(registerWorkbenchIpc(new WorkbenchStateStore(paths.buddyHome), () => windows.window))
this.#subscriptions.push(registerContextPanelIpc(runtime.contextPanel, () => windows.window))
Expand Down
8 changes: 8 additions & 0 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { automationNotifications } from '../../../shared/automation/automationAp
import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel'
import { extensionAgentInvocationSchema, extensionAgentRpc } from '../../../shared/extensions/extensionAgent'
import { extensionAgentResourceRequestSchema } from '../../../shared/extensions/extensionAgentResources'
import { extensionCapabilitiesRpc } from '../../../shared/extensions/extensionCapabilities'
import { extensionSkillsRpc } from '../../../shared/extensions/extensionSkills'
import { networkPermissionGrantSchema, networkPermissionsRpc } from '../../../shared/permissions/networkPermissionsRpc'
import { isLinux } from '../../../shared/platform/identifiers'
Expand Down Expand Up @@ -50,6 +51,7 @@ export class DesktopRuntimeHost {
extensionAgent: ReturnType<typeof registerExtensionIpc>['agent'] | null = null
extensionShells: ReturnType<typeof registerExtensionIpc>['shells'] | null = null
extensionSkills: ReturnType<typeof registerExtensionIpc>['skills'] | null = null
extensionCapabilities: ReturnType<typeof registerExtensionIpc>['capabilities'] | null = null
extensionStorage: Pick<ReturnType<typeof registerExtensionIpc>, 'withStoppedStorage'> | null = null
readonly contextPanel: ContextPanelHost
readonly configStore: LexoraConfigStore
Expand Down Expand Up @@ -221,6 +223,12 @@ export class DesktopRuntimeHost {
throw new Error('EXTENSION_AGENT_UNAVAILABLE')
return this.extensionAgent.readResource(extensionAgentResourceRequestSchema.parse(input), signal ?? new AbortController().signal)
}),
// Unlike skills, an empty answer here would read as "everything was uninstalled", so an unready service must fail instead.
peer.onRequest(extensionCapabilitiesRpc.list, () => {
if (!this.extensionCapabilities)
throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
return this.extensionCapabilities.list()
}),
peer.onRequest(extensionAgentRpc.invoke, (input, signal) => {
if (!this.extensionAgent)
throw new Error('EXTENSION_AGENT_UNAVAILABLE')
Expand Down
61 changes: 43 additions & 18 deletions apps/buddy/electron/main/extensions/SandboxedExtensionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ export class SandboxedExtensionHost implements ExtensionHost {
readonly #pending = new Map<string, Pending>()
readonly #ready: Promise<void>
readonly #broker: (method: string, params: unknown) => Promise<JsonValue>
readonly #cleanup: () => void
readonly #cleanup: () => Promise<void>
readonly #onFailure: (failure: ExtensionHostFailure) => void
#resolveReady!: () => void
#rejectReady!: (error: Error) => void
#io = 0
#closing = false
#disposal: Promise<void> | undefined
#started = false
#developerWindow: BrowserWindow | null = null
#readyTimer: ReturnType<typeof setTimeout>
Expand All @@ -51,11 +52,23 @@ export class SandboxedExtensionHost implements ExtensionHost {
})
void this.#ready.catch(() => {})
this.#readyTimer = setTimeout(() => this.#fail({ code: 'EXTENSION_REQUEST_TIMEOUT', method: 'ready', stage: 'timeout' }), 15000)
this.#cleanup = () => {
endpoint.dispose()
stopProtocol()
isolated.webRequest.onBeforeRequest(null)
void isolated.closeAllConnections()
const cleanup = [
() => endpoint.dispose(),
stopProtocol,
() => isolated.closeAllConnections(),
() => {
if (!this.window.isDestroyed())
this.window.destroy()
if (!this.window.isDestroyed())
throw new Error('EXTENSION_HOST_STOP_FAILED')
},
() => isolated.webRequest.onBeforeRequest(null),
]
this.#cleanup = async () => {
while (cleanup.length) {
await cleanup[0]!()
cleanup.shift()
}
}
const contents = this.window.webContents
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
Expand Down Expand Up @@ -160,18 +173,22 @@ export class SandboxedExtensionHost implements ExtensionHost {
this.window.webContents.openDevTools({ mode: 'detach' })
}

async dispose(): Promise<void> {
if (this.#closing)
return
dispose(): Promise<void> {
if (this.#disposal)
return this.#disposal
this.#closing = true
this.#rejectAll()
if (this.#started && !this.window.isDestroyed())
await this.#send('deactivate', null, 1500).catch(() => {})
this.#rejectAll()
this.#closeDeveloperWindow()
this.#cleanup()
if (!this.window.isDestroyed())
this.window.destroy()
this.#disposal = Promise.resolve().then(async () => {
if (this.#started && !this.window.isDestroyed())
await this.#send('deactivate', null, 1500).catch(() => {})
this.#rejectAll()
this.#closeDeveloperWindow()
await this.#cleanup()
}).catch((error) => {
this.#disposal = undefined
throw error
})
return this.#disposal
}

#send(method: string, params: JsonValue, timeout: number, signal?: AbortSignal): Promise<JsonValue> {
Expand Down Expand Up @@ -207,8 +224,16 @@ export class SandboxedExtensionHost implements ExtensionHost {
#closeDeveloperWindow(): void {
const window = this.#developerWindow
this.#developerWindow = null
if (window && !window.isDestroyed())
window.destroy()
try {
if (window && !window.isDestroyed())
window.destroy()
if (window && !window.isDestroyed())
throw new Error('EXTENSION_HOST_STOP_FAILED')
}
catch (error) {
this.#developerWindow = window
throw error
}
}

#fail(failure: ExtensionHostFailure = { code: 'EXTENSION_HOST_CRASHED', method: 'unknown', stage: 'process' }): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@ import { SandboxedExtensionHost } from '../SandboxedExtensionHost'
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
class WebContents extends EventEmitter {
readonly session: unknown
constructor(session: unknown) {
super()
this.session = session
}

mainFrame = {}
send(channel: string, message: unknown) { this.emit(channel, message) }
setWindowOpenHandler() {}
setDevToolsWebContents() {}
openDevTools() {}
}
class BrowserWindow extends EventEmitter {
webContents = new WebContents()
readonly webContents: WebContents
constructor(options: { webPreferences: { session: unknown } }) {
super()
this.webContents = new WebContents(options.webPreferences.session)
}

destroyed = false
async loadURL() {}
isDestroyed() { return this.destroyed }
Expand All @@ -35,7 +48,7 @@ vi.mock('electron', async () => {
fromPartition: () => ({
setPermissionRequestHandler() {},
setPermissionCheckHandler() {},
webRequest: { onBeforeRequest() {} },
webRequest: { onBeforeRequest: vi.fn() },
async closeAllConnections() {},
}),
},
Expand All @@ -55,9 +68,11 @@ async function fixture() {
await store.install((await reviewPackage(root, store, value)).token)
const failures: ExtensionHostFailure[] = []
const gates = new Map<string, { entered: ReturnType<typeof deferred<void>>, done: ReturnType<typeof deferred<void>> }>()
const endpoint = { token: 'fixture', url: 'lexora-extension://fixture/__host.html', dispose: vi.fn() }
const stopProtocol = vi.fn()
const protocol = {
register: () => ({ token: 'fixture', url: 'lexora-extension://fixture/__host.html', dispose() {} }),
install: () => () => {},
register: () => endpoint,
install: () => stopProtocol,
} as unknown as ExtensionProtocol
let host!: SandboxedExtensionHost
let commandMessage: ReturnType<typeof deferred<string>> | undefined
Expand Down Expand Up @@ -88,7 +103,7 @@ async function fixture() {
return host
},
createView: () => { throw new Error('unexpected view') },
workbench: async () => null,
workbench: async event => event.kind === 'clear-data' ? event.requestId : null,
readText: async () => { throw new Error('unexpected external read') },
get: async () => { throw new Error('unexpected network') },
changed() {},
Expand All @@ -113,7 +128,7 @@ async function fixture() {
return { id: await commandMessage.promise, outcome }
}
const request = (method: string, params: JsonValue = null) => host.request(method, params)
return { id, root, store, service, host, failures, hold, command, request, resources: createExtensionResourceClient(request), ignoreDeactivate: () => {
return { id, root, store, service, host, failures, endpoint, stopProtocol, hold, command, request, resources: createExtensionResourceClient(request), ignoreDeactivate: () => {
acknowledgeDeactivate = false
} }
}
Expand Down
23 changes: 22 additions & 1 deletion apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskAction
import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent'
import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { ExtensionCapabilityDescriptor, ExtensionCapabilityImpact } from '../../../shared/extensions/extensionCapabilities'
import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext'
import type { TaskFileTarget } from '../../../shared/files/taskFileApi'
import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi'
Expand Down Expand Up @@ -46,11 +47,14 @@ export function registerExtensionIpc(options: {
invokeTaskAction: (input: ExtensionTaskActionInput) => Promise<ExtensionTaskActionResult>
shellsChanged?: () => void
skillsChanged?: () => void
capabilitiesChanged?: () => void
capabilityImpact?: (extensionId: string) => Promise<ExtensionCapabilityImpact>
syncCapabilities?: (descriptors: ExtensionCapabilityDescriptor[], clearExtensionId?: string) => Promise<void>
agentChanged?: (catalog: ExtensionAgentCatalog) => void
record?: ApplicationDiagnosticReporter
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise<ExtensionConditionRuntime>
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise<JsonValue>
}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise<void>, conditions: ExtensionService['conditions'], dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
}): { shells: { list: ExtensionService['shellContributions'] }, skills: { list: ExtensionService['skillContributions'] }, capabilities: { list: ExtensionService['capabilityContributions'] }, withStoppedStorage: ExtensionService['withStoppedStorage'], themesReady: Promise<void>, conditions: ExtensionService['conditions'], dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { readResource: ExtensionService['readAgentResource'], list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version)
async function reviewResult(operation: () => Promise<ExtensionReview>): Promise<ExtensionReviewResult> {
try {
Expand All @@ -68,6 +72,7 @@ export function registerExtensionIpc(options: {
const replies = new Map<string, { kind: ExtensionWorkbenchEvent['kind'], finish: (result: ExtensionWorkbenchResult) => void }>()
const bound = new Set<BrowserWindow>()
let resourcePickerOpen = false
let initializing = true
const guardNavigation = (event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) => {
if (!event.isMainFrame && !protocol.validViewUrl(event.url))
event.preventDefault()
Expand All @@ -77,6 +82,11 @@ export function registerExtensionIpc(options: {
clipboard: createExtensionClipboard(options.getWindow),
shellsChanged: options.shellsChanged,
skillsChanged: options.skillsChanged,
capabilitiesChanged: options.capabilitiesChanged,
syncCapabilities: async (descriptors, clearExtensionId) => {
if (!initializing)
await options.syncCapabilities?.(descriptors, clearExtensionId)
},
themes: options.themes,
createHost: (pkg, broker, failed) => {
const host = new SandboxedExtensionHost(pkg, protocol, broker, failed)
Expand Down Expand Up @@ -238,6 +248,8 @@ export function registerExtensionIpc(options: {
else
await service.restart(review.manifest.id)
}
initializing = false
options.capabilitiesChanged?.()
})
const record = safeDiagnosticReporter(options.record)
void prepared.catch(error => record({ component: 'desktop.extensions', level: 'warn', event: 'extensions.initialize.failed', errorCode: extensionError(error) }))
Expand Down Expand Up @@ -285,6 +297,11 @@ export function registerExtensionIpc(options: {
case 'restart': return await service.restart(input.id)
case 'applyUpdate': return await service.applyUpdate(input.id)
case 'uninstall': return await service.uninstall(input.id, input.clearData)
case 'capabilityImpact': {
if (!options.capabilityImpact)
throw new Error('EXTENSION_SERVICE_UNAVAILABLE')
return await options.capabilityImpact(input.id)
}
case 'devtools': return await service.devtools(input.id)
case 'revokeResources': return await service.revokeResources(input.id)
case 'execute': return await service.execute(input.id, input.command, input.resource)
Expand Down Expand Up @@ -362,6 +379,10 @@ export function registerExtensionIpc(options: {
await prepared
return service.skillContributions()
} },
capabilities: { list: async () => {
await prepared
return service.capabilityContributions()
} },
shells: { list: async () => {
await prepared
return service.shellContributions()
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/main/local-chat/buddies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function registerBuddiesIpc({ handle, request }: LocalChatIpcContext): vo
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesList, () => request(buddiesRpc.list, {}))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesGet, (_event, input) => request(buddiesRpc.get, buddiesRpc.get.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesCreate, (_event, input) => request(buddiesRpc.create, buddiesRpc.create.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesDuplicate, (_event, input) => request(buddiesRpc.duplicate, buddiesRpc.duplicate.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesDelete, (_event, input) => request(buddiesRpc.delete, buddiesRpc.delete.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesResetProfile, (_event, input) => request(buddiesRpc.resetProfile, buddiesRpc.resetProfile.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.buddiesSetDefault, (_event, input) => request(buddiesRpc.setDefault, buddiesRpc.setDefault.input.parse(input)))
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/preload/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function createExtensionApi(): ExtensionApi {
restart: id => request({ action: 'restart', id }),
applyUpdate: id => request({ action: 'applyUpdate', id }),
uninstall: (id, options) => request({ action: 'uninstall', id, clearData: options?.clearData ?? false }),
capabilityImpact: id => request({ action: 'capabilityImpact', id }),
devtools: id => request({ action: 'devtools', id }),
revokeResources: id => request({ action: 'revokeResources', id }),
execute: (id, command, resource) => request({ action: 'execute', id, command, resource }),
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/preload/local-chat/buddies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function createBuddiesApi(): Pick<LocalChatApi, 'buddies'> {
list: () => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesList),
get: buddyId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesGet, { buddyId }),
create: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesCreate, input),
duplicate: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesDuplicate, input),
delete: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesDelete, input),
resetProfile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesResetProfile, input),
setDefault: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.buddiesSetDefault, input),
Expand Down
Loading