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 <contact@outofplace.space>
This commit is contained in:
Neil
2026-10-26 03:56:09 -07:00
committed by GitHub
co-authored by outof-place
parent ab62ae7e96
commit ac5c5ae4cf
8 changed files with 55 additions and 8 deletions
@@ -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<ProcessIde
['-p', String(pid), '-o', 'lstart=', '-o', 'command='],
{
encoding: 'utf8',
env: psLocaleEnvironment(),
timeout: PS_IDENTITY_TIMEOUT_MS
},
(error, output) => {
@@ -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<string> {
const { stdout } = await execFileAsync(file, args, {
encoding: 'utf8',
env: psLocaleEnvironment(),
timeout: timeoutMs,
windowsHide: true
})
@@ -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<number | null>
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) {
@@ -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<string | null> {
const result = await runProcess({
program: 'ps',
args,
env: psLocaleEnvironment(),
timeoutMs: PS_TIMEOUT_MS,
maxOutputBytes: PS_MAX_OUTPUT_BYTES
})
@@ -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<CheapProcessTableRow[]>({
@@ -18,6 +19,7 @@ const cheapProcessTableReader = createProcessTableSnapshotReader<CheapProcessTab
const result = await runProcess({
program: 'ps',
args: CHEAP_PS_ARGS,
env: psLocaleEnvironment(),
timeoutMs: PS_TIMEOUT_MS,
maxOutputBytes: PS_MAX_BUFFER_BYTES
})
@@ -0,0 +1,36 @@
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import {
getCheapProcessTableSnapshot,
resetCheapProcessTableSnapshotForTests
} from './cheap-process-table-snapshot-reader'
import {
getStrictProcessTableSnapshot,
resetProcessTableSnapshotForTests
} from './process-table-snapshot-reader'
afterEach(() => {
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()
}
}
)
+3 -7
View File
@@ -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<number, string>()
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
}))
+4
View File
@@ -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
}