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
41 changes: 40 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@ jobs:
. != "tests/e2e/ssh-localhost.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and
. != "tests/e2e/orcad-serve-mode-switch.spec.ts" and
. != "tests/e2e/ssh-orcad-auto-convert.spec.ts"
. != "tests/e2e/ssh-orcad-auto-convert.spec.ts" and
. != "tests/e2e/windows-missing-appdata-startup.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
Expand Down Expand Up @@ -552,6 +553,44 @@ jobs:
retention-days: 7
if-no-files-found: ignore

# A profile-less Windows session has no roaming AppData; Electron 43 used to crash natively there.
windows-missing-appdata-startup:
name: orca serve starts without AppData on Windows
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts')
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Build the e2e app and CLI
shell: bash
run: |
pnpm exec electron-vite build --mode e2e
pnpm run build:cli
- name: Start serve with no AppData folder
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_STARTUP_DIAGNOSTICS: '1'
ELECTRON_ENABLE_LOGGING: '1'
ELECTRON_ENABLE_STACK_DUMPING: '1'
run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/windows-missing-appdata-startup.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
name: windows-missing-appdata-startup-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore

# #24979 on a real host: a relay-era Docker host converts to managed orcad on connect. Needs the
# orcad template for the fixture's target (Debian, linux-x64-glibc), which only this job builds.
orcad-auto-convert-docker:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@ jobs:
src/main/cursor/hook-service.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/startup/windows-install-dir-acl-repair.win32.test.ts
src/main/startup/windows-app-data-path.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
src/shared/secure-file-fsync-flags.test.ts
Expand Down
5 changes: 4 additions & 1 deletion config/scripts/ci-e2e-job-selection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.t
export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts'
// Needs the orcad template for its host's target, which only its own job builds.
export const ORCAD_AUTO_CONVERT_E2E_SPEC = 'tests/e2e/ssh-orcad-auto-convert.spec.ts'
// Windows-only; its own job runs it on a Windows runner.
export const WINDOWS_MISSING_APPDATA_E2E_SPEC = 'tests/e2e/windows-missing-appdata-startup.spec.ts'
export const DEDICATED_E2E_SPECS = [
...DOCKER_SSH_E2E_SPECS,
NODE_NETWORK_E2E_SPEC,
LOCALHOST_SSH_E2E_SPEC,
NATIVE_IME_E2E_SPEC,
ORCAD_SERVE_MODE_SWITCH_E2E_SPEC,
ORCAD_AUTO_CONVERT_E2E_SPEC
ORCAD_AUTO_CONVERT_E2E_SPEC,
WINDOWS_MISSING_APPDATA_E2E_SPEC
]
const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS)
const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS)
Expand Down
7 changes: 7 additions & 0 deletions config/scripts/pr-e2e-source-routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
{
id: 'startup.windows-missing-appdata',
specs: ['tests/e2e/windows-missing-appdata-startup.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/main\/startup\/(?:windows-app-data-path|main-process-preflight)\.ts$/.test(file)
},
{
id: 'ssh.orcad-auto-convert',
specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'],
Expand Down
1 change: 1 addition & 0 deletions config/scripts/release-cut-token-permissions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/e2e.yml#windows-missing-appdata-startup': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/node-server-tests.yml#changes': { contents: 'read' },
'.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' },
Expand Down
3 changes: 2 additions & 1 deletion config/scripts/ssh-docker-ci-sharding.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', ()
'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts',
'tests/e2e/ssh-localhost.spec.ts',
'tests/e2e/ssh-orcad-auto-convert.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/windows-missing-appdata-startup.spec.ts'
])
})

