From 7c93aed6dc2cb0756985dc20f1830ef2f0cf4e25 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:58:55 -0700 Subject: [PATCH] Fix fsync of read-only files on POSIX (#14235) * fix(files): fsync read-only files on POSIX * test(e2e): add golden E2E tests for POSIX profile index fsync Validates that profile index files are properly persisted on POSIX systems, including with restrictive umask settings. These are release-blocking golden tests for Linux and macOS. * test(terminal): wait for fish child ownership before stdin write Fish 4.8 withdraws DECSET 2031 before spawning the child, so the shell-contracts harness could send hello into an intermediate prompt and hang waiting for CHILD-READ. Wait for the child's CHILD-READY marker and answer split DA1/CPR/OSC queries across chunk boundaries. * test(e2e): verify profile index persists to disk with restrictive umask Strengthen the POSIX fsync test to verify the rebuilt index is actually written to disk and has correct permissions under a restrictive umask, not just cached in memory. --- .github/workflows/golden-e2e-experiment.yml | 2 + .github/workflows/release-cut.yml | 10 +- ...package-electron-runtime-contract.test.mjs | 25 +++- package.json | 1 + .../orca-profiles/profile-index-store.test.ts | 22 +++ ...-color-scheme-child-stdin.node-pty.test.ts | 141 ++++++++++++++++-- src/shared/secure-file-fsync-flags.test.ts | 51 ++++++- src/shared/secure-file.ts | 2 +- tests/e2e/golden-posix-fresh-startup.spec.ts | 15 ++ .../golden-posix-profile-index-fsync.spec.ts | 45 ++++++ 10 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/golden-posix-fresh-startup.spec.ts create mode 100644 tests/e2e/golden-posix-profile-index-fsync.spec.ts diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 4591514322e..5a2562bf2ba 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -66,12 +66,14 @@ jobs: run: | xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run golden E2E tests on Windows if: runner.os == 'Windows' diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 5d9e4f29b72..370fbcc0a32 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -783,7 +783,7 @@ jobs: { echo "## Release E2E Signal" echo "" - echo "- Platform golden E2E is release-blocking: terminal rendering on Linux/macOS and fresh startup on Windows." + echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows." echo "- Full E2E runs separately after publication and cannot change the release result." echo "- Terminal rendering release evidence is diagnostic/non-blocking." echo "" @@ -871,11 +871,15 @@ jobs: - name: Run terminal rendering golden on Linux if: runner.os == 'Linux' - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' - run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + run: | + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden - name: Run fresh-startup golden on Windows if: runner.os == 'Windows' diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 2cf5e18af73..c6e96c88b2c 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => { 'build:linux', 'test:e2e', 'test:e2e:terminal-rendering-golden', + 'test:e2e:posix-profile-index-golden', 'test:e2e:terminal-rendering-release-evidence', 'test:e2e:headful' ] @@ -543,6 +544,15 @@ describe('Electron runtime package contract', () => { expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( '@windows-fresh-startup-golden' ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-profile-index-fsync.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + 'golden-posix-fresh-startup.spec.ts' + ) + expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain( + '@posix-profile-index-golden' + ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-opencode-emoji-table-rendering.spec.ts' ) @@ -557,7 +567,11 @@ describe('Electron runtime package contract', () => { expect(goldenRunSteps.get('linux')?.run).toContain( 'pnpm run test:e2e:terminal-rendering-golden' ) + expect(goldenRunSteps.get('linux')?.run).toContain( + 'pnpm run test:e2e:posix-profile-index-golden' + ) expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden') expect(goldenRunSteps.get('windows')).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' @@ -574,9 +588,16 @@ describe('Electron runtime package contract', () => { expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') expect(releaseGoldenJob['continue-on-error']).toBeUndefined() expect(releaseGoldenMatrix).toEqual(goldenMatrix) - expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain( - 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden' + const releaseLinuxRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on Linux' ) + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') + const releaseMacRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run terminal rendering golden on macOS' + ) + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden') const releaseWindowsRunStep = releaseGoldenJob.steps.find( (step) => step.name === 'Run fresh-startup golden on Windows' ) diff --git a/package.json b/package.json index c9cf4a72891..1c2c1deb2df 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index a2d3446a99c..128dc56cea3 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync import { join } from 'node:path' import { tmpdir } from 'node:os' import { + createDefaultLocalOrcaProfile, DEFAULT_LOCAL_ORCA_PROFILE_ID, DEFAULT_LOCAL_ORCA_PROFILE_NAME, ORCA_PROFILE_INDEX_SCHEMA_VERSION, @@ -154,6 +155,27 @@ describe('profile index store', () => { expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile') }) + const posixIt = process.platform === 'win32' ? it.skip : it + posixIt('writes a fresh profile index when umask removes owner-write permission', async () => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + const profile = createDefaultLocalOrcaProfile(1) + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profile.id, + profiles: [profile] + } + const originalUmask = process.umask(0o200) + + try { + expect(() => store.writeProfileIndex(indexPath, index)).not.toThrow() + } finally { + process.umask(originalUmask) + } + + expect(readJson(indexPath)).toEqual(index) + }) + it('recovers a corrupted profile index from the backup copy', async () => { const store = await loadProfileIndexStore() store.ensureActiveOrcaProfile() diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts index a5e52d13e4b..b3b949addbe 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts @@ -39,6 +39,20 @@ const COLOR_SCHEME_REPORT_PREFIX = '\x1b[?997' const ARM_2031 = '\x1b[?2031h' const WITHDRAW_2031 = '\x1b[?2031l' const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const DA1_REPLY = '\x1b[?62;4;6;22c' +const DSR_REPLY = '\x1b[1;1R' +const OSC_10_REPLY = '\x1b]10;rgb:eeee/eeee/eeee\x1b\\' +const OSC_11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\' +const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [ + ['\x1b[0c', DA1_REPLY], + ['\x1b[c', DA1_REPLY], + ['\x1b[6n', DSR_REPLY], + ['\x1b]10;?\x07', OSC_10_REPLY], + ['\x1b]10;?\x1b\\', OSC_10_REPLY], + ['\x1b]11;?\x07', OSC_11_REPLY], + ['\x1b]11;?\x1b\\', OSC_11_REPLY] +] +const QUERY_CARRY_LENGTH = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length)) - 1 type MutableState = Record let mockStoreState: MutableState = {} @@ -216,6 +230,114 @@ async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise void): { + accept: (chunk: string) => void + getCarryLength: () => number +} { + let carry = '' + return { + accept: (chunk) => { + const carriedLength = carry.length + const scan = carry + chunk + carry = scan.slice(-QUERY_CARRY_LENGTH) + for (let at = 0; at < scan.length; at += 1) { + for (const [query, reply] of TERMINAL_QUERY_REPLIES) { + if (!scan.startsWith(query, at)) { + continue + } + if (at + query.length > carriedLength) { + write(reply) + } + at += query.length - 1 + break + } + } + }, + getCarryLength: () => carry.length + } +} + +function allChunkPartitions(input: string): string[][] { + if (input.length === 0) { + return [[]] + } + const partitions: string[][] = [] + for (let end = 1; end <= input.length; end += 1) { + for (const suffix of allChunkPartitions(input.slice(end))) { + partitions.push([input.slice(0, end), ...suffix]) + } + } + return partitions +} + +function collectTerminalQueryReplies(chunks: readonly string[]): string[] { + const replies: string[] = [] + const responder = createTerminalQueryResponder((reply) => replies.push(reply)) + chunks.forEach(responder.accept) + return replies +} + +describe('terminal query responder', () => { + it.each([ + ['OSC 10 BEL', '\x1b]10;?\x07', OSC_10_REPLY], + ['OSC 10 ST', '\x1b]10;?\x1b\\', OSC_10_REPLY], + ['OSC 11 BEL', '\x1b]11;?\x07', OSC_11_REPLY], + ['OSC 11 ST', '\x1b]11;?\x1b\\', OSC_11_REPLY] + ])('answers each complete %s query once across every partition', (_label, query, reply) => { + for (const chunks of allChunkPartitions(query)) { + expect(collectTerminalQueryReplies(chunks)).toEqual([reply]) + } + }) + + it.each([ + ['DA1 with omitted parameter', '\x1b[c', DA1_REPLY], + ['DA1 with zero parameter', '\x1b[0c', DA1_REPLY], + ['DSR cursor position', '\x1b[6n', DSR_REPLY] + ])('preserves %s across every partition', (_label, query, reply) => { + for (const chunks of allChunkPartitions(query)) { + expect(collectTerminalQueryReplies(chunks)).toEqual([reply]) + } + }) + + it.each([ + ['unterminated OSC 10', '\x1b]10;?'], + ['unterminated OSC 11 ST', '\x1b]11;?\x1b'], + ['OSC 10 extra query marker', '\x1b]10;??\x07'], + ['OSC 11 stacked query', '\x1b]11;?;?\x07'], + ['OSC 10 malformed body', '\x1b]10;?x\x07'], + ['OSC 11 malformed ST body', '\x1b]11;?x\x1b\\'], + ['OSC slot lookalike', '\x1b]110;?\x07'] + ])('rejects %s across every partition', (_label, input) => { + for (const chunks of allChunkPartitions(input)) { + expect(collectTerminalQueryReplies(chunks)).toEqual([]) + } + }) + + it('answers concatenated queries once each in source order across every partition', () => { + const input = '\x1b]10;?\x07\x1b]11;?\x07' + for (const chunks of allChunkPartitions(input)) { + expect(collectTerminalQueryReplies(chunks)).toEqual([OSC_10_REPLY, OSC_11_REPLY]) + } + }) + + it('bounds carry while rejecting a long unterminated query lookalike', () => { + const replies: string[] = [] + const responder = createTerminalQueryResponder((reply) => replies.push(reply)) + let maxCarryLength = 0 + for (const fragment of `\x1b]10;?${'x'.repeat(10_000)}`) { + responder.accept(fragment) + maxCarryLength = Math.max(maxCarryLength, responder.getCarryLength()) + } + for (const fragment of '\x1b]10;?\x07') { + responder.accept(fragment) + } + + expect(maxCarryLength).toBe(QUERY_CARRY_LENGTH) + expect(responder.getCarryLength()).toBeLessThanOrEqual(QUERY_CARRY_LENGTH) + expect(replies).toEqual([OSC_10_REPLY]) + }) +}) + describe('fish never receives a color-scheme report it did not query (#9993)', () => { let configHome: string | null = null @@ -375,7 +497,8 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( " if (!buffered.includes('\\n')) return\n" + " process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" + ' process.exit(0)\n' + - '})\n' + '})\n' + + "process.stdout.write('CHILD-READY\\n')\n" ) const term = nodePty.spawn(FISH_BIN as string, ['-l', '-i'], { @@ -398,20 +521,16 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( let rendered = '' const { transport, sent, emit } = createPtyBackedTransport((data) => term.write(data)) + const answerTerminalQueries = createTerminalQueryResponder((reply) => term.write(reply)) transportFactoryQueue.push(transport) // Why answered here: no real xterm is attached, and fish blocks its first prompt ~10s // on DA1 and re-probes every prompt. These are harness bytes, never renderer output. term.onData((chunk) => { rendered += chunk - if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) { - term.write('\x1b[?62;4;6;22c') - } - if (chunk.includes('\x1b[6n')) { - term.write('\x1b[1;1R') - } - if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { - term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') + // Maximal fragmentation keeps query handling independent of node-pty chunk boundaries. + for (const fragment of chunk) { + answerTerminalQueries.accept(fragment) } emit(chunk) }) @@ -444,8 +563,10 @@ describe('fish never receives a color-scheme report it did not query (#9993)', ( // Withdrawal #1: `sleep` owns the tty now, so the next line is typed ahead. expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 1, 5_000)).toBe(true) term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r') - // Withdrawal #2: fish re-armed for the prompt and handed the tty to the child. + // Withdrawal #2: fish re-armed for the prompt and accepted the child command. expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 2, 5_000)).toBe(true) + // DECSET withdrawal precedes fish's child spawn; the child's marker is the ownership signal. + expect(await waitUntil(() => rendered.includes('CHILD-READY'), 5_000)).toBe(true) const renderedBeforeChildInput = rendered.length // Canonical mode buffers this in the tty, so it queues behind anything already diff --git a/src/shared/secure-file-fsync-flags.test.ts b/src/shared/secure-file-fsync-flags.test.ts index 1c167bffdad..15505151aef 100644 --- a/src/shared/secure-file-fsync-flags.test.ts +++ b/src/shared/secure-file-fsync-flags.test.ts @@ -1,23 +1,33 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -const openedPaths = vi.hoisted(() => [] as { path: string; flags: string | number }[]) +const openedPaths = vi.hoisted( + () => [] as { path: string; flags: string | number; ownerWritable: boolean }[] +) vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, openSync: (path: NodeFs.PathLike, flags: string | number, mode?: NodeFs.Mode) => { - openedPaths.push({ path: String(path), flags }) + openedPaths.push({ + path: String(path), + flags, + ownerWritable: Boolean(actual.statSync(path).mode & 0o200) + }) return actual.openSync(path, flags, mode) } } }) -import { bestEffortFsyncDirectorySync, fsyncFileSync } from './secure-file' +import { + bestEffortFsyncDirectorySync, + fsyncFileSync, + writeDurableSecureJsonFile +} from './secure-file' const createdPaths: string[] = [] @@ -29,7 +39,8 @@ afterEach(() => { }) describe('secure file fsync flags', () => { - it('opens files read/write before fsync', () => { + const windowsIt = process.platform === 'win32' ? it : it.skip + windowsIt('opens files read/write before fsync on Windows', () => { const directory = mkdtempSync(join(tmpdir(), 'orca-file-fsync-')) createdPaths.push(directory) const path = join(directory, 'record.json') @@ -37,16 +48,42 @@ describe('secure file fsync flags', () => { fsyncFileSync(path) - expect(openedPaths).toEqual([{ path, flags: 'r+' }]) + expect(openedPaths).toMatchObject([{ path, flags: 'r+' }]) }) const posixIt = process.platform === 'win32' ? it.skip : it + posixIt('fsyncs files without owner-write permission', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-file-fsync-read-only-')) + createdPaths.push(directory) + const path = join(directory, 'record.json') + writeFileSync(path, '{}') + chmodSync(path, 0o400) + + expect(() => fsyncFileSync(path)).not.toThrow() + expect(openedPaths).toEqual([{ path, flags: 'r', ownerWritable: false }]) + }) + + posixIt('durably writes when umask removes owner-write from the temporary file', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-durable-file-fsync-read-only-')) + createdPaths.push(directory) + const path = join(directory, 'record.json') + const originalUmask = process.umask(0o200) + + try { + expect(() => writeDurableSecureJsonFile(path, { ok: true })).not.toThrow() + } finally { + process.umask(originalUmask) + } + + expect(openedPaths[0]).toMatchObject({ flags: 'r', ownerWritable: false }) + }) + posixIt('opens directories read-only before fsync', () => { const directory = mkdtempSync(join(tmpdir(), 'orca-directory-fsync-')) createdPaths.push(directory) bestEffortFsyncDirectorySync(directory) - expect(openedPaths).toEqual([{ path: directory, flags: 'r' }]) + expect(openedPaths).toMatchObject([{ path: directory, flags: 'r' }]) }) }) diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index 7b2f646c540..639630c7c65 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -144,7 +144,7 @@ function fsyncPathSync(path: string, flags: 'r' | 'r+'): void { export function fsyncFileSync(path: string): void { // FlushFileBuffers requires a write-capable handle on Windows. - fsyncPathSync(path, 'r+') + fsyncPathSync(path, process.platform === 'win32' ? 'r+' : 'r') } export function bestEffortFsyncDirectorySync(directory: string): void { diff --git a/tests/e2e/golden-posix-fresh-startup.spec.ts b/tests/e2e/golden-posix-fresh-startup.spec.ts new file mode 100644 index 00000000000..ba30f550498 --- /dev/null +++ b/tests/e2e/golden-posix-fresh-startup.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from './helpers/orca-app' + +test.skip(process.platform === 'win32', 'POSIX fresh-startup golden; Windows has its own suite') + +test.describe('POSIX fresh startup golden', () => { + test.use({ dismissOnboarding: false, seedTestRepo: false }) + + test('fresh profile reaches onboarding normally @posix-profile-index-golden', async ({ + orcaPage + }) => { + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 30_000 + }) + }) +}) diff --git a/tests/e2e/golden-posix-profile-index-fsync.spec.ts b/tests/e2e/golden-posix-profile-index-fsync.spec.ts new file mode 100644 index 00000000000..d3d34d6743d --- /dev/null +++ b/tests/e2e/golden-posix-profile-index-fsync.spec.ts @@ -0,0 +1,45 @@ +import { readFileSync, rmSync, statSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) +test.skip(process.platform === 'win32', 'Restrictive-umask fsync regression is POSIX-only') + +test('recreates a fresh profile index on disk with a restrictive umask @posix-profile-index-golden', async ({ + electronApp, + orcaPage +}) => { + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const indexPath = path.join(userDataDir, 'orca-profile-index.json') + // Why: the index tmp file is written with 0o666 & ~umask, so a umask that clears + // owner-write is what made the pre-fix fsync open(path, 'r+') fail with EACCES. + const originalUmask = await electronApp.evaluate(() => process.umask(0o200)) + + try { + rmSync(indexPath, { force: true }) + rmSync(`${indexPath}.bak`, { force: true }) + // Why: orcaProfiles:list reads the index from disk on every call and rebuilds + // it when missing, so this drives the real create + fsync + rename path. + const listed = await orcaPage.evaluate(async () => { + const result = await window.api.orcaProfiles.list() + window.__store!.getState().openSettingsPage() + return result + }) + expect(listed.profiles.length).toBeGreaterThan(0) + + // The rebuilt index must be on disk and complete — a cached in-memory list is + // not proof that the restrictive-umask write survived fsync. + const persisted = JSON.parse(readFileSync(indexPath, 'utf-8')) + expect(persisted.activeProfileId).toBe(listed.activeProfileId) + expect(persisted.profiles.map((profile: { id: string }) => profile.id)).toEqual( + listed.profiles.map((profile) => profile.id) + ) + // Owner-write cleared proves the file was created under the restrictive umask + // rather than left over from startup's normal-umask write. + expect(statSync(indexPath).mode & 0o200).toBe(0) + } finally { + await electronApp.evaluate((_electron, umask) => process.umask(umask), originalUmask) + } + + await expect(orcaPage.getByPlaceholder('Search settings')).toBeVisible() +})