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: 1 addition & 0 deletions apps/buddy/electron/main/app/DesktopApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class DesktopApplication {
try {
await this.#dispose()
}
catch {}
finally {
try {
if (!this.#environment.isSmokeTest)
Expand Down
5 changes: 4 additions & 1 deletion apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { currentTarget } from '../../../platform/target'
import { resolveWindowsPowerShell } from '../../../platform/windows/powerShell'
import { automationNotifications } from '../../../shared/automation/automationApi'
import { contextPanelRpc, contextPanelSourceSchema } from '../../../shared/context-panel/contextPanel'
import { readDiagnosticError } from '../../../shared/diagnostics/applicationDiagnostic'
import { isLinux } from '../../../shared/platform/identifiers'
import { runtimePreferencesRpc } from '../../../shared/runtime/runtimePreferences'
import { installAttachmentProtocol } from '../attachmentProtocol'
Expand Down Expand Up @@ -139,6 +140,8 @@ export class DesktopRuntimeHost {
this.#config = config
this.#network = new DesktopNetwork()
await this.#network.start(config.proxy)
if (this.#network.startupError)
environment.events.publish({ event: 'network.start_failed', component: 'desktop.network', level: 'warn', ...readDiagnosticError(this.#network.startupError) })
this.#windowsPowerShell = currentPlatform.shell === 'powershell'
? await resolveWindowsPowerShell()
: undefined
Expand All @@ -162,7 +165,7 @@ export class DesktopRuntimeHost {
const source = contextPanelSourceSchema.parse(params)
return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness')
}),
registerWebHostRpc(peer, this.#network!.authenticateProxy),
registerWebHostRpc(peer, this.#network!.authenticateProxy, this.#network!.assertAvailable),
registerSandboxHostRpc(peer, {
buddyHome: environment.paths.buddyHome,
proxyUrl: this.#network!.sandboxProxyUrl,
Expand Down
18 changes: 10 additions & 8 deletions apps/buddy/electron/main/app/desktopStartupFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,20 @@ export function describeDesktopStartupFailure(error: unknown, language: LexoraCo
const unconfirmedPermissions = error instanceof PrivateDirectoryError && (error.failure.acl?.reason === 'unsupported_ace' || error.failure.acl?.principal === 'other')
const reason = error instanceof PowerShellUnavailableError
? 'powerShellUnavailable'
: unconfirmedPermissions
? 'privateDirectoriesUnconfirmed'
: code === 'PRIVATE_DIRECTORIES_UNSAFE'
? 'privateDirectoriesUnsafe'
: code.startsWith('PRIVATE_DIRECTORIES_')
? 'privateDirectoriesFailed'
: 'startupFailureHelp'
: code === 'NETWORK_START_FAILED'
? 'networkStartupFailed'
: unconfirmedPermissions
? 'privateDirectoriesUnconfirmed'
: code === 'PRIVATE_DIRECTORIES_UNSAFE'
? 'privateDirectoriesUnsafe'
: code.startsWith('PRIVATE_DIRECTORIES_')
? 'privateDirectoriesFailed'
: 'startupFailureHelp'
const t = (key: Parameters<typeof translateDesktopNative>[1]) => translateDesktopNative(language, key)
const fields: RecoveryPresentation['recovery']['fields'] = [
{ label: t('startupErrorCode'), value: code },
...(launchId ? [{ label: t('diagnosticReference'), value: launchId }] : []),
...(failure ? [{ label: t('startupFailureStage'), value: [failure.operation, failure.directoryRole, failure.kind === 'desktop_bootstrap' ? failure.systemCode : undefined].filter(Boolean).join(' / ') }] : []),
...(failure ? [{ label: t('startupFailureStage'), value: [failure.operation, failure.kind === 'network_startup' ? undefined : failure.directoryRole, failure.kind === 'private_directories' ? undefined : failure.systemCode].filter(Boolean).join(' / ') }] : []),
...(failure?.kind === 'private_directories' && failure.acl ? [{ label: t('startupPermissionCheck'), value: [failure.acl.reason, failure.acl.principal, failure.acl.accessMask === undefined ? undefined : `mask=0x${failure.acl.accessMask.toString(16)}`, failure.acl.aceFlags === undefined ? undefined : `flags=0x${failure.acl.aceFlags.toString(16)}`].filter(Boolean).join(' / ') }] : []),
...(directory ? [{ label: t('affectedDirectory'), value: directory, localOnly: true }] : []),
...(logsPath ? [{ label: t('startupLogsPath'), value: logsPath, localOnly: true }] : []),
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/electron/main/desktopNativeI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const messages = {
powerShellUnavailable: 'No usable PowerShell installation was found. Install PowerShell 7 or restore Windows PowerShell, then restart Lexora Buddy.',
startupFailed: 'Lexora Buddy could not start',
startupFailureHelp: 'Open the logs folder to view the diagnostic information.',
networkStartupFailed: 'The application network could not be initialized. Check the failed step and system error code in the diagnostic bundle.',
privateDirectoriesUnsafe: 'An application data directory does not meet the required access permissions or points to another location. Check its permissions and location, then try again.',
privateDirectoriesFailed: 'Lexora Buddy could not prepare its application data directories. Check the diagnostic information for the failed operation.',
privateDirectoriesUnconfirmed: 'Lexora Buddy cannot confirm the access permissions of this data directory. This does not necessarily mean the directory is unsafe. Keep the diagnostic reference for support; do not remove unfamiliar Windows permissions.',
Expand Down Expand Up @@ -59,6 +60,7 @@ const messages = {
powerShellUnavailable: '未找到可用的 PowerShell。请安装 PowerShell 7 或修复系统自带的 Windows PowerShell,然后重新启动 Lexora Buddy。',
startupFailed: 'Lexora Buddy 启动失败',
startupFailureHelp: '可以打开日志目录查看诊断信息。',
networkStartupFailed: '应用网络初始化失败,请查看诊断包中的失败步骤与系统错误码。',
privateDirectoriesUnsafe: '应用数据目录的访问权限不符合要求,或目录指向了其他位置。请检查目录权限和位置后重试。',
privateDirectoriesFailed: '无法准备应用数据目录,请查看诊断信息中的失败操作。',
privateDirectoriesUnconfirmed: '暂时无法确认此数据目录的访问权限,并不一定代表目录不安全。请保留诊断编号以便排查,不要自行删除不认识的 Windows 权限。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ function unpack(bytes: Uint8Array) {
}

describe('application diagnostic bundles', () => {
it('keeps successful startup steps and the launch network failure when exporting a later incident', async () => {
const { reader } = await fixture([
record(1, { event: 'network.start_failed', runId: undefined, level: 'warn', errorCode: 'NETWORK_START_FAILED', failure: { kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 } }),
record(2, { timestamp: '2026-09-24T00:02:00.000Z', event: 'component.ready', component: 'runtime.database', level: 'info', runId: undefined, operationId: 'database-setup', errorCode: undefined }),
record(3, { timestamp: '2026-09-24T00:02:01.000Z', event: 'run.failed' }),
])
const { context } = unpack((await createApplicationDiagnosticBundle(reader, 'current', { launchId: 'launch-current', sequence: 3 }))!.bytes)
expect(context.map(record => record.sequence)).toEqual([1, 2, 3])
expect(context[0].failure).toEqual({ kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 })
})
it('exports related activity before and after errors without free-form content or unrelated user files', async () => {
const { reader } = await fixture([
record(1, { launchId: 'launch-history' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ function incidentRecords(records: ApplicationLogRecord[], seed: ApplicationLogRe
const times = related.map(record => Date.parse(record.timestamp))
const from = Math.min(...times) - 30_000
const to = Math.max(...times) + 30_000
return launch.filter(record => linked(record) || (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to))
return launch.filter(record => linked(record)
|| record.event === 'network.start_failed'
|| (!record.runId && (!record.operationId || /^(?:app|startup|runtime|recorder|component)\./.test(record.event)) && Date.parse(record.timestamp) >= from && Date.parse(record.timestamp) <= to))
}

function encodeRecords(records: ApplicationLogRecord[]): Uint8Array {
Expand Down
59 changes: 46 additions & 13 deletions apps/buddy/electron/main/network/DesktopNetwork.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { AuthenticationResponseDetails, AuthInfo, Event, ProxyConfig, Session, WebContents } from 'electron'
import type { NetworkStartupFailure } from '../../../shared/diagnostics/networkStartupFailure'
import type { ProxySettings } from '../../../shared/network/proxySettings'
import { randomUUID } from 'node:crypto'
import { app, session } from 'electron'
import { NetworkStartupError } from '../../../shared/diagnostics/networkStartupFailure'
import { OutboundProxy, toElectronProxyConfig } from './OutboundProxy'
import { requestThroughHost } from './requestThroughHost'

const UNAVAILABLE_PROXY_URL = 'http://127.0.0.1:0'

export class DesktopNetwork {
readonly #resolver = session.fromPartition(`lexora-proxy-resolver:${randomUUID()}`, { cache: false })
readonly #proxy = new OutboundProxy(url => this.#resolve(url))
Expand All @@ -13,16 +17,26 @@ export class DesktopNetwork {
#sessionConfig: ProxyConfig | null = null
readonly #sessionSetup = new Set<Promise<void>>()
#sessionFailure: unknown
#startupError: NetworkStartupError | null = null

get startupError(): NetworkStartupError | null { return this.#startupError }
get proxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.url }
get sandboxProxyUrl(): string { return this.#startupError ? UNAVAILABLE_PROXY_URL : this.#proxy.sandboxUrl }

get proxyUrl(): string { return this.#proxy.url }
get sandboxProxyUrl(): string { return this.#proxy.sandboxUrl }
readonly assertAvailable = (): void => {
if (this.#startupError)
throw this.#startupError
}

readonly get = (url: string, init?: Pick<RequestInit, 'headers' | 'signal'>): Promise<Response> => requestThroughHost(
session.defaultSession,
{ url, method: 'GET', headers: Object.fromEntries(new Headers(init?.headers)) },
init?.signal ?? AbortSignal.timeout(30_000),
this.authenticateProxy,
)
readonly get = async (url: string, init?: Pick<RequestInit, 'headers' | 'signal'>): Promise<Response> => {
this.assertAvailable()
return requestThroughHost(
session.defaultSession,
{ url, method: 'GET', headers: Object.fromEntries(new Headers(init?.headers)) },
init?.signal ?? AbortSignal.timeout(30_000),
this.authenticateProxy,
)
}

readonly authenticateProxy = (authInfo: AuthInfo, callback: (username?: string, password?: string) => void): boolean => {
if (!authInfo.isProxy || authInfo.host !== '127.0.0.1' || authInfo.port !== this.#proxy.port)
Expand All @@ -32,7 +46,9 @@ export class DesktopNetwork {
}

readonly #onSessionCreated = (created: Session) => {
if (!this.#sessionConfig || created === this.#resolver)
if (created === this.#resolver)
return
if (!this.#sessionConfig)
return
const setup = created.setProxy(this.#sessionConfig)
this.#sessionSetup.add(setup)
Expand All @@ -49,15 +65,30 @@ export class DesktopNetwork {
}

async start(settings: ProxySettings): Promise<void> {
await this.apply(settings)
await this.#proxy.start()
this.#sessionConfig = { mode: 'fixed_servers', proxyRules: this.#proxy.address, proxyBypassRules: '<-loopback>' }
let operation: NetworkStartupFailure['operation'] = 'configure_upstream'
try {
await this.apply(settings)
operation = 'listen'
await this.#proxy.start()
}
catch (cause) {
this.#startupError = new NetworkStartupError(operation, cause)
this.#proxy.disconnect()
}
this.#sessionConfig = { mode: 'fixed_servers', proxyRules: this.#startupError ? this.proxyUrl : this.#proxy.address, proxyBypassRules: '<-loopback>' }
app.on('login', this.#onLogin)
app.on('session-created', this.#onSessionCreated)
await Promise.all([app.setProxy(this.#sessionConfig), session.defaultSession.setProxy(this.#sessionConfig)])
try {
await Promise.all([app.setProxy(this.#sessionConfig), session.defaultSession.setProxy(this.#sessionConfig)])
}
catch (cause) {
throw new NetworkStartupError('configure_sessions', cause)
}
}

async apply(settings: ProxySettings): Promise<void> {
if (this.#startupError)
return
if (settings.mode === this.#settings?.mode && settings.server === this.#settings.server)
return
const operation = this.#updating.then(async () => {
Expand All @@ -71,6 +102,8 @@ export class DesktopNetwork {

async #resolve(url: string): Promise<string> {
await this.#updating
if (this.#startupError)
throw this.#startupError
if (this.#sessionFailure)
throw this.#sessionFailure
return this.#resolver.resolveProxy(url)
Expand Down
8 changes: 7 additions & 1 deletion apps/buddy/electron/main/network/OutboundProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ export class OutboundProxy {

async stop(): Promise<void> {
this.disconnect()
await this.#server.close(true)
try {
await this.#server.close(true)
}
catch (error) {
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ERR_SERVER_NOT_RUNNING')
throw error
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions apps/buddy/electron/main/network/__tests__/OutboundProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ function get(proxy: OutboundProxy, url: string, authenticated = true) {
}

describe('outbound proxy', () => {
it('can clean up before listening and after repeated shutdown', async () => {
const proxy = new OutboundProxy(async () => 'DIRECT')
await expect(proxy.stop()).resolves.toBeUndefined()
await expect(proxy.stop()).resolves.toBeUndefined()
const started = new OutboundProxy(async () => 'DIRECT')
await started.start()
expect(started.port).toBeGreaterThan(0)
await expect(started.stop()).resolves.toBeUndefined()
await expect(started.stop()).resolves.toBeUndefined()
})
it.each([
['lexora', '[::1]:443', 'https://[::1]:443/'],
['lexora-http', 'fixture.invalid:80', 'http://fixture.invalid:80/'],
Expand Down
4 changes: 3 additions & 1 deletion apps/buddy/electron/main/network/registerWebHostRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { HostWebNetwork } from './HostWebNetwork'
import { requestThroughHost } from './requestThroughHost'
import { renderWebDocument } from './WebRenderHost'

export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticateProxy?: ProxyAuthenticator): () => void {
export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticateProxy?: ProxyAuthenticator, assertAvailable?: () => void): () => void {
const isolated = session.fromPartition(`buddy-web-network:${randomUUID()}`, { cache: false })
isolated.setPermissionCheckHandler(() => false)
isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
Expand All @@ -21,6 +21,7 @@ export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticatePro
peer.notify('host.web.chunk', params)
}
const begin = (id: string, timeout: number) => {
assertAvailable?.()
if (disposed || active.has(id) || active.size >= 8)
throw new WebError('WEB_BUSY')
const controller = new AbortController()
Expand All @@ -38,6 +39,7 @@ export function registerWebHostRpc(peer: RuntimeRpcPeerContract, authenticatePro
peer.onRequest('host.web.authorize', async (params) => {
const input = webRenderInputSchema.pick({ url: true }).parse(params)
try {
assertAvailable?.()
await network.authorizePublicUrl(input.url)
return { ok: true }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { describe, expect, it } from 'vitest'
import { applicationDiagnosticSchema, readDiagnosticError } from '../applicationDiagnostic'
import { NetworkStartupError } from '../networkStartupFailure'

describe('safe diagnostic errors', () => {
it('preserves network startup evidence without inferring a cause or copying endpoint details', () => {
const cause = Object.assign(new Error('listen UNKNOWN fixture-private-endpoint'), { code: 'UNKNOWN', errno: -4094, address: 'fixture-private-endpoint', port: 8080 })
const error = new NetworkStartupError('listen', cause)
const diagnostic = readDiagnosticError(error)
expect(diagnostic).toEqual({ errorCode: 'NETWORK_START_FAILED', errorType: 'NetworkStartupError', failure: { kind: 'network_startup', operation: 'listen', systemCode: 'UNKNOWN', errno: -4094 } })
expect(JSON.stringify(diagnostic)).not.toContain('fixture-private')
expect(applicationDiagnosticSchema.safeParse({ event: 'network.start_failed', level: 'warn', ...diagnostic }).success).toBe(true)
expect(applicationDiagnosticSchema.safeParse({ event: 'network.start_failed', level: 'warn', failure: { ...error.failure, address: 'fixture-private-endpoint' } }).success).toBe(false)
expect(new NetworkStartupError('configure_sessions', { code: 'fixture-secret', errno: 'fixture-secret' }).failure).toEqual({ kind: 'network_startup', operation: 'configure_sessions' })
})
it('preserves bounded ACL evidence but rejects identities, paths and arbitrary failure details', () => {
const acl = { reason: 'untrusted_access', aceIndex: 3, aceType: 0, aceFlags: 19, accessMask: 0xFFFFFFFF, principal: 'other' }
const failure = { kind: 'private_directories', operation: 'validate_acl', acl }
Expand Down
9 changes: 5 additions & 4 deletions apps/buddy/shared/diagnostics/applicationDiagnostic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { readLocalChatErrorCode } from '../runtime/localChatError'
import { desktopBootstrapFailureSchema, processExitSchema, rendererLoadFailureSchema } from './desktopStartupDiagnostic'
import { networkStartupFailureSchema } from './networkStartupFailure'
import { privateDirectoryErrorCodeSchema, privateDirectoryFailureSchema } from './privateDirectoryFailure'
import { providerRequestDiagnosticSchema } from './providerRequestDiagnostic'

Expand Down Expand Up @@ -29,7 +30,7 @@ export const applicationDiagnosticSchema = z.object({
durationMs: z.number().finite().nonnegative().optional(),
errorCode: diagnosticCodeSchema.optional(),
errorType: z.string().regex(/^[a-z]\w{0,95}$/i).optional(),
failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema]).optional(),
failure: z.union([privateDirectoryFailureSchema, desktopBootstrapFailureSchema, networkStartupFailureSchema]).optional(),
providerRequest: providerRequestDiagnosticSchema.optional(),
recorderLoss: z.object({ dropped: z.number().int().nonnegative(), failed: z.number().int().nonnegative() }).strict().optional(),
processExit: processExitSchema.optional(),
Expand Down Expand Up @@ -71,11 +72,11 @@ export function readDiagnosticError(error: unknown): DiagnosticError {
if (!errorCode) {
if (privateDirectoryCode.success)
errorCode = privateDirectoryCode.data
else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code))
else if (typeof code === 'string' && ['DESKTOP_BOOTSTRAP_FAILED', 'NETWORK_START_FAILED', 'INITIAL_STATE_UNAVAILABLE', 'POWERSHELL_UNAVAILABLE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC', 'EIO', 'EMFILE', 'ERR_SQLITE_ERROR'].includes(code))
errorCode = code
}
if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED')) {
const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : desktopBootstrapFailureSchema
if (!failure && (privateDirectoryCode.success || code === 'DESKTOP_BOOTSTRAP_FAILED' || code === 'NETWORK_START_FAILED')) {
const schema = privateDirectoryCode.success ? privateDirectoryFailureSchema : code === 'NETWORK_START_FAILED' ? networkStartupFailureSchema : desktopBootstrapFailureSchema
const parsed = schema.safeParse('failure' in current ? current.failure : undefined)
if (parsed.success)
failure = parsed.data
Expand Down
Loading