Expand Down
3 changes: 3 additions & 0 deletions src/main/startup/main-process-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-
import { acquireProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access'
import { getActiveProfileStateLocation } from '../persistence/profile-state/profile-state-active-location'
import { handleMainProcessPreflightFailure } from './main-process-preflight-failure'
import { ensureWindowsAppDataPath } from './windows-app-data-path'

export type MainProcessPreflightOptions = {
focusExistingWindow: () => void
Expand All @@ -125,6 +126,8 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b
}

function initializeMainProcessPreflight(options: MainProcessPreflightOptions): boolean {
// Why first: every step below, recovery and the instance lock included, may resolve userData.
ensureWindowsAppDataPath(app)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call runs before state.isServeMode is assigned (line 151). If ensureWindowsAppDataPath throws, handleMainProcessPreflightFailure reads state.isServeMode === false and, on Windows, routes to dialog.showErrorBox before app.whenReady() rather than the clean serve exit 1 the PR describes. A normal orca serve child does not set ORCA_BACKGROUND_LAUNCH (src/cli/runtime/launch.ts:119-166), so isBackgroundLaunch() is false and the dialog is not suppressed.

Technical details
# Serve-mode failure is classified as desktop

## Affected sites
- `src/main/startup/main-process-preflight.ts:130` — `ensureWindowsAppDataPath(app)` can throw before `state.isServeMode` is set at line 151.
- `src/main/startup/main-process-preflight-failure.ts:11` — `shouldShowDialog = !state.isServeMode && !isBackgroundLaunch()`.
- `src/main/startup/main-process-state.ts:145` — `isServeMode` defaults to `false`.
- `src/main/window/foreground-activation-policy.ts:23` — `isBackgroundLaunch()` only reads `ORCA_BACKGROUND_LAUNCH`/E2E env.

## Required outcome
- A failure on the newly added Windows path during a `--serve` launch exits 1 without attempting a modal error box, matching the PR's stated behavior.

## Suggested approach (optional)
- Derive `state.isServeMode` before this call, or pass the serve flag into `handleMainProcessPreflightFailure` so the failure path does not depend on an assignment that has not run yet.

if (runProfileStateRecoveryPreflight()) {
return false
}
Expand Down
135 changes: 135 additions & 0 deletions src/main/startup/windows-app-data-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, win32 } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import {
deriveWindowsAppDataPath,
ensureWindowsAppDataPath,
type WindowsAppDataPathHost
} from './windows-app-data-path'

const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/

function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & {
calls: string[]
paths: Map<string, string>
} {
const calls: string[] = []
const paths = new Map<string, string>()
return {
calls,
paths,
getName: () => 'orca',
getPath: (name) => {
calls.push(`get:${name}`)
const overridden = paths.get(name)
if (overridden) {
return overridden
}
if (nativeAppData instanceof Error) {
throw nativeAppData
}
return nativeAppData
},
setPath: (name, value) => {
calls.push(`set:${name}`)
paths.set(name, value)
}
}
}

const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
removeTreeSync(dir)
}
})

describe('deriveWindowsAppDataPath', () => {
it('prefers APPDATA', () => {
expect(
deriveWindowsAppDataPath({
APPDATA: 'D:\\Profiles\\me\\Roaming',
USERPROFILE: 'C:\\Users\\me'
})
).toBe('D:\\Profiles\\me\\Roaming')
})

it('falls back to USERPROFILE\\AppData\\Roaming when APPDATA is unset or relative', () => {
expect(deriveWindowsAppDataPath({ USERPROFILE: 'C:\\Users\\me' })).toBe(
'C:\\Users\\me\\AppData\\Roaming'
)
expect(deriveWindowsAppDataPath({ APPDATA: 'Roaming', USERPROFILE: 'C:\\Users\\me' })).toBe(
'C:\\Users\\me\\AppData\\Roaming'
)
})

it('throws a readable error when neither variable is usable', () => {
expect(() => deriveWindowsAppDataPath({})).toThrow(ERROR_MESSAGE)
expect(() => deriveWindowsAppDataPath({ APPDATA: ' ', USERPROFILE: '' })).toThrow(ERROR_MESSAGE)
})
})

describe('ensureWindowsAppDataPath', () => {
it('does nothing off Windows', () => {
const host = createHost(new Error('unused'))
ensureWindowsAppDataPath(host, {}, 'darwin')
expect(host.calls).toEqual([])
})

it('keeps the native appData and pins userData to the path Electron would derive', () => {
const host = createHost('C:\\Users\\me\\AppData\\Roaming')
ensureWindowsAppDataPath(host, { APPDATA: 'D:\\ignored' }, 'win32')
expect(host.calls).toEqual(['get:appData', 'set:userData'])
expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca')
})

it('creates and sets appData from the environment before userData when the lookup throws', () => {
// Why a real dir: the fallback must mkdir a folder that a profile-less session never created.
const root = mkdtempSync(join(tmpdir(), 'orca-appdata-'))
tempDirs.push(root)
const missingAppData = join(root, 'missing', 'Roaming')
const host = createHost(new Error('Failed to get appData path'))

ensureWindowsAppDataPath(host, { APPDATA: missingAppData }, 'win32')

expect(host.calls).toEqual(['get:appData', 'set:appData', 'set:userData'])
expect(host.paths.get('appData')).toBe(missingAppData)
expect(host.paths.get('userData')).toBe(win32.join(missingAppData, 'orca'))
expect(existsSync(missingAppData)).toBe(true)
})

it('treats an empty native appData like a failed lookup', () => {
const host = createHost('')
expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE)
expect(host.calls).toEqual(['get:appData'])
})

it('fails with a readable error, setting nothing, when both variables are missing', () => {
const host = createHost(new Error('Failed to get appData path'))
expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE)
expect(host.paths.size).toBe(0)
})
})

