From 0a5a7b144646ce2d4ac310b60704065bbe334ca6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:39:32 -0700 Subject: [PATCH] fix(orcad): run the remote daemon at the login umask so worktrees it creates are not owner-only (#31135) * fix(orcad): run the remote daemon at the login umask so worktrees it creates are not owner-only (#31125) * fix(orcad): keep refreshed Gemini OAuth tokens owner-only now that orcad runs at the login umask The Gemini usage fetcher rewrote ~/.gemini/oauth_creds.json through a temp file and rename with no mode, so the replacement took the process umask. Under the old inherited umask 077 that happened to be 0600 on SSH hosts; at the login umask it would be 0644. Write it through writeCredentialFileAtomic (0600 staging file, owner-only ACL on Windows). The hostile-host matrix now checks that the deployed orcad runs at the SSH login's umask and that its readiness file stays 0600. * fix(orcad): keep rewritten agent configs at their mode and create the launch PID file owner-only writeHooksJson now keeps an existing config's mode unless a caller opts out. Rewriting ~/.claude/settings.json (which can hold API keys) and the other agent configs through a temp file used to take the process umask; inside orcad that was 077, and at the login umask it would widen an owner-only file to 0644. The launch runs `umask 077` in the foreground shell rather than inside the backgrounded nohup list, so the PID file the foreground writes is created 0600 instead of relying on a racing chmod. The Gemini credential test now pins umask 022 rather than inheriting the runner's. * fix(agent-hooks): do not create a read-only temp file for a read-only Windows config A preserved Windows mode can be read-only, which would leave an undeletable temp file behind when the rename fails. POSIX keeps the preserved mode. --------- Co-authored-by: Neil --- src/main/agent-hooks/installer-utils.test.ts | 12 +++++ src/main/agent-hooks/installer-utils.ts | 8 ++- .../orcad-remote-launch-file-modes.test.ts | 16 ++++++ .../orcad-remote-launch-setsid.test.ts | 25 ++++++++- .../install/orcad-remote-launch.test.ts | 6 ++- .../install/orcad-remote-launch.ts | 44 ++++++++++------ .../posix-host-op-byte-budget.test.ts | 3 +- .../rate-limits/gemini-oauth-sources.test.ts | 51 +++++++++++++++++++ src/main/rate-limits/gemini-oauth-sources.ts | 9 ++-- .../gemini-usage-fetcher.fallback.test.ts | 12 ++--- .../rate-limits/gemini-usage-fetcher.test.ts | 9 ++-- .../ssh-hostile-host-managed-orcad.ts | 31 +++++++++++ 12 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 src/main/rate-limits/gemini-oauth-sources.test.ts diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index c3ae247b410..7b53f9f76d5 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -120,6 +120,18 @@ describe('writeHooksJson', () => { expect(existsSync(targetPath)).toBe(false) }) + it.skipIf(process.platform === 'win32')( + 'keeps an owner-only config owner-only when it rewrites it', + () => { + writeFileSync(configPath, '{"hooks":{}}\n') + chmodSync(configPath, 0o600) + + writeHooksJson(configPath, { hooks: { Stop: [] } }) + + expect(statSync(configPath).mode & 0o777).toBe(0o600) + } + ) + it('writes the config as formatted JSON', () => { const config: HooksConfig = { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] } diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index ed0be0743a3..60d38f1c450 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -310,8 +310,10 @@ export function writeHooksJson( // Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond. const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n` + // Why preserve by default: a rewrite must not widen an owner-only config (settings.json can + // hold API keys) to the process umask, which on SSH hosts is the login's (#31125). const existingMode = - options?.preserveMode === true && existsSync(writePath) + options?.preserveMode !== false && existsSync(writePath) ? statSync(writePath).mode & 0o777 : options?.defaultMode @@ -330,7 +332,9 @@ export function writeHooksJson( } try { - writeFileSync(tmpPath, serialized, { encoding: 'utf-8', mode: existingMode, flag: 'wx' }) + // Why not on Windows: a read-only config's mode would make the temp file undeletable on failure. + const tmpMode = process.platform === 'win32' ? undefined : existingMode + writeFileSync(tmpPath, serialized, { encoding: 'utf-8', mode: tmpMode, flag: 'wx' }) if (existingMode !== undefined && process.platform !== 'win32') { // Preserve requested permissions even with a stricter process umask. chmodSync(tmpPath, existingMode) diff --git a/src/main/managed-server/install/orcad-remote-launch-file-modes.test.ts b/src/main/managed-server/install/orcad-remote-launch-file-modes.test.ts index c40d2a12555..6a1e9e716cc 100644 --- a/src/main/managed-server/install/orcad-remote-launch-file-modes.test.ts +++ b/src/main/managed-server/install/orcad-remote-launch-file-modes.test.ts @@ -91,6 +91,22 @@ describe.skipIf(process.platform === 'win32')('orcad launch file modes', () => { expect(mode(baseDir)).toBe(0o700) }) + // Regression (#31125): git, terminals and agents the daemon spawns create the user's files. + it('runs the daemon itself at the login umask', async () => { + writeFileSync( + join(slotDir, 'orcad.js'), + `mkdir daemon-dir && : > daemon-file && printf '%s\\n' '${READY_LINE}'\n` + ) + + await launchUnderLoginUmask() + + expect(mode(join(slotDir, 'daemon-file'))).toBe(0o644) + expect(mode(join(slotDir, 'daemon-dir'))).toBe(0o755) + expect(mode(join(slotDir, ORCAD_READINESS_FILENAME))).toBe(0o600) + expect(mode(join(slotDir, ORCAD_PID_FILENAME))).toBe(0o600) + expect(mode(join(slotDir, ORCAD_LOG_FILENAME))).toBe(0o600) + }) + it('leaves a parent that is not ~/.orca-remote alone', async () => { slotDir = join(home, 'custom-slot') mkdirSync(slotDir, { mode: 0o755 }) diff --git a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts index 0deebec2d26..b154799d811 100644 --- a/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts +++ b/src/main/managed-server/install/orcad-remote-launch-setsid.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -47,7 +47,7 @@ describe('the WSL launch (setsid)', () => { it('has the child record its own PID, since the setsid -f parent exits at once', () => { const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid') const removeStale = command.indexOf(`rm -f '/d/orcad-1/${ORCAD_PID_FILENAME}'`) - const recordOwn = command.indexOf(`echo $$ > "$1" && shift && exec "$@"`) + const recordOwn = command.indexOf(`echo $$ > "$1" && umask "$2" && shift 2 && exec "$@"`) expect(removeStale).toBeGreaterThan(-1) expect(recordOwn).toBeGreaterThan(removeStale) expect(command.trimEnd().endsWith(`cat '/d/orcad-1/${ORCAD_PID_FILENAME}'`)).toBe(true) @@ -106,4 +106,25 @@ describe('the WSL launch (setsid)', () => { } } ) + + // Regression (#31125): git, terminals and agents the daemon spawns create the user's files. + it.skipIf(process.platform !== 'linux')( + 'runs the daemon at the login umask while its PID and readiness files stay owner-only', + () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-setsid-umask-')) + dirs.push(dir) + const runtime = join(dir, 'fake-node') + writeFileSync(runtime, `#!/bin/sh\n: > daemon-file\necho '{"type":"orca_server_ready"}'\n`) + chmodSync(runtime, 0o755) + const command = orcadLaunchCommand(host, spec(dir, runtime), 'setsid') + execFileSync('sh', ['-c', `umask 022; ${command}`]) + for (let i = 0; i < 50 && !readFileSync(join(dir, ORCAD_READINESS_FILENAME), 'utf8'); i++) { + execFileSync('sleep', ['0.05']) + } + const mode = (name: string): number => statSync(join(dir, name)).mode & 0o777 + expect(mode('daemon-file')).toBe(0o644) + expect(mode(ORCAD_PID_FILENAME)).toBe(0o600) + expect(mode(ORCAD_READINESS_FILENAME)).toBe(0o600) + } + ) }) diff --git a/src/main/managed-server/install/orcad-remote-launch.test.ts b/src/main/managed-server/install/orcad-remote-launch.test.ts index b914cf8d771..053a0ac4385 100644 --- a/src/main/managed-server/install/orcad-remote-launch.test.ts +++ b/src/main/managed-server/install/orcad-remote-launch.test.ts @@ -51,9 +51,11 @@ describe('orcadLaunchCommand', () => { it('truncates the readiness file, so a stale line cannot be activated on', () => { const command = orcadLaunchCommand(posix, SPEC) - const truncate = command.indexOf(`: > '${SPEC.remoteInstallDir}/${ORCAD_READINESS_FILENAME}'`) + const cd = command.indexOf(`cd '${SPEC.remoteInstallDir}'`) + const truncate = command.indexOf(`: > '${ORCAD_READINESS_FILENAME}'`) const launch = command.indexOf('nohup') - expect(truncate).toBeGreaterThan(-1) + expect(cd).toBeGreaterThan(-1) + expect(truncate).toBeGreaterThan(cd) expect(truncate).toBeLessThan(launch) }) diff --git a/src/main/managed-server/install/orcad-remote-launch.ts b/src/main/managed-server/install/orcad-remote-launch.ts index d1df90a70f6..6d6e7ab7911 100644 --- a/src/main/managed-server/install/orcad-remote-launch.ts +++ b/src/main/managed-server/install/orcad-remote-launch.ts @@ -100,11 +100,13 @@ export function orcadLaunchCommand( ): string { assertPosixHost(host) const dir = quotePosixShell(spec.remoteInstallDir) - const readiness = quotePosixShell( - joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME) - ) - const log = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME)) + // Relative to the `cd` below, which every use follows in the same shell: the slot path is most + // of the sent bytes. + const readiness = quotePosixShell(ORCAD_READINESS_FILENAME) + const log = quotePosixShell(ORCAD_LOG_FILENAME) + // Absolute: a nohup launch backgrounds the whole list, `cd` included, then writes this outside it. const pidFile = quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME)) + // Absolute: liveness matches the runtime's args against this path. const entry = quotePosixShell( joinRemotePath(host, spec.remoteInstallDir, ORCAD_LAUNCHER_FILENAME) ) @@ -116,11 +118,19 @@ export function orcadLaunchCommand( ] // Why in the launching shell: the detached list below runs in a subshell whose variables are lost. const recordSession = detach === 'nohup' && recordsOrcadLaunchSshSession(host) + const launchEnv = [ + `ORCA_VERSION=${quotePosixShell(spec.fullVersion)}`, + `ORCA_USER_DATA=${quotePosixShell(spec.userDataDir)}`, + ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${quotePosixShell(value)}`) + ] return [ ...(recordSession ? [captureOrcadLaunchSshSessionCommand()] : []), - // Why first: the readiness file carries the pairing offer's device token, so every file this - // launch creates must be owner-only from birth, not only the ones after the exec. - 'umask 077 &&', + // The daemon gets this back: the git, terminals and agents it spawns create the user's files. + 'orcad_login_umask=$(umask);', + // Why before anything is created: the readiness file carries the pairing offer's device token, + // so every file this launch creates must be owner-only from birth. Why `;`: a nohup launch + // backgrounds the `&&` list below, and the PID file is written outside it. + 'umask 077;', // Best effort: directories and files an earlier build left 0755/0644 keep their old modes. `{ chmod 700 ${privateDirs.map(quotePosixShell).join(' ')} 2>/dev/null || :; } &&`, `cd ${dir} &&`, @@ -132,19 +142,19 @@ export function orcadLaunchCommand( `chmod 600 ${readiness} &&`, `{ chmod 600 ${pidFile} ${log} 2>/dev/null || :; } &&`, // A stop request the previous process never consumed must not stop this one. - `rm -f ${quotePosixShell(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, + `rm -f ${quotePosixShell(ORCAD_STOP_REQUEST_FILENAME)} &&`, ...(detach === 'setsid' ? [`rm -f ${pidFile} &&`] : []), - `ORCA_VERSION=${quotePosixShell(spec.fullVersion)}`, - `ORCA_USER_DATA=${quotePosixShell(spec.userDataDir)}`, - ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${quotePosixShell(value)}`), ...(detach === 'setsid' - ? setsidLaunchTail(entry, spec, readiness, log, pidFile, hostEnv) + ? setsidLaunchTail(entry, spec, readiness, log, pidFile, launchEnv, hostEnv) : [ + // Why a group: its redirects open under 077 before the daemon gets the login umask back. + '{ umask "$orcad_login_umask" &&', + ...launchEnv, // Keep $! equal to the runtime PID rather than a waiting shell's PID. // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. `exec nohup "$orcad_runtime" ${entry}`, - `--json --bind ${quotePosixShell(spec.bindHost)} --port ${String(spec.port)}`, - `> ${readiness} 2>> ${log} < /dev/null &`, + `--json --bind ${quotePosixShell(spec.bindHost)} --port ${String(spec.port)};`, + `} > ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} &&`, ...(recordSession ? [`${writeOrcadLaunchSshSessionCommand(host, spec.userDataDir)} &&`] @@ -165,15 +175,19 @@ function setsidLaunchTail( readiness: string, log: string, pidFile: string, + launchEnv: readonly string[], hostEnv: readonly OrcadLaunchEnvEntry[] ): string[] { return [ + ...launchEnv, // Only a setsid (WSL host) launch marks itself; SSH keeps its byte-for-byte command. `${ORCAD_WSL_HOST_ENV}=1`, ...hostEnv.map(([name, value]) => `${assertEnvName(name)}=${quotePosixShell(value)}`), 'sh -c \'orcad_detach() { if setsid -f true >/dev/null 2>&1; then setsid -f "$@"; ' + 'else setsid "$@" & fi; }; orcad_detach "$@"\' orcad-detach', - `sh -c 'echo $$ > "$1" && shift && exec "$@"' orcad-launch ${pidFile}`, + // The PID file is written under 077 before the daemon gets the login umask back. + `sh -c 'echo $$ > "$1" && umask "$2" && shift 2 && exec "$@"' orcad-launch ${pidFile}`, + '"$orcad_login_umask"', `"$orcad_runtime" ${entry}`, `--json --bind ${quotePosixShell(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &&`, diff --git a/src/main/managed-server/posix-host-op-byte-budget.test.ts b/src/main/managed-server/posix-host-op-byte-budget.test.ts index ac4c51556d0..95a0ff9ce92 100644 --- a/src/main/managed-server/posix-host-op-byte-budget.test.ts +++ b/src/main/managed-server/posix-host-op-byte-budget.test.ts @@ -98,8 +98,7 @@ const POSIX_HOST_OP_BYTE_BUDGET = 6 * 1024 // Ops already past the budget, at their measured size. Ceilings may only shrink. const OVER_BUDGET_CEILINGS: Readonly> = { tryStealInstallLockCommand: 9_022, - orcadLaunchCommand: 8_122, - 'orcadLaunchCommand (WSL setsid)': 6_439, + orcadLaunchCommand: 7_130, probeRemoteInstallCompleteCommand: 12_189 } diff --git a/src/main/rate-limits/gemini-oauth-sources.test.ts b/src/main/rate-limits/gemini-oauth-sources.test.ts new file mode 100644 index 00000000000..2e3226a4bdc --- /dev/null +++ b/src/main/rate-limits/gemini-oauth-sources.test.ts @@ -0,0 +1,51 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ home: '' })) + +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => state.home +})) + +const CREDS = { access_token: 'access', refresh_token: 'refresh', expiry_date: 1 } + +describe.skipIf(process.platform === 'win32')('saveGeminiCredentials', () => { + let previousUmask = 0 + + beforeAll(() => { + previousUmask = process.umask(0o022) + state.home = mkdtempSync(join(tmpdir(), 'gemini-oauth-sources-')) + }) + + afterAll(() => { + process.umask(previousUmask) + rmSync(state.home, { recursive: true, force: true }) + }) + + // Regression (#31125): orcad no longer inherits umask 077, so the replacement must be born 0600. + it('keeps the refreshed OAuth tokens owner-only under a permissive umask', async () => { + // Imported here: the module resolves the credential path from homedir() at load. + const { saveGeminiCredentials } = await import('./gemini-oauth-sources') + const path = join(state.home, '.gemini', 'oauth_creds.json') + mkdirSync(join(state.home, '.gemini')) + writeFileSync(path, '{}') + chmodSync(path, 0o600) + + await saveGeminiCredentials(CREDS) + + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(CREDS) + }) +}) diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts index 1cd3087b19c..fd589b812af 100644 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ b/src/main/rate-limits/gemini-oauth-sources.ts @@ -1,8 +1,9 @@ -import { readFile, writeFile, rename } from 'node:fs/promises' +import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { getMainHttpClient } from '../network/http-client' import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' +import { writeCredentialFileAtomic } from '../integration-credential-file' const API_TIMEOUT_MS = 10_000 const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') @@ -77,10 +78,10 @@ export async function readGeminiCredentials(): Promise } } +// Why an explicit owner-only mode: the rename replaces Gemini's 0600 file, and orcad runs at the +// login umask (#31125). export async function saveGeminiCredentials(creds: GeminiCredentials): Promise { - const tmpPath = `${OAUTH_CREDS_PATH}.${process.pid}.tmp` - await writeFile(tmpPath, JSON.stringify(creds, null, 2), 'utf-8') - await rename(tmpPath, OAUTH_CREDS_PATH) + writeCredentialFileAtomic(OAUTH_CREDS_PATH, Buffer.from(JSON.stringify(creds, null, 2), 'utf-8')) } export type RefreshTokenResult = { diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index 4fbf3dc610e..55f31f38964 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -24,12 +24,12 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - // Why: saveGeminiCredentials is exercised on the refresh path. The atomic - // tmp+rename write has no observable side effect in these tests, so the - // stubs just resolve. - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) + readFile: readFileMock +})) + +// Why: the refresh path saves credentials; it must never write the real ~/.gemini. +vi.mock('../integration-credential-file', () => ({ + writeCredentialFileAtomic: vi.fn().mockReturnValue(true) })) import { fetchGeminiRateLimits } from './gemini-usage-fetcher' diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index 2e6ee758a3b..053be7d10dd 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -23,9 +23,12 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ - readFile: readFileMock, - writeFile: vi.fn().mockResolvedValue(undefined), - rename: vi.fn().mockResolvedValue(undefined) + readFile: readFileMock +})) + +// Why: the refresh path saves credentials; it must never write the real ~/.gemini. +vi.mock('../integration-credential-file', () => ({ + writeCredentialFileAtomic: vi.fn().mockReturnValue(true) })) import { fetchGeminiRateLimits } from './gemini-usage-fetcher' diff --git a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts index 84b0f9a94ac..f5fd3e09489 100644 --- a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts +++ b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-managed-orcad.ts @@ -23,6 +23,10 @@ import { parseOrcadLiveness } from '../../managed-server/install/orcad-remote-launch' import { execOrcadRemote } from '../../managed-server/install/orcad-remote-runtime-control' +import { + ORCAD_PID_FILENAME, + ORCAD_READINESS_FILENAME +} from '../../managed-server/install/orcad-remote-host-support' import { decommissionRemoteOrcad } from '../../managed-server/install/orcad-remote-decommission' import { quotePosixShell } from '../../../shared/shell-argument-quoting' import { @@ -111,6 +115,7 @@ async function assertLiveThenDecommissioned( const liveness = async (): Promise => parseOrcadLiveness(await execOrcadRemote(slot, orcadLivenessProbeCommand(slot.host, slotDir))) expect(await liveness()).toBe('LIVE') + await assertOrcadRunsAtLoginUmask(slot, slotDir) const decommission = await decommissionRemoteOrcad({ ...slot, record: await readOrcadActivationRecord(slot), @@ -123,6 +128,32 @@ async function assertLiveThenDecommissioned( expect(await liveness()).toBe('DEAD') } +/** + * #31125: orcad runs at the SSH login's umask, so the git, terminals and agents it spawns create + * the user's files, while the readiness file holding the pairing token stays owner-only. + * Linux only: macOS has no /proc to read another process's umask from. + */ +async function assertOrcadRunsAtLoginUmask(slot: OrcadSlotOptions, slotDir: string): Promise { + if (slot.host.os !== 'linux') { + return + } + const pidFile = quotePosixShell(posix.join(slotDir, ORCAD_PID_FILENAME)) + const readiness = quotePosixShell(posix.join(slotDir, ORCAD_READINESS_FILENAME)) + const [login, daemon, readinessMode] = ( + await execOrcadRemote( + slot, + `umask; sed -n 's/^Umask:[[:space:]]*//p' "/proc/$(cat ${pidFile})/status"; stat -c %a ${readiness}` + ) + ) + .trim() + .split('\n') + .map((line) => line.trim()) + expect(Number.parseInt(daemon ?? '', 8), `login ${login}, orcad ${daemon}`).toBe( + Number.parseInt(login ?? '', 8) + ) + expect(readinessMode).toBe('600') +} + /** An empty runtime cache and a refusing network on this computer, until `restore`. */ function refuseDesktopDownloads(): { cacheRoot: string