fix(agents): stop a deeper vendor helper from stealing a pane's agent identity (#18062)

* fix(agents): keep outer agent identity over vendor helpers

* fix(agents): preserve outer identity across relay scans

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-02 12:49:15 -07:00
committed by GitHub
co-authored by Merge Sim
parent 616fa751da
commit 1d94ebee3f
7 changed files with 201 additions and 52 deletions
@@ -1,16 +1,15 @@
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcessFromCommandLine
isExpectedAgentProcess
} from '../../shared/agent-process-recognition'
import { getFirstCommandToken } from '../../shared/command-token-scanner'
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence'
import {
buildProcessTableIndex,
getStrictProcessTableSnapshot,
lookupProcessTableIndex,
scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableIndexStats,
type ProcessTableRow
@@ -126,23 +125,15 @@ export function resolveAgentForegroundProcessesFromIndex(
if (wrapperFallback && candidates.length !== 1) {
return { available: true, processName: null }
}
let bestCandidate: (ProcessTableRow & { depth: number }) | null = null
let bestName: ReturnType<typeof recognizeAgentProcessFromCommandLine> = null
for (const candidate of candidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (
recognized &&
(bestCandidate === null ||
scoreForegroundCandidateRow(candidate) > scoreForegroundCandidateRow(bestCandidate))
) {
bestCandidate = candidate
bestName = recognized
}
}
if (bestCandidate && bestName) {
const selected = selectForegroundProcessCandidate(candidates, allCandidates)
if (selected) {
return {
available: true,
processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates)
processName: resolveOuterWrapperForegroundProcess(
selected.recognized,
selected.candidate,
allCandidates
)
}
}
return { available: true, processName: null }
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import { resolveAgentForegroundProcessFromPs } from './agent-foreground-process'
type CapturedRun = {
agent: string
shellPid: number
rows: ProcessTableRow[]
}
describe('real foreground process captures', () => {
it('resolves all six agents, including omp over its deeper vendor helpers', () => {
const captured = JSON.parse(
gunzipSync(readFileSync(join(__dirname, '__fixtures__', 'real-agent-rows.json.gz'))).toString(
'utf8'
)
) as CapturedRun[]
expect(captured).toHaveLength(6)
expect(
captured.map(({ agent, shellPid, rows }) => ({
agent,
processName: resolveAgentForegroundProcessFromPs(rows, shellPid)
}))
).toEqual([
{ agent: 'claude', processName: 'claude' },
{ agent: 'codex', processName: 'codex' },
{ agent: 'opencode', processName: 'opencode' },
{ agent: 'gemini', processName: 'gemini' },
{ agent: 'grok', processName: 'grok' },
{ agent: 'omp', processName: 'omp' }
])
})
})
+15 -21
View File
@@ -11,6 +11,7 @@ import {
type AgentForegroundResolutionOptions
} from './windows-agent-foreground-process'
import { isShellProcess } from '../../shared/shell-process-detection'
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
export {
@@ -120,13 +121,6 @@ export async function confirmShellForegroundProcess(
}
}
function candidateScore(row: ProcessTableRow & { depth: number }): number {
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
// parent shell but still lets the native child confirm the same identity.
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
export async function resolveAgentForegroundProcess(
shellPid: number | null | undefined,
fallbackProcess: string | null,
@@ -191,30 +185,30 @@ export async function resolveAgentForegroundProcessWithAvailability(
}
}
function resolveAgentForegroundProcessFromPs(
export function resolveAgentForegroundProcessFromPs(
rows: ProcessTableRow[],
shellPid: number
): string | null {
const shellRow = rows.find((row) => row.pid === shellPid)
const candidates = collectDescendants(rows, shellPid).sort(
(a, b) => candidateScore(b) - candidateScore(a)
)
const candidates = collectDescendants(rows, shellPid)
// Why: `+` in `ps stat` marks the process holding the terminal foreground.
// The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the
// foreground gate; otherwise a stopped agent child still masquerades as live.
const foregroundIsKnown =
shellRow?.stat.includes('+') === true ||
candidates.some((candidate) => candidate.stat.includes('+'))
for (const candidate of candidates) {
if (foregroundIsKnown && !candidate.stat.includes('+')) {
continue
}
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
const foregroundCandidates = foregroundIsKnown
? candidates.filter((candidate) => candidate.stat.includes('+'))
: candidates
// Keep the complete process tree for ancestry checks. A recognized agent can
// sit above a non-foreground helper before another recognized process; the
// helper is filtered from selection but must remain traversable.
const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates
const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates)
if (selected) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
+9 -13
View File
@@ -6,17 +6,16 @@ import { promisify } from 'node:util'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
recognizeAgentProcess,
recognizeAgentProcessFromCommandLine
recognizeAgentProcess
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import {
getProcessTableIndex,
getProcessTableSnapshot,
scoreForegroundCandidateRow,
type ProcessTableIndex,
type ProcessTableRow
} from '../shared/process-table-snapshot'
import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection'
import {
resolveOuterWrapperForegroundProcess,
shouldInspectOuterWrapperForegroundProcess
@@ -240,9 +239,7 @@ function getForegroundProcessNameFromProcessTable(
// snapshot no longer each rebuild the parent/child map over every row.
const index = getProcessTableIndex(rows)
const root = index.byPid.get(pid)
const candidates = collectDescendants(index, pid).sort(
(a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a)
)
const candidates = collectDescendants(index, pid)
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
// remote process tree so node/python agent entrypoints become real agents.
const foregroundIsKnown =
@@ -264,13 +261,12 @@ function getForegroundProcessNameFromProcessTable(
) {
return null
}
for (const candidate of inspectionCandidates) {
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
if (recognized) {
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
}
const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates
const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates)
if (selected) {
// Why: return the outer wrapper (omp) rather than a deeper recognized helper
// in the same process lineage.
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
}
return null
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { selectForegroundProcessCandidate } from './foreground-process-selection'
describe('selectForegroundProcessCandidate', () => {
it('keeps a recognized ancestor over a different agent helper below a non-agent', () => {
const candidates = [
{ pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'omp' },
{ pid: 102, ppid: 101, depth: 2, stat: 'S+', command: 'vendor-ui' },
{ pid: 103, ppid: 102, depth: 3, stat: 'S+', command: 'codex' }
]
expect(selectForegroundProcessCandidate(candidates)).toMatchObject({
candidate: { pid: 101 },
recognized: { agent: 'omp' }
})
})
it('traverses non-foreground helpers when checking ancestry', () => {
const all = [
{ pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'omp' },
{ pid: 102, ppid: 101, depth: 2, stat: 'S', command: 'vendor-helper' },
{ pid: 103, ppid: 102, depth: 3, stat: 'S+', command: 'codex' }
]
expect(selectForegroundProcessCandidate([all[0], all[2]], all)).toMatchObject({
candidate: { pid: 101 },
recognized: { agent: 'omp' }
})
})
it('refuses different recognized agents on sibling lineages', () => {
const candidates = [
{ pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'codex' },
{ pid: 102, ppid: 100, depth: 1, stat: 'S+', command: 'gemini' }
]
expect(selectForegroundProcessCandidate(candidates)).toBeNull()
})
it('keeps the deepest process when one recognized agent owns the lineage', () => {
const candidates = [
{ pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'node /opt/bin/codex' },
{ pid: 102, ppid: 101, depth: 2, stat: 'S+', command: '/opt/vendor/bin/codex' }
]
expect(selectForegroundProcessCandidate(candidates)).toMatchObject({
candidate: { pid: 102 },
recognized: { agent: 'codex' }
})
})
})
@@ -0,0 +1,80 @@
import {
recognizeAgentProcessFromCommandLine,
type RecognizedAgentProcess
} from './agent-process-recognition'
export type ForegroundProcessCandidate = {
pid: number
ppid: number
command: string
depth: number
stat?: string
}
export type SelectedForegroundProcess = {
candidate: ForegroundProcessCandidate
recognized: RecognizedAgentProcess
}
/**
* Select a foreground agent without letting a vendor helper steal an outer
* agent's identity when both names occur in one process lineage.
*/
export function selectForegroundProcessCandidate(
candidates: readonly ForegroundProcessCandidate[],
ancestryCandidates: readonly ForegroundProcessCandidate[] = candidates
): SelectedForegroundProcess | null {
const recognized = candidates.flatMap((candidate) => {
const agent = recognizeAgentProcessFromCommandLine(candidate.command)
return agent ? [{ candidate, recognized: agent }] : []
})
if (recognized.length === 0) {
return null
}
const agentNames = new Set(recognized.map(({ recognized: agent }) => agent.agent))
if (agentNames.size > 1) {
const candidatesByPid = new Map(
ancestryCandidates.map((candidate) => [candidate.pid, candidate])
)
const outer = [...recognized].sort(
(left, right) => left.candidate.depth - right.candidate.depth
)[0]
if (
!outer ||
!recognized.every((entry) =>
isAncestorOrSelf(outer.candidate, entry.candidate, candidatesByPid)
)
) {
// Distinct sibling agents do not provide a trustworthy identity.
return null
}
return outer
}
return recognized.reduce((best, current) =>
foregroundCandidateScore(current.candidate) > foregroundCandidateScore(best.candidate)
? current
: best
)
}
function foregroundCandidateScore(candidate: ForegroundProcessCandidate): number {
return (candidate.stat?.includes('+') ? 10_000 : 0) + candidate.depth
}
function isAncestorOrSelf(
ancestor: ForegroundProcessCandidate,
descendant: ForegroundProcessCandidate,
candidatesByPid: ReadonlyMap<number, ForegroundProcessCandidate>
): boolean {
let currentPid = descendant.pid
while (currentPid !== ancestor.pid) {
const current = candidatesByPid.get(currentPid)
if (!current) {
return false
}
currentPid = current.ppid
}
return true
}