describe('startup ordering', () => {
it('pins Windows appData before any preflight step can resolve userData', () => {
const source = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-preflight.ts'),
'utf8'
)
const body = source.slice(source.indexOf('function initializeMainProcessPreflight('))
const ensure = body.indexOf('ensureWindowsAppDataPath(app)')
expect(ensure).toBeGreaterThan(0)
for (const laterStep of [
'runProfileStateRecoveryPreflight()',
'maybeRedirectCliLaunch(',
'configureDevUserDataPath(',
'configureOrcaUserDataPathEnv()',
'startCrashpadCapture()'
]) {
expect(body.indexOf(laterStep)).toBeGreaterThan(ensure)
}
})
})
55 changes: 55 additions & 0 deletions src/main/startup/windows-app-data-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { mkdirSync } from 'node:fs'
import { win32 } from 'node:path'

export type WindowsAppDataPathHost = {
getPath(name: 'appData'): string
setPath(name: 'appData' | 'userData', value: string): void
getName(): string
}

/** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */
export function deriveWindowsAppDataPath(env: NodeJS.ProcessEnv): string {
const appData = env.APPDATA?.trim()
if (appData && win32.isAbsolute(appData)) {
return appData
}
const userProfile = env.USERPROFILE?.trim()
if (userProfile && win32.isAbsolute(userProfile)) {
return win32.join(userProfile, 'AppData', 'Roaming')
}
throw new Error(
'Orca could not find the Windows roaming AppData folder: Windows did not report it, and neither APPDATA nor USERPROFILE is set.'
)
}

function readNativeAppDataPath(host: WindowsAppDataPathHost): string | null {
try {
return host.getPath('appData') || null
} catch {
return null
}
}

/**
* Pins appData and userData before anything resolves userData on Windows.
*
* Why: when Electron cannot resolve or create userData itself (no loaded profile, e.g. over SSH), it falls
* through to Chromium's provider, which dereferences null and crashes natively before crashpad connects.
*/
export function ensureWindowsAppDataPath(
host: WindowsAppDataPathHost,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): void {
if (platform !== 'win32') {
return
}
let appData = readNativeAppDataPath(host)
if (!appData) {
appData = deriveWindowsAppDataPath(env)
mkdirSync(appData, { recursive: true })
host.setPath('appData', appData)
}
// Same value Electron's own provider computes; setting it skips that provider entirely.
host.setPath('userData', win32.join(appData, host.getName()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setPath('userData', …) runs unconditionally on Windows, so it overwrites a --user-data-dir supplied on the command line. Chromium applies that switch before the main script runs, and app.setPath replaces the same DIR_USER_DATA override — so before this PR the switch won, and now the pinned value wins. The orcad Electron browser sidecar spawns the app with --user-data-dir=<temp> and polls that exact temp dir for orca-runtime.json; after this change the file lands under %APPDATA%\Orca and the sidecar's 120s start timeout fires.

Technical details
# `--user-data-dir` is silently overridden on Windows

## Affected sites
- `src/main/startup/windows-app-data-path.ts:54` — pins `userData` even when an explicit `--user-data-dir` is present.
- `src/main/orcad/electron-serve-browser-process.ts:115` — spawns the installed Orca executable with `--user-data-dir=${userDataPath}` (a `mkdtemp` temp dir).
- `src/main/orcad/electron-serve-browser-process.ts:128` — `readRuntimeMetadata(userDataPath)` polls that temp dir; the child writes `orca-runtime.json` under `app.getPath('userData')` via `configureOrcaUserDataPathEnv` (`src/main/startup/configure-process.ts:234`) and `writeRuntimeMetadata` (`src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts:150`).
- `src/main/orcad/orcad-browser-provider.ts:87-93,176-179` — the sidecar path is Windows-capable (`%LOCALAPPDATA%\Programs\Orca\Orca.exe`).
- `config/scripts/runtime-serve-terminal-smoke.mjs:194` — same switch, no env override.

## Evidence
- Electron applies `--user-data-dir` in `ElectronMainDelegate::PreSandboxStartup()` via `PathService::OverrideAndCreateIfNeeded(chrome::DIR_USER_DATA, …)` before the JS main script runs (https://github.com/electron/electron/blob/main/shell/app/electron_main_delegate.cc).
- `app.setPath('userData', x)` overrides the same key (https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_app.cc). `appData` is a different key (`base::DIR_ROAMING_APP_DATA`), so `--user-data-dir` does not change `getPath('appData')`.
- Neither `ORCA_E2E_USER_DATA_DIR` nor `ORCA_DEV_USER_DATA_PATH` is set on this spawn (`electronServeEnvironment()` strips only `AGENT_BROWSER_*`), so `configureDevUserDataPath` does not restore it.

## Required outcome
- A launch that already carries an explicit `--user-data-dir` must keep resolving `userData` to that directory, while the profile-less `appData` hardening still applies.

## Suggested approach (optional)
- Detect `--user-data-dir` in argv and skip pinning `userData` in that case. Chromium's override already bypasses the crashing provider, so `getPath('userData')` is safe there; still pin/derive `appData` if that lookup fails.

## Open questions for the human (optional)
- Is `--user-data-dir` considered a supported launch contract, or only internal test/orcad tooling? The orcad browser sidecar relies on it in production.

}
Loading
Loading