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
146 changes: 146 additions & 0 deletions .playwright/scripts/__tests__/sandbox.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import targets from '../../../apps/buddy/platform/targets.json' with { type: 'json' }
import { expect, test } from '../fixtures/electron.mjs'

const buddyRoot = path.resolve('apps/buddy')
const require = createRequire(path.join(buddyRoot, 'package.json'))

test('Sandbox preserves production RPC boundaries with large toolchains', async ({ buddy }, testInfo) => {
test.skip(!['win32', 'linux'].includes(process.platform), 'Requires Windows or Linux sandbox resources')
test.setTimeout(600_000)
const { build } = await import(pathToFileURL(require.resolve('vite')).href)
const mainDirectory = path.join(buddyRoot, '.output/build/electron/main')
const processes = (await readdir(mainDirectory)).filter(file => /^sandboxProcess-.*\.js$/.test(file))
expect(processes).toHaveLength(1)
const outDir = await mkdtemp(path.join(mainDirectory, 'sandbox-verification-'))
try {
await build({
configFile: false,
root: buddyRoot,
logLevel: 'error',
publicDir: false,
plugins: [{
name: 'sandbox-verification-process',
enforce: 'pre',
resolveId: source => source.endsWith('?modulePath') ? source : null,
load: id => id.endsWith('?modulePath') ? `export default ${JSON.stringify(path.join(mainDirectory, processes[0]))}` : null,
}],
build: {
outDir,
target: 'node22',
minify: false,
lib: { entry: path.join(buddyRoot, 'electron/main/sandbox/verifySandboxInstallation.ts'), formats: ['es'], fileName: () => 'verify.mjs' },
rollupOptions: { external: ['electron', /^node:/] },
},
})
const instance = await buddy.createInstance('sandbox-scale')
const { app, page } = await instance.launch()
await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.runtime.getStatus()).status), { timeout: 120_000 }).toBe('ready')
const measurements = await app.evaluate(async (_electron, { module, home, root, target }) => {
const file = process.getBuiltinModule('node:url').fileURLToPath(module)
const { join } = process.getBuiltinModule('node:path')
const childProcess = process.getBuiltinModule('node:child_process')
const spawn = childProcess.spawn
const execFile = childProcess.execFile
let nativeDiagnostics = ''
const inspections = []
childProcess.execFile = (...args) => {
const start = performance.now()
const child = execFile(...args)
if (String(args[0]).endsWith('lexora-buddy-sandbox.exe'))
child.once('close', () => inspections.push({ action: args[1]?.[0], elapsedMs: performance.now() - start }))
return child
}
const { promisify } = process.getBuiltinModule('node:util')
Object.defineProperty(childProcess.execFile, promisify.custom, {
value: async (...args) => {
const start = performance.now()
try {
return await promisify(execFile)(...args)
}
finally {
if (String(args[0]).endsWith('lexora-buddy-sandbox.exe'))
inspections.push({ action: args[1]?.[0], elapsedMs: performance.now() - start })
}
},
})
childProcess.spawn = (...args) => {
const child = spawn(...args)
if (String(args[0]).endsWith('lexora-buddy-sandbox.exe'))
child.stderr?.on('data', data => nativeDiagnostics = `${nativeDiagnostics}${data}`.slice(-8192))
return child
}
process.getBuiltinModule('node:module').syncBuiltinESMExports()
try {
const { verifySandboxInstallation } = process.getBuiltinModule('node:module').createRequire(file)(file)
const measurements = await verifySandboxInstallation({
buddyHome: home,
protectedRoots: [home],
...(process.platform === 'win32'
? {
windowsSandbox: join(root, `.output/build/native/${target.rustTarget}/release/lexora-buddy-sandbox.exe`),
windowsShell: join(process.env.ProgramFiles, 'PowerShell/7/pwsh.exe'),
}
: {
sandboxDirectory: join(root, `.output/build/shell-sandbox/${process.platform}-${process.arch}`),
processControl: join(root, `.output/build/native/${target.rustTarget}/release/lexora-buddy-process-control`),
}),
searchDirectory: join(root, `.output/build/search-tools/${process.platform}-${process.arch}`),
}, true)
return { ...measurements, inspections }
}
catch (error) {
throw new Error(`${error.message}; ${nativeDiagnostics}; ${JSON.stringify(inspections)}`)
}
finally {
childProcess.spawn = spawn
childProcess.execFile = execFile
process.getBuiltinModule('node:module').syncBuiltinESMExports()
}
}, { module: pathToFileURL(path.join(outDir, 'verify.mjs')).href, home: instance.home, root: buddyRoot, target: targets[`${process.platform}-${process.arch}`] })
expect(measurements.repeatedRpcMs).toHaveLength(3)
await testInfo.attach('sandbox-scale', { body: JSON.stringify({ toolchainFiles: 50_000, workspaceFiles: 5_000, ...measurements }), contentType: 'application/json' })
}
finally {
await rm(outDir, { recursive: true, force: true })
}
})

