From ac5c5ae4cfccf79db1ae60055ce4177e3fb70b03 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:56:09 -0700 Subject: [PATCH] fix(process-table): pin macOS ps to en_US.UTF-8 so lstart and argv parse under any locale (#33898) macOS ps formats lstart with the user's locale (%c) and vis-encodes non-ASCII argv when no UTF-8 locale is set. Under de_DE/en_NZ/ja_JP the process-table parsers dropped tty/startTime and folded them into command, breaking agent recognition, pane fingerprints, agent-session start times and daemon identity. Run those ps readers with LC_ALL=en_US.UTF-8 on macOS; other platforms keep the inherited env. Fixes #27004 Co-authored-by: outof-place --- .../daemon/daemon-process-identity-query.ts | 4 ++- src/main/daemon/daemon-process-inspection.ts | 2 ++ .../agent-session-process-identity-probe.ts | 3 ++ src/main/runtime/terminal-foreground-group.ts | 2 ++ .../cheap-process-table-snapshot-reader.ts | 2 ++ .../process-table-snapshot-locale.test.ts | 36 +++++++++++++++++++ src/shared/process-table-snapshot-reader.ts | 10 ++---- src/shared/ps-locale-environment.ts | 4 +++ 8 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 src/shared/process-table-snapshot-locale.test.ts create mode 100644 src/shared/ps-locale-environment.ts diff --git a/src/main/daemon/daemon-process-identity-query.ts b/src/main/daemon/daemon-process-identity-query.ts index 6fa9386b4be..125181c2d9e 100644 --- a/src/main/daemon/daemon-process-identity-query.ts +++ b/src/main/daemon/daemon-process-identity-query.ts @@ -1,4 +1,5 @@ import { execFile, execFileSync } from 'node:child_process' +import { psLocaleEnvironment } from '../../shared/ps-locale-environment' const PS_IDENTITY_TIMEOUT_MS = 2_000 @@ -24,7 +25,7 @@ export function getPsProcessIdentity( const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8', timeout: 2_000, - ...(options?.utc ? { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } } : {}) + env: options?.utc ? { ...process.env, TZ: 'UTC', LC_ALL: 'C' } : psLocaleEnvironment() }) return parsePsProcessIdentity(output, options?.utc) } catch { @@ -40,6 +41,7 @@ export async function getPsProcessIdentityAsync(pid: number): Promise { diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 4ab9d090b99..d78cb2f593c 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { psLocaleEnvironment } from '../../shared/ps-locale-environment' import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks } from './daemon-process-start-time' import type { LinuxStatEvidence, @@ -203,6 +204,7 @@ async function runInspectionCommand( ): Promise { const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', + env: psLocaleEnvironment(), timeout: timeoutMs, windowsHide: true }) diff --git a/src/main/runtime/agent-session/ownership/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session/ownership/agent-session-process-identity-probe.ts index 364421d36ac..f77778fd4c9 100644 --- a/src/main/runtime/agent-session/ownership/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session/ownership/agent-session-process-identity-probe.ts @@ -8,6 +8,7 @@ import type { } from '../../../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../../../shared/agent-session-record' import { runProcess } from '@orca/process-host' +import { psLocaleEnvironment } from '../../../../shared/ps-locale-environment' import { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh @@ -61,6 +62,7 @@ async function readDarwinProcessStartTimeMs(pid: number): Promise const result = await runProcess({ program: 'ps', args: ['-o', 'lstart=', '-p', String(pid)], + env: psLocaleEnvironment(), timeoutMs: PROCESS_START_TIME_TIMEOUT_MS }) if (result.timedOut || result.code !== 0) { @@ -84,6 +86,7 @@ async function readDarwinProcessStartTimesMs( const result = await runProcess({ program: 'ps', args: ['-o', 'pid=,lstart=', '-p', pids.join(',')], + env: psLocaleEnvironment(), timeoutMs: PROCESS_START_TIME_TIMEOUT_MS }) if (result.timedOut || result.code !== 0) { diff --git a/src/main/runtime/terminal-foreground-group.ts b/src/main/runtime/terminal-foreground-group.ts index 08a816da6c9..d9a3eb034d3 100644 --- a/src/main/runtime/terminal-foreground-group.ts +++ b/src/main/runtime/terminal-foreground-group.ts @@ -3,6 +3,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { runProcess } from '@orca/process-host' import { parseShellForegroundRows, type ProcessTableRow } from '../../shared/process-table-snapshot' +import { psLocaleEnvironment } from '../../shared/ps-locale-environment' import { isShellProcess } from '../../shared/shell-process-detection' import type { TuiAgent } from '../../shared/tui-agent' @@ -17,6 +18,7 @@ async function ps(args: readonly string[]): Promise { const result = await runProcess({ program: 'ps', args, + env: psLocaleEnvironment(), timeoutMs: PS_TIMEOUT_MS, maxOutputBytes: PS_MAX_OUTPUT_BYTES }) diff --git a/src/shared/cheap-process-table-snapshot-reader.ts b/src/shared/cheap-process-table-snapshot-reader.ts index 2a6da4f8cd6..1919f331e71 100644 --- a/src/shared/cheap-process-table-snapshot-reader.ts +++ b/src/shared/cheap-process-table-snapshot-reader.ts @@ -11,6 +11,7 @@ import { createProcessTableSnapshotReader, withEvidenceBudget } from './process-table-snapshot-reader' +import { psLocaleEnvironment } from './ps-locale-environment' /** Keep cheap captures separate: without tty=/command= they parse differently and cannot serve strict evidence readers. Share coalescing/TTL policy. */ const cheapProcessTableReader = createProcessTableSnapshotReader({ @@ -18,6 +19,7 @@ const cheapProcessTableReader = createProcessTableSnapshotReader { + vi.unstubAllEnvs() + resetProcessTableSnapshotForTests() + resetCheapProcessTableSnapshotForTests() +}) + +it.runIf(process.platform === 'darwin').each(['de_DE.UTF-8', 'C'])( + 'reads real macOS ps rows under LC_ALL=%s', + async (locale) => { + vi.stubEnv('LC_ALL', locale) + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', 'café-ü']) + try { + await once(child, 'spawn') + const strict = (await getStrictProcessTableSnapshot()).find((row) => row.pid === child.pid) + const cheap = (await getCheapProcessTableSnapshot()).find((row) => row.pid === child.pid) + + expect(strict?.command).toContain('café-ü') + expect(Number.isFinite(Date.parse(strict?.startTime ?? ''))).toBe(true) + expect(cheap?.startTime?.replace(/\s+/g, ' ')).toBe(strict?.startTime?.replace(/\s+/g, ' ')) + } finally { + child.kill() + } + } +) diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index da94d35cdd7..4c0459eecd2 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -1,6 +1,7 @@ import { execFile as execFileCb } from 'node:child_process' import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { psLocaleEnvironment } from './ps-locale-environment' import { captureDarwinProcessTable, resetDarwinProcessTableCaptureForTests @@ -238,13 +239,7 @@ async function readLinuxProcessStartTimes( } }) ) - const result = new Map() - for (const entry of starts) { - if (entry) { - result.set(entry[0], entry[1]) - } - } - return result + return new Map(starts.filter((entry) => entry !== null)) } async function captureProcessTable(args: readonly string[], timeout = PS_TIMEOUT_MS) { @@ -252,6 +247,7 @@ async function captureProcessTable(args: readonly string[], timeout = PS_TIMEOUT try { ;({ stdout } = await execFile('ps', [...args], { encoding: 'utf-8', + env: psLocaleEnvironment(), timeout, maxBuffer: PS_MAX_BUFFER_BYTES })) diff --git a/src/shared/ps-locale-environment.ts b/src/shared/ps-locale-environment.ts new file mode 100644 index 00000000000..2f3887e656e --- /dev/null +++ b/src/shared/ps-locale-environment.ts @@ -0,0 +1,4 @@ +/** macOS ps localizes lstart (%c) and vis-encodes non-ASCII argv; en_US.UTF-8 yields the parsed shape. */ +export function psLocaleEnvironment(): NodeJS.ProcessEnv | undefined { + return process.platform === 'darwin' ? { ...process.env, LC_ALL: 'en_US.UTF-8' } : undefined +}