diff --git a/integrations/test-failure-explorer/src/report-store.test.ts b/integrations/test-failure-explorer/src/report-store.test.ts index f8eb57eb454..657c5644309 100644 --- a/integrations/test-failure-explorer/src/report-store.test.ts +++ b/integrations/test-failure-explorer/src/report-store.test.ts @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MAX_SNAPSHOT_BYTES, MAX_SOURCE_BYTES } from './contracts.js'; import { ReportStore } from './report-store.js'; import { parseVitestReport } from './vitest-report.js'; +import { arePermissionBitsEnforced } from './test-utils/permission-bits.js'; const roots: string[] = []; async function workspace(): Promise { @@ -319,7 +320,7 @@ describe('workspace report snapshots', () => { expect(await readFile(path, 'utf8')).toBe(altered); }); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'reports an actually unwritable cache without claiming an import', async () => { const root = await workspace(); diff --git a/integrations/test-failure-explorer/src/test-utils/permission-bits.ts b/integrations/test-failure-explorer/src/test-utils/permission-bits.ts new file mode 100644 index 00000000000..adfa456fea6 --- /dev/null +++ b/integrations/test-failure-explorer/src/test-utils/permission-bits.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { expect } from 'vitest'; + +/** The node:fs calls the probe makes. */ +export interface ProbeFs { + mkdtempSync(prefix: string): string; + chmodSync(path: string, mode: number): void; + readdirSync(path: string): unknown; + writeFileSync(path: string, data: string): void; + rmSync(path: string, options: { recursive: boolean; force: boolean }): void; +} + +// The real node:fs and node:os, even when the test file that asks has +// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded +// through Node's require. A mocked fs would answer for the mock, not the +// kernel. A vi.spyOn on node:fs's default export at collection time would +// still reach the probe, since that export is this same module object. +const nodeRequire = createRequire(import.meta.url); +const realFs = nodeRequire('node:fs') as typeof import('node:fs'); +const realOs = nodeRequire('node:os') as typeof import('node:os'); + +/** + * Whether file permission bits bind this process, for tests that force a + * failure with `chmod`. They do not on Windows, which ignores Unix-style + * bits, nor for root, which bypasses them (on Linux through + * CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both + * capabilities when it runs unit tests as root (package-scripts.test.js + * pins that), so asking the kernel, rather than checking for uid 0, keeps + * those tests running there. A probe that cannot measure also answers + * false, and says so on stderr. + * `fs` is for this helper's own tests; every caller probes the real one. + */ +export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean { + if (process.platform === 'win32') return false; + let dir: string | undefined; + try { + dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-')); + fs.chmodSync(dir, 0o000); + const probe = dir; + return ( + refused(() => fs.readdirSync(probe)) && + refused(() => fs.writeFileSync(path.join(probe, 'probe'), '')) + ); + } catch (error) { + // A temp directory that cannot hold the probe, or refuses chmod, + // cannot hold these tests' fixtures either, though after a transient + // ENOSPC it may hold them again by the time they run, so say which it + // was rather than skip silently. + inconclusive(error); + return false; + } finally { + if (dir !== undefined) { + try { + fs.chmodSync(dir, 0o700); + } catch { + // Best effort, like the removal below: newer Node versions refuse + // to remove even an empty mode-000 directory. + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort: a leftover probe must not fail the test file. + } + } + } +} + +function refused(attempt: () => unknown): boolean { + try { + attempt(); + return false; + } catch (error) { + if (errnoOf(error) === 'EACCES') return true; + inconclusive(error); + return false; + } +} + +function errnoOf(error: unknown): string | undefined { + const code = attempt( + () => (error as NodeJS.ErrnoException | null | undefined)?.code, + ); + return typeof code === 'string' ? code : undefined; +} + +/** + * Names a probe that could not measure, and the test file that asked, so + * the skips it causes are not mistaken for a Windows or root run. vitest's + * `silent: true` drops console output, so this writes to stderr directly. + * Under GitHub Actions the line opens with a workflow command, so the skips + * show in the check UI and not only deep in the log. + */ +function inconclusive(error: unknown): void { + try { + process.stderr.write( + `${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` + + `[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` + + 'treating permission bits as unenforced, so chmod-based tests skip\n', + ); + } catch { + // A diagnostic must never fail the test file it is meant to explain. + } +} + +function causeOf(error: unknown): string { + const code = errnoOf(error); + if (code === undefined) { + return ( + attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value' + ); + } + const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall); + return typeof syscall === 'string' ? `${code} in ${syscall}` : code; +} + +function askedBy(): string { + const testPath = attempt(() => expect.getState().testPath); + if (!testPath) return ''; + return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`; +} + +function attempt(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} diff --git a/packages/browser-use/src/native-host-installer.test.ts b/packages/browser-use/src/native-host-installer.test.ts index 1efb05dfa2a..aa2ff5e6e5a 100644 --- a/packages/browser-use/src/native-host-installer.test.ts +++ b/packages/browser-use/src/native-host-installer.test.ts @@ -26,6 +26,7 @@ import { CHROME_NATIVE_HOST_NAME, CHROME_NATIVE_HOST_REVISION, } from './bridge/protocol.js'; +import { arePermissionBitsEnforced } from './test-utils/permission-bits.js'; const roots: string[] = []; @@ -178,9 +179,7 @@ describe('Chrome Native Host installer', () => { ); }); - it - .skipIf(process.platform === 'win32' || process.getuid?.() === 0) - .each(['launcher', 'manifest'])( + it.skipIf(!arePermissionBitsEnforced()).each(['launcher', 'manifest'])( 'preserves an unreadable %s during install, status and uninstall', async (kind) => { const fixture = createFixture(); @@ -265,7 +264,7 @@ describe('Chrome Native Host installer', () => { }); it - .skipIf(process.platform === 'win32' || process.getuid?.() === 0) + .skipIf(!arePermissionBitsEnforced()) .each(['launcher', 'manifest'] as const)( 'keeps a correct %s usable in a read-only directory', async (kind) => { @@ -313,7 +312,7 @@ describe('Chrome Native Host installer', () => { }, ); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'propagates a required launcher update failure and updates when writable', async () => { const fixture = createFixture(); diff --git a/packages/browser-use/src/runtime.test.ts b/packages/browser-use/src/runtime.test.ts index 8d290209cbd..70f42a93dc6 100644 --- a/packages/browser-use/src/runtime.test.ts +++ b/packages/browser-use/src/runtime.test.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { arePermissionBitsEnforced } from './test-utils/permission-bits.js'; const installer = vi.hoisted(() => ({ ensure: vi.fn(async () => ({ @@ -93,7 +94,7 @@ describe('createBrowserBackend', () => { }); }); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'initializes when Chrome preference files are unreadable', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); diff --git a/packages/browser-use/src/test-utils/permission-bits.mocked-fs.test.ts b/packages/browser-use/src/test-utils/permission-bits.mocked-fs.test.ts new file mode 100644 index 00000000000..2c205c55ce8 --- /dev/null +++ b/packages/browser-use/src/test-utils/permission-bits.mocked-fs.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import mockedFsDefault, * as mockedFs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, it, vi } from 'vitest'; + +import { arePermissionBitsEnforced } from './permission-bits.js'; + +// A test file that replaces node:fs, as some callers of the probe do, must +// not change its answer: the probe asks the kernel, not the mock. The test +// makes the mock answer the opposite of the kernel, so a probe that read the +// mock fails here whoever runs it. +const mocked = vi.hoisted(() => ({ + mkdtempSync: vi.fn(), + readdirSync: vi.fn(), + writeFileSync: vi.fn(), +})); +// Both the named exports and the default one: a probe reading either would +// answer for the mock. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...mocked, default: { ...actual, ...mocked } }; +}); + +const realFs = createRequire(import.meta.url)( + 'node:fs', +) as typeof import('node:fs'); + +function refusedWithEacces(attempt: () => unknown): boolean { + try { + attempt(); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EACCES'; + } +} + +it.skipIf(process.platform === 'win32')( + 'answers for the kernel while the test file mocks node:fs', + () => { + // The mock is in place, both ways, or this test would pass without + // pinning anything. Asked of the graph's node:fs, never of realFs, + // which the mock never reaches. + expect(mockedFs.mkdtempSync).toBe(mocked.mkdtempSync); + expect(mockedFsDefault.mkdtempSync).toBe(mocked.mkdtempSync); + const dir = realFs.mkdtempSync(path.join(os.tmpdir(), 'locked-')); + realFs.chmodSync(dir, 0o000); + let enforced: boolean; + try { + enforced = + refusedWithEacces(() => realFs.readdirSync(dir)) && + refusedWithEacces(() => + realFs.writeFileSync(path.join(dir, 'probe'), ''), + ); + } finally { + realFs.chmodSync(dir, 0o700); + realFs.rmSync(dir, { recursive: true, force: true }); + } + mocked.mkdtempSync.mockImplementation((prefix: string) => + realFs.mkdtempSync(prefix), + ); + if (enforced) { + // The mock lists and writes freely: it would answer false. + mocked.readdirSync.mockReturnValue([]); + } else { + // The mock refuses both: it would answer true. + const eacces = () => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); + }; + mocked.readdirSync.mockImplementation(eacces); + mocked.writeFileSync.mockImplementation(eacces); + } + expect(arePermissionBitsEnforced()).toBe(enforced); + }, +); diff --git a/packages/browser-use/src/test-utils/permission-bits.test.ts b/packages/browser-use/src/test-utils/permission-bits.test.ts new file mode 100644 index 00000000000..4ab10cb12c7 --- /dev/null +++ b/packages/browser-use/src/test-utils/permission-bits.test.ts @@ -0,0 +1,310 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; + +import { arePermissionBitsEnforced } from './permission-bits.js'; + +/** The real filesystem, with each call the probe makes open to a fault. */ +function probeFs() { + return { + mkdtempSync: vi.fn((prefix: string) => fs.mkdtempSync(prefix)), + chmodSync: vi.fn((file: string, mode: number) => fs.chmodSync(file, mode)), + readdirSync: vi.fn((file: string): unknown => fs.readdirSync(file)), + writeFileSync: vi.fn((file: string, data: string) => + fs.writeFileSync(file, data), + ), + rmSync: vi.fn( + (file: string, options: { recursive: boolean; force: boolean }) => + fs.rmSync(file, options), + ), + }; +} + +function refusal(code: string, syscall?: string): () => never { + return () => { + throw Object.assign(new Error(code), { code, syscall }); + }; +} + +describe('arePermissionBitsEnforced', () => { + const initialTmpdir = process.env['TMPDIR']; + let tmp: string; + let stderr: MockInstance; + let probe: ReturnType; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'permission-bits-test-')); + process.env['TMPDIR'] = tmp; + // The diagnostic's shape depends on it, and CI sets it. + vi.stubEnv('GITHUB_ACTIONS', ''); + stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + probe = probeFs(); + }); + + afterEach(() => { + stderr.mockRestore(); + vi.unstubAllEnvs(); + if (initialTmpdir === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = initialTmpdir; + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + /** What the probe wrote to stderr about an inconclusive measurement. */ + function diagnostics(): string[] { + return stderr.mock.calls + .map((call) => String(call[0])) + .filter((line) => /^(::warning::)?\[permission-bits\]/.test(line)); + } + + /** The probe was made in os.tmpdir() and is gone again. */ + function expectProbeCleanedUp(): void { + const dir = String(probe.chmodSync.mock.calls[0]?.[0]); + expect(dir.startsWith(path.join(tmp, 'qwen-permission-probe-'))).toBe(true); + expect(fs.readdirSync(tmp)).toEqual([]); + } + + it.skipIf(process.platform === 'win32')( + 'answers true only when listing and writing are both refused', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EACCES')); + probe.writeFileSync.mockImplementationOnce(refusal('EACCES')); + expect(arePermissionBitsEnforced(probe)).toBe(true); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false when the directory can still be listed', + () => { + probe.readdirSync.mockImplementationOnce(() => []); + probe.writeFileSync.mockImplementationOnce(refusal('EACCES')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + // Bits that do not bind are an answer, not a failed measurement. + expect(diagnostics()).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false when listing is refused but writing is not', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EACCES')); + probe.writeFileSync.mockImplementationOnce(() => undefined); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false for a refusal other than EACCES', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EPERM')); + probe.writeFileSync.mockImplementationOnce(refusal('EACCES')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([ + expect.stringContaining('probe inconclusive (EPERM)'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'names a measurement lost to fd exhaustion instead of skipping silently', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EMFILE', 'scandir')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([ + expect.stringMatching( + /probe inconclusive \(EMFILE in scandir\) for .+permission-bits\.test\.ts;/, + ), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'names a temp directory that cannot hold the probe, without throwing', + () => { + probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expect(probe.chmodSync).not.toHaveBeenCalled(); + expect(fs.readdirSync(tmp)).toEqual([]); + // One whole line, with no workflow command outside GitHub Actions. + expect(diagnostics()).toEqual([ + expect.stringMatching( + /^\[permission-bits\] probe inconclusive \(ENOSPC\)[^\n]*\n$/, + ), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'opens the line with a warning command under GitHub Actions', + () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + // A workflow command counts only at the start of a line. + expect(diagnostics()).toEqual([ + expect.stringMatching( + /^::warning::\[permission-bits\] probe inconclusive \(ENOSPC\)[^\n]*\n$/, + ), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false, without throwing, when the directory refuses chmod', + () => { + probe.chmodSync.mockImplementationOnce(refusal('EPERM')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([ + expect.stringContaining('probe inconclusive (EPERM)'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'agrees with whether a mode-000 directory refuses this process', + () => { + const dir = fs.mkdtempSync(path.join(tmp, 'locked-')); + fs.chmodSync(dir, 0o000); + let refused = false; + try { + fs.readdirSync(dir); + } catch (error) { + refused = (error as NodeJS.ErrnoException).code === 'EACCES'; + } finally { + fs.chmodSync(dir, 0o700); + fs.rmSync(dir, { recursive: true, force: true }); + } + expect(arePermissionBitsEnforced()).toBe(refused); + expect(diagnostics()).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false, without throwing, for a value its diagnostic cannot print', + () => { + // Thrown where only the diagnostic's own guard stands between it and + // a failed test file. + probe.chmodSync.mockImplementationOnce(() => { + throw Object.assign(Object.create(null), { code: Symbol('odd') }); + }); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expectProbeCleanedUp(); + expect(diagnostics()).toEqual([ + expect.stringContaining('probe inconclusive (an unprintable value)'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'prints an error whose code is not an errno by its message', + () => { + probe.readdirSync.mockImplementationOnce(() => { + throw Object.assign(new Error('odd'), { code: Symbol('odd') }); + }); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expect(diagnostics()).toEqual([ + expect.stringContaining('probe inconclusive (Error: odd)'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'still names the errno when its syscall cannot be read', + () => { + probe.readdirSync.mockImplementationOnce(() => { + const error = Object.assign(new Error('EPERM'), { code: 'EPERM' }); + Object.defineProperty(error, 'syscall', { + get() { + throw new Error('no syscall'); + }, + }); + throw error; + }); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expect(diagnostics()).toEqual([ + expect.stringContaining('probe inconclusive (EPERM)'), + ]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers false, without throwing, when stderr itself refuses the line', + () => { + stderr.mockImplementationOnce(() => { + throw Object.assign(new Error('EPIPE'), { code: 'EPIPE' }); + }); + // The outer catch reports here, so nothing else stands between the + // throwing write and the test file. + probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC')); + expect(arePermissionBitsEnforced(probe)).toBe(false); + expect(fs.readdirSync(tmp)).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers, without throwing, when the probe cannot be removed', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EACCES')); + probe.writeFileSync.mockImplementationOnce(refusal('EACCES')); + probe.rmSync.mockImplementationOnce(refusal('EBUSY')); + expect(arePermissionBitsEnforced(probe)).toBe(true); + expect(diagnostics()).toEqual([]); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'answers, without throwing, when the probe mode cannot be restored', + () => { + probe.readdirSync.mockImplementationOnce(refusal('EACCES')); + probe.writeFileSync.mockImplementationOnce(refusal('EACCES')); + probe.chmodSync + .mockImplementationOnce((file: string, mode: number) => + fs.chmodSync(file, mode), + ) + .mockImplementationOnce(refusal('EPERM')); + try { + expect(arePermissionBitsEnforced(probe)).toBe(true); + expect(diagnostics()).toEqual([]); + } finally { + // Newer Node versions cannot remove a mode-000 directory, so open + // whatever the probe left behind before afterEach clears tmp. + const dir = probe.chmodSync.mock.calls[0]?.[0]; + if (dir !== undefined && fs.existsSync(dir)) fs.chmodSync(dir, 0o700); + } + }, + ); + + it('answers false on Windows without probing', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(arePermissionBitsEnforced(probe)).toBe(false); + } finally { + Object.defineProperty(process, 'platform', platform); + } + expect(probe.chmodSync).not.toHaveBeenCalled(); + expect(diagnostics()).toEqual([]); + }); +}); diff --git a/packages/browser-use/src/test-utils/permission-bits.ts b/packages/browser-use/src/test-utils/permission-bits.ts new file mode 100644 index 00000000000..adfa456fea6 --- /dev/null +++ b/packages/browser-use/src/test-utils/permission-bits.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { expect } from 'vitest'; + +/** The node:fs calls the probe makes. */ +export interface ProbeFs { + mkdtempSync(prefix: string): string; + chmodSync(path: string, mode: number): void; + readdirSync(path: string): unknown; + writeFileSync(path: string, data: string): void; + rmSync(path: string, options: { recursive: boolean; force: boolean }): void; +} + +// The real node:fs and node:os, even when the test file that asks has +// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded +// through Node's require. A mocked fs would answer for the mock, not the +// kernel. A vi.spyOn on node:fs's default export at collection time would +// still reach the probe, since that export is this same module object. +const nodeRequire = createRequire(import.meta.url); +const realFs = nodeRequire('node:fs') as typeof import('node:fs'); +const realOs = nodeRequire('node:os') as typeof import('node:os'); + +/** + * Whether file permission bits bind this process, for tests that force a + * failure with `chmod`. They do not on Windows, which ignores Unix-style + * bits, nor for root, which bypasses them (on Linux through + * CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both + * capabilities when it runs unit tests as root (package-scripts.test.js + * pins that), so asking the kernel, rather than checking for uid 0, keeps + * those tests running there. A probe that cannot measure also answers + * false, and says so on stderr. + * `fs` is for this helper's own tests; every caller probes the real one. + */ +export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean { + if (process.platform === 'win32') return false; + let dir: string | undefined; + try { + dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-')); + fs.chmodSync(dir, 0o000); + const probe = dir; + return ( + refused(() => fs.readdirSync(probe)) && + refused(() => fs.writeFileSync(path.join(probe, 'probe'), '')) + ); + } catch (error) { + // A temp directory that cannot hold the probe, or refuses chmod, + // cannot hold these tests' fixtures either, though after a transient + // ENOSPC it may hold them again by the time they run, so say which it + // was rather than skip silently. + inconclusive(error); + return false; + } finally { + if (dir !== undefined) { + try { + fs.chmodSync(dir, 0o700); + } catch { + // Best effort, like the removal below: newer Node versions refuse + // to remove even an empty mode-000 directory. + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort: a leftover probe must not fail the test file. + } + } + } +} + +function refused(attempt: () => unknown): boolean { + try { + attempt(); + return false; + } catch (error) { + if (errnoOf(error) === 'EACCES') return true; + inconclusive(error); + return false; + } +} + +function errnoOf(error: unknown): string | undefined { + const code = attempt( + () => (error as NodeJS.ErrnoException | null | undefined)?.code, + ); + return typeof code === 'string' ? code : undefined; +} + +/** + * Names a probe that could not measure, and the test file that asked, so + * the skips it causes are not mistaken for a Windows or root run. vitest's + * `silent: true` drops console output, so this writes to stderr directly. + * Under GitHub Actions the line opens with a workflow command, so the skips + * show in the check UI and not only deep in the log. + */ +function inconclusive(error: unknown): void { + try { + process.stderr.write( + `${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` + + `[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` + + 'treating permission bits as unenforced, so chmod-based tests skip\n', + ); + } catch { + // A diagnostic must never fail the test file it is meant to explain. + } +} + +function causeOf(error: unknown): string { + const code = errnoOf(error); + if (code === undefined) { + return ( + attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value' + ); + } + const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall); + return typeof syscall === 'string' ? `${code} in ${syscall}` : code; +} + +function askedBy(): string { + const testPath = attempt(() => expect.getState().testPath); + if (!testPath) return ''; + return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`; +} + +function attempt(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} diff --git a/packages/browser-use/tsconfig.declarations.json b/packages/browser-use/tsconfig.declarations.json index e58637b6aa6..cacd41b02af 100644 --- a/packages/browser-use/tsconfig.declarations.json +++ b/packages/browser-use/tsconfig.declarations.json @@ -11,5 +11,5 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "node_modules", "dist"] + "exclude": ["src/**/*.test.ts", "src/test-utils/**", "node_modules", "dist"] } diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index bce4c214d97..477c29d8a6b 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3434,7 +3434,7 @@ describe('WeComChannel', () => { // The read-only parent only blocks removal where permission bits bind: // Windows ignores them and root with its DAC capabilities bypasses them. - // Ask the kernel, as the cli and core permission-bits helpers do, so a + // Ask the kernel, as the permission-bits helpers in other packages do, so a // root lane that drops those capabilities still runs this case. A probe // that cannot measure answers false too, and says so on stderr, as a // warning under GitHub Actions. diff --git a/packages/cli/src/serve/local-process-runtime-activator.test.ts b/packages/cli/src/serve/local-process-runtime-activator.test.ts index dd1fbfd8b06..b295dc9f545 100644 --- a/packages/cli/src/serve/local-process-runtime-activator.test.ts +++ b/packages/cli/src/serve/local-process-runtime-activator.test.ts @@ -14,6 +14,7 @@ import { } from './local-process-runtime-activator.js'; import { ProcessRegistry } from '@qwen-code/acp-bridge'; import type { WorkspaceRuntime } from './workspace-registry.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; const fixture = ` process.on('message', b => { @@ -492,7 +493,7 @@ process.on('SIGTERM', () => process.exit(0)); } }); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'aggregates a settled rm-class cleanup failure into close()', async () => { const { activator, stateDir } = await setup(1); diff --git a/packages/core/src/agents/runtime/workflow-extension.test.ts b/packages/core/src/agents/runtime/workflow-extension.test.ts index cc72f8c4b8a..165b3af9651 100644 --- a/packages/core/src/agents/runtime/workflow-extension.test.ts +++ b/packages/core/src/agents/runtime/workflow-extension.test.ts @@ -14,6 +14,7 @@ import { MAX_EXTENSION_WORKFLOW_SCRIPT_BYTES, } from './workflow-extension.js'; import { computeWorkflowScriptDigest } from './workflow-saved.js'; +import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js'; const isWindows = process.platform === 'win32'; @@ -249,11 +250,12 @@ describe('loadExtensionWorkflows', () => { ).toEqual([]); }); - it.skipIf(isWindows || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'keeps later declared paths when an earlier one cannot be read', async () => { await write('good/x.js', workflowSource('x')); - await fs.mkdir(path.join(root, 'bad')); + // Read, `bad` would add gcp:y, so the result shows it was refused. + await write('bad/y.js', workflowSource('y')); await fs.chmod(path.join(root, 'bad'), 0o000); try { expect(await names(['bad', 'good'])).toEqual(['gcp:x']); diff --git a/packages/core/src/config/managed-session-log.test.ts b/packages/core/src/config/managed-session-log.test.ts index cbb2b32d04d..a3e9c2726cc 100644 --- a/packages/core/src/config/managed-session-log.test.ts +++ b/packages/core/src/config/managed-session-log.test.ts @@ -61,6 +61,7 @@ import { localManagedSessionKey, managedSessionResourceRoot, } from '../utils/sessionStorageUtils.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; const SESSION_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; @@ -639,8 +640,8 @@ describe('Managed Session log recording', () => { }); // An unreadable file is made with chmod, which neither Windows nor root - // honours. - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + // with its DAC capabilities honours. + it.skipIf(!arePermissionBitsEnforced())( 'keeps a reclaimed lock when it cannot read the log', async () => { const first = await start(managedConfig()); diff --git a/packages/core/src/ipc/uds-inbox.test.ts b/packages/core/src/ipc/uds-inbox.test.ts index 963deab35be..20eb8240b28 100644 --- a/packages/core/src/ipc/uds-inbox.test.ts +++ b/packages/core/src/ipc/uds-inbox.test.ts @@ -48,6 +48,7 @@ import { import type { PeerControllerIdentity } from './peer-controllers.js'; import { expectWithinLatencyBudget } from '../test-utils/latency-budget.js'; import { leaveStaleSocket } from '../test-utils/stale-socket.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; /** * A PID no process can ever hold. @@ -331,7 +332,7 @@ describe.skipIf(isWindows)('startPeerInbox', () => { expectFailure('path_too_long', 'shorter directory'); }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'names the cause when a parent directory is not writable', async () => { const locked = path.join(tmpDir, 'locked'); @@ -1132,7 +1133,7 @@ describe.skipIf(isWindows)('orphan socket sweeps', () => { // it. The existing "keeps a listening socket even when its filename PID // is absent" pins only the case where the probe reaches a definitive // answer, and stays green whether or not `unknown` is honoured. - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'keeps a socket whose probe could not reach a verdict', async () => { const dir = await socksDir(); @@ -1147,7 +1148,7 @@ describe.skipIf(isWindows)('orphan socket sweeps', () => { }, ); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'keeps a fallback directory whose probe could not reach a verdict', async () => { const parent = await makeShortTmpDir('qwen-inbox-unknown-'); @@ -1401,7 +1402,7 @@ describe.skipIf(isWindows)('PID-keyed path collisions', () => { } }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'binds a sibling when the requested path cannot be verified free', async () => { const taken = sockAt('4242.sock'); @@ -1556,7 +1557,7 @@ describe.skipIf(isWindows)('PID-keyed path collisions', () => { } }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'reports sibling overflow when the requested path cannot be verified free', async (ctx) => { const { root, taken } = await paddedTaken(); diff --git a/packages/core/src/lsp/native-lsp-service.test.ts b/packages/core/src/lsp/native-lsp-service.test.ts index a1f4a2049d9..0717853fd90 100644 --- a/packages/core/src/lsp/native-lsp-service.test.ts +++ b/packages/core/src/lsp/native-lsp-service.test.ts @@ -16,6 +16,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; // 模拟依赖项 class MockConfig { @@ -1125,7 +1126,7 @@ describe('NativeLspService', () => { // typed cast so the private method need not be public just for tests. describe('applyTextEdits — error branches', () => { let tmpDir: string; - const noChmod = process.platform === 'win32' || process.getuid?.() === 0; + const noChmod = !arePermissionBitsEnforced(); const applyEdit = (filePath: string, newText: string) => ( diff --git a/packages/core/src/memory/indexer.test.ts b/packages/core/src/memory/indexer.test.ts index 3a6a205c336..31188ab6b73 100644 --- a/packages/core/src/memory/indexer.test.ts +++ b/packages/core/src/memory/indexer.test.ts @@ -25,6 +25,7 @@ import { } from './indexer.js'; import { ensureAutoMemoryScaffold } from './store.js'; import * as trustedMemoryFilesystem from './trusted-memory-filesystem.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; vi.mock('./trusted-memory-filesystem.js', { spy: true }); @@ -127,40 +128,45 @@ describe('managed auto-memory indexer', () => { await expect(fs.readFile(index, 'utf-8')).resolves.toBe('GOOD INDEX\n'); }); - it('refuses to persist a partial index when a subdirectory cannot be read', async () => { - // chmod 000 blocks neither root nor Windows, where a directory chmod only - // toggles FILE_ATTRIBUTE_READONLY and so cannot make a directory - // unreadable — the rebuild would resolve and the rejects assertion red. - if (process.platform === 'win32' || process.getuid?.() === 0) { - return; - } - // A partially walked root must fail the rebuild loudly: the index is the - // persisted, authoritative artifact, so committing it from a scan that - // silently skipped a directory would drop those entries with no warning. - const memoryRoot = getAutoMemoryRoot(projectRoot); - const indexPath = getAutoMemoryIndexPath(projectRoot); - const doc = (name: string) => - `---\ntype: project\nname: ${name}\ndescription: ${name}\n---\nbody`; - const visible = path.join(memoryRoot, 'project', 'visible.md'); - const locked = path.join(memoryRoot, 'reference'); - await fs.mkdir(path.dirname(visible), { recursive: true }); - await fs.writeFile(visible, doc('Visible'), 'utf-8'); - await fs.mkdir(locked, { recursive: true }); - await fs.writeFile(path.join(locked, 'hidden.md'), doc('Hidden'), 'utf-8'); - - const complete = await rebuildManagedAutoMemoryIndex(projectRoot); - expect(complete).toContain('reference/hidden.md'); - - await fs.chmod(locked, 0o000); - try { - await expect(rebuildManagedAutoMemoryIndex(projectRoot)).rejects.toThrow( - 'memory scan', + // chmod 000 blocks neither root with its DAC capabilities nor Windows, + // where a directory chmod only toggles FILE_ATTRIBUTE_READONLY and so + // cannot make a directory unreadable — the rebuild would resolve and the + // rejects assertion red. + it.skipIf(!arePermissionBitsEnforced())( + 'refuses to persist a partial index when a subdirectory cannot be read', + async () => { + // A partially walked root must fail the rebuild loudly: the index is the + // persisted, authoritative artifact, so committing it from a scan that + // silently skipped a directory would drop those entries with no warning. + const memoryRoot = getAutoMemoryRoot(projectRoot); + const indexPath = getAutoMemoryIndexPath(projectRoot); + const doc = (name: string) => + `---\ntype: project\nname: ${name}\ndescription: ${name}\n---\nbody`; + const visible = path.join(memoryRoot, 'project', 'visible.md'); + const locked = path.join(memoryRoot, 'reference'); + await fs.mkdir(path.dirname(visible), { recursive: true }); + await fs.writeFile(visible, doc('Visible'), 'utf-8'); + await fs.mkdir(locked, { recursive: true }); + await fs.writeFile( + path.join(locked, 'hidden.md'), + doc('Hidden'), + 'utf-8', ); - await expect(fs.readFile(indexPath, 'utf-8')).resolves.toBe(complete); - } finally { - await fs.chmod(locked, 0o700); - } - }); + + const complete = await rebuildManagedAutoMemoryIndex(projectRoot); + expect(complete).toContain('reference/hidden.md'); + + await fs.chmod(locked, 0o000); + try { + await expect( + rebuildManagedAutoMemoryIndex(projectRoot), + ).rejects.toThrow('memory scan'); + await expect(fs.readFile(indexPath, 'utf-8')).resolves.toBe(complete); + } finally { + await fs.chmod(locked, 0o700); + } + }, + ); it('does not create a missing user root while rebuilding', async () => { const previousBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR']; diff --git a/packages/core/src/omni/upload-cache.test.ts b/packages/core/src/omni/upload-cache.test.ts index 9e7c8f59935..bc6e5e9ef5d 100644 --- a/packages/core/src/omni/upload-cache.test.ts +++ b/packages/core/src/omni/upload-cache.test.ts @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { OmniUploadCache } from './upload-cache.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; let root: string; @@ -25,11 +26,9 @@ afterEach(async () => { const SHA = 'a'.repeat(64); -// chmod-based denial tests are meaningless on Windows and as root (root -// bypasses file permission bits). -const canDropPermissions = - process.platform !== 'win32' && - (typeof process.getuid !== 'function' || process.getuid() !== 0); +// chmod-based denial tests are meaningless where permission bits do not +// bind: on Windows, and for root with its DAC capabilities. +const canDropPermissions = arePermissionBitsEnforced(); async function listCorruptBackups(): Promise { return (await fs.readdir(root)).filter((n) => diff --git a/packages/core/src/services/container-execution-transport.test.ts b/packages/core/src/services/container-execution-transport.test.ts index c88f2365020..807376eeb2f 100644 --- a/packages/core/src/services/container-execution-transport.test.ts +++ b/packages/core/src/services/container-execution-transport.test.ts @@ -26,6 +26,7 @@ import { Config } from '../config/config.js'; import { findProjectRoot } from '../utils/projectRoot.js'; import { ContainerExecutionEnvironment } from './container-execution-environment.js'; import type { ExecutionWorkerRequest } from './execution-environment.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; const runtime = vi.hoisted(() => ({ spawn: vi.fn(), execFile: vi.fn() })); vi.mock('node:child_process', async (original) => ({ @@ -374,7 +375,7 @@ describe.skipIf(process.platform === 'win32')( }, ); - it.skipIf(process.getuid?.() === 0).each(['mount point', 'output'])( + it.skipIf(!arePermissionBitsEnforced()).each(['mount point', 'output'])( 'retries failed %s deletion without releasing another sibling lease', async (target) => { const temporary = dirname(environment.outputDirectory); diff --git a/packages/core/src/services/media-memory/service.test.ts b/packages/core/src/services/media-memory/service.test.ts index 88003887a76..0de971f556d 100644 --- a/packages/core/src/services/media-memory/service.test.ts +++ b/packages/core/src/services/media-memory/service.test.ts @@ -16,6 +16,7 @@ import { import { truncateUtf8 } from './service.js'; import { MEDIA_MEMORY_FILE_NAME } from './store.js'; import type { MediaMemorySnapshot } from './types.js'; +import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js'; let root: string; let clock: number; @@ -42,9 +43,7 @@ afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); }); -const canDropPermissions = - process.platform !== 'win32' && - (typeof process.getuid !== 'function' || process.getuid() !== 0); +const canDropPermissions = arePermissionBitsEnforced(); async function readSnapshot(): Promise { const raw = await fs.readFile( diff --git a/packages/core/src/services/media-memory/store.test.ts b/packages/core/src/services/media-memory/store.test.ts index 880b07faa0d..ed768f0c8dc 100644 --- a/packages/core/src/services/media-memory/store.test.ts +++ b/packages/core/src/services/media-memory/store.test.ts @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { MediaMemoryStore, MEDIA_MEMORY_FILE_NAME } from './store.js'; +import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js'; let root: string; let store: MediaMemoryStore; @@ -24,10 +25,9 @@ afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); }); -// chmod-based denial tests are meaningless on Windows and as root. -const canDropPermissions = - process.platform !== 'win32' && - (typeof process.getuid !== 'function' || process.getuid() !== 0); +// chmod-based denial tests are meaningless where permission bits do not +// bind: on Windows, and for root with its DAC capabilities. +const canDropPermissions = arePermissionBitsEnforced(); async function listCorruptBackups(): Promise { return (await fs.readdir(root)).filter((n) => diff --git a/packages/core/src/services/ssh-workspace-script.test.ts b/packages/core/src/services/ssh-workspace-script.test.ts index f2ab306b630..763adf01f8a 100644 --- a/packages/core/src/services/ssh-workspace-script.test.ts +++ b/packages/core/src/services/ssh-workspace-script.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SSH_WORKSPACE_SCRIPT } from './ssh-workspace-script.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; interface Reply { ok: boolean; @@ -631,7 +632,7 @@ describe.skipIf(process.platform === 'win32')('SSH filesystem script', () => { ); }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'keeps readable results when files and directories deny access', () => { writeFileSync(join(root, 'visible.txt'), 'needle'); diff --git a/packages/core/src/services/worktreeCleanup.test.ts b/packages/core/src/services/worktreeCleanup.test.ts index d4227e27841..b08a6a8529f 100644 --- a/packages/core/src/services/worktreeCleanup.test.ts +++ b/packages/core/src/services/worktreeCleanup.test.ts @@ -19,6 +19,7 @@ import { STALE_WORKTREE_CUTOFF_MS, __test__, } from './worktreeCleanup.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; const { isEphemeralSlug } = __test__; @@ -561,8 +562,9 @@ describe('cleanupStaleAgentWorktrees', () => { }); // chmod-based denial needs POSIX semantics; Windows ACLs do not map, and - // root (CI containers) opens mode-000 directories without a warning. - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + // root with its DAC capabilities opens mode-000 directories without a + // warning. + it.skipIf(!arePermissionBitsEnforced())( 'preserves a stale worktree when the probe cannot enumerate a directory', async () => { // `git status` answers a directory it cannot open with a stderr diff --git a/packages/core/src/tools/record-artifact.test.ts b/packages/core/src/tools/record-artifact.test.ts index b3cecf3268b..823332c49bc 100644 --- a/packages/core/src/tools/record-artifact.test.ts +++ b/packages/core/src/tools/record-artifact.test.ts @@ -24,6 +24,7 @@ import { RecordArtifactTool, type RecordArtifactParams, } from './record-artifact.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; const signal = new AbortController().signal; const RES = 'https://example.com/resource'; @@ -456,21 +457,23 @@ describe('RecordArtifactTool', () => { ); }); - it('classifies an unreadable path as permission denied', async () => { - if (process.platform === 'win32' || process.getuid?.() === 0) return; - const ws = await workspace(); - await ws.write('hidden/a.csv', 'x'); - const hidden = path.join(ws.cwd, 'hidden'); - await chmod(hidden, 0); - try { - expectFailed( - await ws.record('Hidden', 'hidden/a.csv'), - ToolErrorType.PERMISSION_DENIED, - ); - } finally { - await chmod(hidden, 0o755); - } - }); + it.skipIf(!arePermissionBitsEnforced())( + 'classifies an unreadable path as permission denied', + async () => { + const ws = await workspace(); + await ws.write('hidden/a.csv', 'x'); + const hidden = path.join(ws.cwd, 'hidden'); + await chmod(hidden, 0); + try { + expectFailed( + await ws.record('Hidden', 'hidden/a.csv'), + ToolErrorType.PERMISSION_DENIED, + ); + } finally { + await chmod(hidden, 0o755); + } + }, + ); it('rejects a wrong workspace-folder prefix instead of reporting success', async () => { const ws = await workspace(); diff --git a/packages/core/src/utils/atomicFileWrite.test.ts b/packages/core/src/utils/atomicFileWrite.test.ts index af9ebfabe24..ca905699afe 100644 --- a/packages/core/src/utils/atomicFileWrite.test.ts +++ b/packages/core/src/utils/atomicFileWrite.test.ts @@ -16,6 +16,7 @@ import { renameWithRetry, renameWithRetrySync, } from './atomicFileWrite.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; type AsyncSeams = NonNullable[3]>; type SyncSeams = NonNullable[3]>; @@ -373,12 +374,11 @@ describe('atomicWriteFile', () => { ); it.skipIf( - process.platform === 'win32' || - typeof process.geteuid !== 'function' || - // chmod 0o000 against the file's real owner still succeeds via - // POSIX rename in CI/sandbox setups where the user is effectively - // root; only assert real EACCES when we own and can be denied. - process.geteuid() === 0, + typeof process.geteuid !== 'function' || + // The in-place write is refused only where permission bits bind: not + // on Windows, nor for root with its DAC capabilities, which writes + // the 0o444 file anyway. + !arePermissionBitsEnforced(), )( 'should surface EACCES when in-place fallback hits an unwritable file', async () => { diff --git a/packages/core/src/utils/workspace-artifact-directory.test.ts b/packages/core/src/utils/workspace-artifact-directory.test.ts index 898d2569c55..cfebd3452fe 100644 --- a/packages/core/src/utils/workspace-artifact-directory.test.ts +++ b/packages/core/src/utils/workspace-artifact-directory.test.ts @@ -21,6 +21,7 @@ import { collectRecordableWorkspaceFiles, pathHasSkippedDirectoryComponent, } from './workspace-artifact-directory.js'; +import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js'; describe('collectRecordableWorkspaceFiles', () => { const dirs: string[] = []; @@ -145,25 +146,25 @@ describe('collectRecordableWorkspaceFiles', () => { expect(collected.depthLimited).toBe(true); }); - it('treats an unreadable over-depth directory as depth-limited', async () => { - if (process.platform === 'win32' || process.getuid?.() === 0) { - return; - } - const root = await workspace(); - const deepParts = Array.from( - { length: MAX_DIRECTORY_ARTIFACT_DEPTH + 1 }, - (_, index) => `d${index}`, - ); - const deepDir = path.join(root, ...deepParts); - await mkdir(deepDir, { recursive: true }); - await writeFile(path.join(root, 'shallow.xlsx'), 'shallow'); - await chmod(deepDir, 0o000); - try { - const collected = await collectRecordableWorkspaceFiles(root, '', root); - expect(collected.files).toEqual(['shallow.xlsx']); - expect(collected.depthLimited).toBe(true); - } finally { - await chmod(deepDir, 0o755); - } - }); + it.skipIf(!arePermissionBitsEnforced())( + 'treats an unreadable over-depth directory as depth-limited', + async () => { + const root = await workspace(); + const deepParts = Array.from( + { length: MAX_DIRECTORY_ARTIFACT_DEPTH + 1 }, + (_, index) => `d${index}`, + ); + const deepDir = path.join(root, ...deepParts); + await mkdir(deepDir, { recursive: true }); + await writeFile(path.join(root, 'shallow.xlsx'), 'shallow'); + await chmod(deepDir, 0o000); + try { + const collected = await collectRecordableWorkspaceFiles(root, '', root); + expect(collected.files).toEqual(['shallow.xlsx']); + expect(collected.depthLimited).toBe(true); + } finally { + await chmod(deepDir, 0o755); + } + }, + ); }); diff --git a/packages/sdk-typescript/test/test-utils/permission-bits.ts b/packages/sdk-typescript/test/test-utils/permission-bits.ts new file mode 100644 index 00000000000..adfa456fea6 --- /dev/null +++ b/packages/sdk-typescript/test/test-utils/permission-bits.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { expect } from 'vitest'; + +/** The node:fs calls the probe makes. */ +export interface ProbeFs { + mkdtempSync(prefix: string): string; + chmodSync(path: string, mode: number): void; + readdirSync(path: string): unknown; + writeFileSync(path: string, data: string): void; + rmSync(path: string, options: { recursive: boolean; force: boolean }): void; +} + +// The real node:fs and node:os, even when the test file that asks has +// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded +// through Node's require. A mocked fs would answer for the mock, not the +// kernel. A vi.spyOn on node:fs's default export at collection time would +// still reach the probe, since that export is this same module object. +const nodeRequire = createRequire(import.meta.url); +const realFs = nodeRequire('node:fs') as typeof import('node:fs'); +const realOs = nodeRequire('node:os') as typeof import('node:os'); + +/** + * Whether file permission bits bind this process, for tests that force a + * failure with `chmod`. They do not on Windows, which ignores Unix-style + * bits, nor for root, which bypasses them (on Linux through + * CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both + * capabilities when it runs unit tests as root (package-scripts.test.js + * pins that), so asking the kernel, rather than checking for uid 0, keeps + * those tests running there. A probe that cannot measure also answers + * false, and says so on stderr. + * `fs` is for this helper's own tests; every caller probes the real one. + */ +export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean { + if (process.platform === 'win32') return false; + let dir: string | undefined; + try { + dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-')); + fs.chmodSync(dir, 0o000); + const probe = dir; + return ( + refused(() => fs.readdirSync(probe)) && + refused(() => fs.writeFileSync(path.join(probe, 'probe'), '')) + ); + } catch (error) { + // A temp directory that cannot hold the probe, or refuses chmod, + // cannot hold these tests' fixtures either, though after a transient + // ENOSPC it may hold them again by the time they run, so say which it + // was rather than skip silently. + inconclusive(error); + return false; + } finally { + if (dir !== undefined) { + try { + fs.chmodSync(dir, 0o700); + } catch { + // Best effort, like the removal below: newer Node versions refuse + // to remove even an empty mode-000 directory. + } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort: a leftover probe must not fail the test file. + } + } + } +} + +function refused(attempt: () => unknown): boolean { + try { + attempt(); + return false; + } catch (error) { + if (errnoOf(error) === 'EACCES') return true; + inconclusive(error); + return false; + } +} + +function errnoOf(error: unknown): string | undefined { + const code = attempt( + () => (error as NodeJS.ErrnoException | null | undefined)?.code, + ); + return typeof code === 'string' ? code : undefined; +} + +/** + * Names a probe that could not measure, and the test file that asked, so + * the skips it causes are not mistaken for a Windows or root run. vitest's + * `silent: true` drops console output, so this writes to stderr directly. + * Under GitHub Actions the line opens with a workflow command, so the skips + * show in the check UI and not only deep in the log. + */ +function inconclusive(error: unknown): void { + try { + process.stderr.write( + `${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` + + `[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` + + 'treating permission bits as unenforced, so chmod-based tests skip\n', + ); + } catch { + // A diagnostic must never fail the test file it is meant to explain. + } +} + +function causeOf(error: unknown): string { + const code = errnoOf(error); + if (code === undefined) { + return ( + attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value' + ); + } + const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall); + return typeof syscall === 'string' ? `${code} in ${syscall}` : code; +} + +function askedBy(): string { + const testPath = attempt(() => expect.getState().testPath); + if (!testPath) return ''; + return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`; +} + +function attempt(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} diff --git a/packages/sdk-typescript/test/unit/peer/registry.test.ts b/packages/sdk-typescript/test/unit/peer/registry.test.ts index c8116ade11f..8393aca2ed8 100644 --- a/packages/sdk-typescript/test/unit/peer/registry.test.ts +++ b/packages/sdk-typescript/test/unit/peer/registry.test.ts @@ -22,6 +22,7 @@ import { type SessionRecord, writeOwnRecord, } from '../../../src/peer/registry.js'; +import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js'; const isPosix = process.platform !== 'win32'; const isLinux = process.platform === 'linux'; @@ -274,7 +275,7 @@ describe('writeOwnRecord — defensive paths', () => { }, ); - it.runIf(isPosix && process.getuid?.() !== 0)( + it.runIf(arePermissionBitsEnforced())( 'does not replace a record it merely failed to read', async () => { const shared = plant( diff --git a/scripts/tests/check-tui-dep-direction.test.js b/scripts/tests/check-tui-dep-direction.test.js index 50bc812b310..03a73430453 100644 --- a/scripts/tests/check-tui-dep-direction.test.js +++ b/scripts/tests/check-tui-dep-direction.test.js @@ -27,6 +27,7 @@ import { listSourceFiles, symlinkedPathComponents, } from '../check-tui-dep-direction.mjs'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; const temporaryDirectories = []; @@ -435,7 +436,7 @@ describe('checkRule', () => { expect(otherRule.violations).toEqual([]); }); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'reports unlistable directories instead of silently dropping them', () => { const root = makeTemporaryDirectory(); @@ -709,7 +710,7 @@ describe('end-to-end gate run (main wiring)', () => { expect(out).toContain('not found or has no source files'); }, 40000); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'fails on an unlistable directory inside the scanned tree', () => { const base = makeFixtureCheckout(); diff --git a/scripts/tests/package-assets.test.js b/scripts/tests/package-assets.test.js index 612a010d634..7b3d76427e9 100644 --- a/scripts/tests/package-assets.test.js +++ b/scripts/tests/package-assets.test.js @@ -25,6 +25,7 @@ import { } from '../copy_bundle_assets.js'; import { copyFiles } from '../copy_files.js'; import { preparePackage } from '../prepare-package.js'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; const realReadFileSync = fs.readFileSync; const realReaddirSync = fs.readdirSync; @@ -569,12 +570,12 @@ describe('package asset scripts', () => { ).toBe(false); }); - // chmod is the only lever this case has: on Windows it is a no-op, and a - // root user reads through it, so the branch under test is unreachable - // there. The case skips rather than running into the other branch — a - // readable tree, stamped as usual — and failing red against the no-stamp - // assertion. - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + // chmod is the only lever this case has: on Windows it is a no-op, and + // root with its DAC capabilities reads through it, so the branch under + // test is unreachable there. The case skips rather than running into the + // other branch — a readable tree, stamped as usual — and failing red + // against the no-stamp assertion. + it.skipIf(!arePermissionBitsEnforced())( 'refuses when a digest root exists but cannot be listed', () => { // The root-level arm of the unlistable-directory refusal: with the diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 7b829cf5403..162558c7015 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -1158,9 +1158,9 @@ describe('package scripts', () => { }); it('drops DAC capabilities for root unit tests on both CI commands', () => { - // The permission-bit gates in packages/cli, packages/core, - // packages/channels/wecom and scripts/tests ask the kernel, so on a root - // lane that keeps these capabilities they all skip, without failing. + // The permission-bit probes in the workspaces and scripts/tests ask the + // kernel, so on a root lane that keeps these capabilities every case + // they gate skips, without failing. const step = getWorkflowStep( getWorkflowJob(readWorkflow('.github/workflows/ci.yml'), 'test'), 'Run tests and generate reports', @@ -1198,6 +1198,40 @@ describe('package scripts', () => { ).toHaveLength(2); }); + it('keeps every copy of the permission-bit probe identical', () => { + // The probe is copied, not shared: each package that uses it keeps its + // own copy, and cli, core and browser-use also keep its tests (wecom + // inlines a smaller probe for its one case). A fix to one copy has to + // reach the others, or the packages measure differently. + const copies = { + 'permission-bits.ts': [ + 'packages/cli/src/test-utils', + 'packages/core/src/test-utils', + 'packages/browser-use/src/test-utils', + 'packages/sdk-typescript/test/test-utils', + 'integrations/test-failure-explorer/src/test-utils', + ], + 'permission-bits.test.ts': [ + 'packages/cli/src/test-utils', + 'packages/core/src/test-utils', + 'packages/browser-use/src/test-utils', + ], + 'permission-bits.mocked-fs.test.ts': [ + 'packages/cli/src/test-utils', + 'packages/core/src/test-utils', + 'packages/browser-use/src/test-utils', + ], + }; + for (const [file, dirs] of Object.entries(copies)) { + const [reference, ...others] = dirs.map((dir) => + readFileSync(path.join(root, dir, file), 'utf8'), + ); + others.forEach((text, i) => { + expect(text, `${file}: ${dirs[i + 1]} vs ${dirs[0]}`).toBe(reference); + }); + } + }); + it('bounds the CI unit step so a hang fails instead of stalling', () => { // #10490's run 3 was cancelled at ~60 minutes, leaving behind // orphaned test processes; a stall reads as a timeout, not a failure. diff --git a/scripts/tests/qwen-pr-review-workflow.test.js b/scripts/tests/qwen-pr-review-workflow.test.js index b4bfbe8dd5a..aef0806b7e8 100644 --- a/scripts/tests/qwen-pr-review-workflow.test.js +++ b/scripts/tests/qwen-pr-review-workflow.test.js @@ -3427,7 +3427,8 @@ describe('checkout self-heal', () => { // A workspace whose entries the owning user cannot unlink: find -exec rm // exits nonzero, so the user-mode wipe leg fails deterministically. Root // bypasses the 0o500 lock via CAP_DAC_OVERRIDE, so the tests built on it - // skip there, and win32 has no POSIX permission bits to honor. + // skip where it keeps that capability, and win32 has no POSIX permission + // bits to honor. const lockFixture = () => { const parent = realpathSync( mkdtempSync(join(tmpdir(), 'checkout-heal-lock-')), @@ -3443,7 +3444,7 @@ describe('checkout self-heal', () => { rmSync(parent, { recursive: true, force: true }); }; - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'exits 0 and keeps survivors when BOTH wipe legs fail', () => { // The wipe step has no continue-on-error, so its own never-fail exit @@ -3473,7 +3474,7 @@ describe('checkout self-heal', () => { }, ); - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + it.skipIf(!arePermissionBitsEnforced())( 'escalates to the sudo leg when the user-mode wipe fails', () => { // Deleting the `|| sudo -n find …` leg (or flipping it to `&&`) must diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index 0368cfb5ed3..5c7f57dd912 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -21,6 +21,7 @@ import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; const workflow = readFileSync('.github/workflows/qwen-triage.yml', 'utf8'); const cacheProducerWorkflow = readFileSync( @@ -2284,12 +2285,13 @@ describe('qwen-triage verify hardening', () => { }, ); - it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( + it.skipIf(!hasGnuRealpath || !arePermissionBitsEnforced())( 'fails closed when the corrupt workspace cannot be unlinked', () => { // A swallowed `rm -f` failure would let the mkdir and the wipe run on - // a path that is still a symlink. Root bypasses the mode bits, so the - // fixture cannot produce the refusal there. + // a path that is still a symlink. Root with its DAC capabilities + // bypasses the mode bits, so the fixture cannot produce the refusal + // there. for (const stepName of WIPE_STEPS) { const parent = mkdtempSync(join(tmpdir(), 'verify-heal-perm-')); const outside = mkdtempSync(join(tmpdir(), 'verify-heal-outside-')); diff --git a/scripts/tests/review-source-digest.test.ts b/scripts/tests/review-source-digest.test.ts index 7fdebb7aaeb..6c0bce58642 100644 --- a/scripts/tests/review-source-digest.test.ts +++ b/scripts/tests/review-source-digest.test.ts @@ -43,6 +43,7 @@ import { reviewSourceRoots, reviewSourcesDigest, } from '../../packages/cli/src/commands/review/lib/stale-bundle.js'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); @@ -228,11 +229,12 @@ describe('the build stamp and the staleness check agree', () => { } }); - // chmod is the only lever this case has: on Windows it is a no-op, and a - // root user reads through it, so the branch under test is unreachable - // there. The case skips rather than measuring a readable tree and failing - // red against the throw-and-nothing-measured assertions. - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + // chmod is the only lever this case has: on Windows it is a no-op, and + // root with its DAC capabilities reads through it, so the branch under + // test is unreachable there. The case skips rather than measuring a + // readable tree and failing red against the throw-and-nothing-measured + // assertions. + it.skipIf(!arePermissionBitsEnforced())( 'neither side measures a tree whose subdirectory cannot be listed', () => { // The unreadable-DIRECTORY case, on both sides of the boundary at once: diff --git a/scripts/tests/review-worktree-cleanup-workflow.test.js b/scripts/tests/review-worktree-cleanup-workflow.test.js index a3df45bdeca..c00ae3c518d 100644 --- a/scripts/tests/review-worktree-cleanup-workflow.test.js +++ b/scripts/tests/review-worktree-cleanup-workflow.test.js @@ -28,6 +28,7 @@ import { reviewBranch, worktreePath, } from '../../packages/cli/src/commands/review/lib/paths.js'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; // The cleanup steps in ci.yml and qwen-code-pr-review.yml hard-code the // review-artifact layout owned by paths.ts: worktreePath()/reviewBranch() @@ -191,13 +192,13 @@ function expectQuarantineFallback(run) { // guard's `! -L` arm is what keeps `chmod -R u+w` from dereferencing a // symlinked leftover into a tree outside the workspace. The behavioral // fixtures below execute both copies where permission fixtures are - // available (skipped on the Windows/root lanes): the dangling-symlink - // fixture witnesses the existence guard's `-L` arm behaviorally on each - // copy, but the chmod guard's `! -L` arm has no behavioral witness (the - // live-symlink fixture asserts content, not mode), so pin both arms - // textually here on every copy — dropping `-L` fails the dangling-symlink - // fixture, while dropping `! -L` keeps this suite green and re-poisons - // the checkout (mutation-probed). + // available (skipped on Windows, and for root with its DAC capabilities): + // the dangling-symlink fixture witnesses the existence guard's `-L` arm + // behaviorally on each copy, but the chmod guard's `! -L` arm has no + // behavioral witness (the live-symlink fixture asserts content, not + // mode), so pin both arms textually here on every copy — dropping `-L` + // fails the dangling-symlink fixture, while dropping `! -L` keeps this + // suite green and re-poisons the checkout (mutation-probed). expect(code).toContain('[ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]'); expect(code).toContain('[ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]'); // The move must be the fallback of the removal chain, not an @@ -263,9 +264,9 @@ const sweepToolsAvailable = ).status === 0; // The fixtures defeat rm with a chmod-555 parent, which needs POSIX // permission semantics: Git Bash on Windows resolves `bash` but not chmod, -// and root ignores the bits entirely. +// and root with its DAC capabilities ignores the bits entirely. const permissionFixturesAvailable = - bashAvailable && process.platform !== 'win32' && process.geteuid?.() !== 0; + bashAvailable && arePermissionBitsEnforced(); // The ladder's failure path resolves the leftover with `realpath --`; on a // host without it (the merge_group macOS lane ships none) the function // refuses as `path could not be resolved` instead, so the test asserting diff --git a/scripts/tests/serve-ab-workflow.test.js b/scripts/tests/serve-ab-workflow.test.js index a50f74f11bb..d76897fee78 100644 --- a/scripts/tests/serve-ab-workflow.test.js +++ b/scripts/tests/serve-ab-workflow.test.js @@ -22,6 +22,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; +import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js'; const workflow = readFileSync('.github/workflows/serve-ab.yml', 'utf8'); @@ -744,12 +745,13 @@ describe('serve-ab pre-checkout workspace wipe', () => { }, ); - it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( + it.skipIf(!hasGnuRealpath || !arePermissionBitsEnforced())( 'fails closed when the corrupt workspace cannot be unlinked', () => { // A swallowed `rm -f` failure would let the mkdir and the wipe run on - // a path that is still a symlink. Root bypasses the mode bits, so the - // fixture cannot produce the refusal there. + // a path that is still a symlink. Root with its DAC capabilities + // bypasses the mode bits, so the fixture cannot produce the refusal + // there. const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-perm-')); const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-')); const ws = join(parent, 'repo');