test('Windows prepares bound workspace permissions before any task command', async ({ buddy }) => {
test.skip(process.platform !== 'win32', 'Requires the installed Windows sandbox component')
test.setTimeout(180_000)
const instance = await buddy.createInstance('sandbox-binding')
const directory = await mkdtemp(path.join(tmpdir(), 'lexora-sandbox-binding-'))
const shell = path.join(process.env.ProgramFiles, 'PowerShell/7/pwsh.exe')
const explicitWriters = async () => {
const command = `$acl = Get-Acl -LiteralPath '${directory.replaceAll('\'', '\'\'')}'; $writers = @($acl.GetAccessRules($true, $false, [Security.Principal.SecurityIdentifier]) | Where-Object { $_.AccessControlType -eq 'Allow' -and ($_.FileSystemRights -band 2) -ne 0 } | ForEach-Object { $_.IdentityReference.Value }); [Console]::Out.Write((ConvertTo-Json -InputObject $writers -Compress))`
const { stdout } = await promisify(execFile)(shell, ['-NoLogo', '-NoProfile', '-Command', command], { windowsHide: true, timeout: 10_000 })
return JSON.parse(stdout)
}
try {
await writeFile(path.join(directory, 'before-command.txt'), 'fixture')
const original = await explicitWriters()
const { app, page } = await instance.launch()
await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.runtime.getStatus()).status), { timeout: 120_000 }).toBe('ready')
await app.evaluate(({ dialog }, directory) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] })
}, directory)
const space = await page.evaluate(async () => {
const root = await window.lexoraDesktop.localChat.spaces.selectDirectory()
return window.lexoraDesktop.localChat.spaces.create({ name: 'Sandbox binding', memoryScope: 'space_only', primaryDirectory: { id: null, root } })
})
await expect.poll(async () => (await explicitWriters()).filter(sid => !original.includes(sid)).length, { timeout: 60_000 }).toBeGreaterThanOrEqual(2)
await page.evaluate(id => window.lexoraDesktop.localChat.spaces.delete(id), space.id)
}
finally {
await instance.stop()
await rm(directory, { recursive: true, force: true })
}
})
2 changes: 1 addition & 1 deletion .playwright/scripts/buddy-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export async function createBuddyTestRun({ appPath = process.env.LEXORA_TEST_APP
assert.equal(new URL(page.url()).protocol, 'data:')
return { app: application, page, diagnostics: launchRecord.diagnostics }
}
assert.equal(rendererUrl ? new URL(page.url()).origin : new URL(page.url()).protocol, rendererUrl ? new URL(rendererUrl).origin : 'lexora-app:', `Application failed to start: ${await page.locator('body').textContent()}`)
assert.equal(rendererUrl ? new URL(page.url()).origin : new URL(page.url()).protocol, rendererUrl ? new URL(rendererUrl).origin : 'lexora-app:')
await page.waitForFunction(async status => (await window.lexoraDesktop?.app.startup.getState())?.status === status, startupStatus, { timeout: 45000 })
const info = await page.evaluate(() => window.lexoraDesktop.app.getInfo())
assert.equal(info.runtimeProfile, 'test')
Expand Down
24 changes: 21 additions & 3 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export class DesktopRuntimeHost {
#network: DesktopNetwork | null = null
#windowsPowerShell: string | undefined
readonly #subscriptions: Array<() => void> = []
#prepareSandbox: (() => void) | undefined
readonly #sandboxCleanup = new Set<Promise<void>>()
#sandboxCheck: ReturnType<typeof checkSandboxEnvironment> | null = null
#sandboxSetup: ReturnType<typeof setupWindowsSandbox> | null = null

Expand Down Expand Up @@ -106,7 +108,11 @@ export class DesktopRuntimeHost {
}

setupSandbox(): ReturnType<typeof setupWindowsSandbox> {
this.#sandboxSetup ??= setupWindowsSandbox(this.#sandboxExecutable()).finally(() => {
this.#sandboxSetup ??= setupWindowsSandbox(this.#sandboxExecutable()).then((result) => {
if (result === 'ready')
this.#prepareSandbox?.()
return result
}).finally(() => {
this.#sandboxSetup = null
this.#sandboxCheck = null
})
Expand Down Expand Up @@ -173,9 +179,15 @@ export class DesktopRuntimeHost {
const sandbox = registerSandboxHostRpc(peer, {
buddyHome: environment.paths.buddyHome,
protectedRoots: environment.storage.protection.sandboxRoots,
protectionScope: environment.storage.bootstrapRoot,
proxyUrl: this.#network!.sandboxProxyUrl,
...this.#sandboxOptions(),
})
const prepareSandbox = () => {
sandbox.prepare()
peer.notify('host.sandbox.ready', {})
}
this.#prepareSandbox = prepareSandbox
sandbox.onDidChange(event => environment.events.publish({
event: `sandbox.supervisor.${event.kind}`,
level: event.kind === 'cleanup_failed' ? 'warn' : 'info',
Expand All @@ -200,15 +212,20 @@ export class DesktopRuntimeHost {
return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness')
}),
registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable),
sandbox.dispose,
registerBrowserHostRpc(peer, {
createAdapterLease: input => this.#browser.adapter.issueLease(input),
getHost: () => this.#browser.host,
}),
registerCredentialHostRpc(peer, credentials),
...this.#features.map(feature => feature.bindPeer(peer)),
]
return () => disposers.forEach(dispose => dispose())
return () => {
if (this.#prepareSandbox === prepareSandbox)
this.#prepareSandbox = undefined
const cleanup = sandbox.dispose().finally(() => this.#sandboxCleanup.delete(cleanup))
this.#sandboxCleanup.add(cleanup)
disposers.forEach(dispose => dispose())
}
},
diagnosticOutput: environment.diagnostics.createWritable('local-service', { event: 'runtime.supervisor', level: 'error' }),
spawnService: (onFatalError, sourceId) => forkBuddyServiceProcess({
Expand Down Expand Up @@ -277,6 +294,7 @@ export class DesktopRuntimeHost {
() => this.contextPanel.dispose(),
() => this.configStore.dispose(),
() => this.#service?.stop(),
() => Promise.all(this.#sandboxCleanup),
() => this.#network?.stop(),
...this.#features.map(feature => () => feature.stop()),
...this.#subscriptions.splice(0),
Expand Down
97 changes: 97 additions & 0 deletions apps/buddy/electron/main/sandbox/WindowsSandboxPreparation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { SandboxHostOptions } from './registerSandboxHostRpc'
import { homedir } from 'node:os'
import process from 'node:process'
import { filePaths } from '../../../platform/filesystem/filePaths'
import { resolveWindowsSandbox } from '../../../platform/process/windowsSandbox'
import { prepareWindowsSandboxFilesystem } from '../../../platform/process/windowsSandboxProcess'
import { resolveSensitivePathRoots } from '../../../service/src/permissions/sensitivePaths'
import { createWindowsSandboxPolicy } from '../../../service/src/sandbox/backends/windows/createPolicy'
import { existingSandboxPaths, existingSandboxSensitivePaths, inspectSandboxPath } from '../../../service/src/sandbox/sandboxFilesystemPolicy'
import { SHELL_SANDBOX_BACKEND } from '../../../shared/platform/identifiers'

export class WindowsSandboxPreparation {
readonly #options: SandboxHostOptions
readonly #controller = new AbortController()
readonly #pending = new Map<string, Promise<void>>()
#background: Promise<void> | undefined
#queue: Promise<void> = Promise.resolve()

constructor(options: SandboxHostOptions) {
this.#options = options
this.start()
}

start(): void {
if (this.#background || this.#controller.signal.aborted)
return
this.#background = this.#initialize().catch(() => {
this.#background = undefined
if (!this.#controller.signal.aborted)
console.error('SANDBOX_BACKGROUND_PREPARATION_FAILED')
})
}

prepare(roots: readonly string[]): Promise<void> {
this.start()
if (!roots.length)
return Promise.resolve()
const key = JSON.stringify([...new Set(roots.map(filePaths.sensitiveKey))].sort())
const existing = this.#pending.get(key)
if (existing)
return existing
const work = this.#queue.then(async () => {
this.#controller.signal.throwIfAborted()
const executable = await resolveWindowsSandbox(this.#options.windowsSandbox)
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT
if (!systemRoot || !this.#options.windowsShell)
throw new Error('SANDBOX_UNAVAILABLE')
const policy = await createWindowsSandboxPolicy({
roots: [...roots],
workspaceRoots: [...roots],
resourceReadRoots: [],
additionalDirectories: [],
readOnly: false,
cwd: roots[0]!,
home: homedir(),
sensitiveRoots: resolveSensitivePathRoots(),
protectedRoots: this.#options.protectedRoots ?? [],
searchDirectory: this.#options.searchDirectory,
path: process.env.PATH ?? '',
backend: { kind: SHELL_SANDBOX_BACKEND.Windows, executable, shell: this.#options.windowsShell, systemRoot, protectionScope: this.#options.protectionScope ?? this.#options.buddyHome },
}, this.#controller.signal)
await prepareWindowsSandboxFilesystem(executable, policy.grants, AbortSignal.any([this.#controller.signal, AbortSignal.timeout(60_000)]), {
denyReadScope: this.#options.protectionScope ?? this.#options.buddyHome,
})
}).finally(() => this.#pending.delete(key))
this.#pending.set(key, work)
this.#queue = work.catch(() => {})
return work
}

async dispose(): Promise<void> {
this.#controller.abort()
await Promise.allSettled([this.#background, this.#queue])
}

async #initialize(): Promise<void> {
const executable = await resolveWindowsSandbox(this.#options.windowsSandbox)
const home = homedir()
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT
if (!systemRoot || !this.#options.windowsShell)
throw new Error('SANDBOX_UNAVAILABLE')
const protectedPaths = [
...await existingSandboxSensitivePaths({
sensitiveRoots: resolveSensitivePathRoots(),
backend: { kind: SHELL_SANDBOX_BACKEND.Windows, executable, shell: this.#options.windowsShell, systemRoot, protectionScope: this.#options.protectionScope ?? this.#options.buddyHome },
}),
...await existingSandboxPaths(this.#options.protectedRoots ?? []),
]
const grants = await Promise.all(protectedPaths.map(path => inspectSandboxPath(path, 'denyRead')))
grants.push(await inspectSandboxPath(home, 'read'))
await prepareWindowsSandboxFilesystem(executable, grants, AbortSignal.any([this.#controller.signal, AbortSignal.timeout(900_000)]), {
denyReadScope: this.#options.protectionScope ?? this.#options.buddyHome,
synchronizeDenials: true,
background: true,
})
}
}
Loading
Loading