mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user