Loading files
/**
* Reading the environment of a live engine process.
*
* Two things about a pane are decided at `execve` and readable nowhere else: which endpoint the engine
* talks to (`gatewayRuntime.ts`) and which grid it was pointed at (`gridAssignment.ts`). Both are
* questions about the SAME process, so the read lives here and both ask through it — one `ps` per
* agent, not one per question.
*
* Nothing here interprets or stores what it reads. A process environment is the user's business: the
* callers pick out the handful of variables they have a reason to look at, and the rest is passed
* through and dropped.
*/
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import type { ProcessIdentity } from './registry.js'
/** NUL-separated `/proc/<pid>/environ`. */
export function parseEnviron(blob: string): Record<string, string> {
const out: Record<string, string> = {}
for (const entry of blob.split('\0')) {
const eq = entry.indexOf('=')
if (eq > 0) out[entry.slice(0, eq)] = entry.slice(eq + 1)
}
return out
}
/**
* macOS `ps eww -p <pid> -o command=`: argv first, then `KEY=VALUE` pairs, all space-separated with no
* quoting. A value containing spaces is therefore only recoverable by reading up to the next token that
* starts a new variable — which is exactly what this does. Leading argv is skipped by the same rule
* (`--flag=x` cannot start a variable), and the keys we care about never carry spaces anyway.
*/
export function parsePsEnviron(line: string): Record<string, string> {
const out: Record<string, string> = {}
let key: string | null = null
for (const token of line.trim().split(/\s+/)) {
const eq = token.indexOf('=')
if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(token.slice(0, eq))) {
key = token.slice(0, eq)
out[key] = token.slice(eq + 1)
} else if (key) {
out[key] += ` ${token}`
}
}
return out
}
function read(pid: number): Promise<Record<string, string> | null> {
if (process.platform === 'linux') {
return readFile(`/proc/${pid}/environ`, 'utf8').then(parseEnviron).catch(() => null)
}
return new Promise((resolve) => {
execFile('ps', ['eww', '-p', String(pid), '-o', 'command='], { timeout: 2_000, maxBuffer: 4 << 20 }, (err, stdout) => {
if (err && !stdout) { resolve(null); return }
const env = parsePsEnviron(stdout)
// NO variables at all is macOS refusing to show them, not a process that has none.
//
// `ps` will not print the environment of an Apple PLATFORM BINARY — `/bin/zsh`, `/bin/bash`,
// `/bin/sleep` — to anyone, and it reports that refusal by exiting 0 with argv alone. (The
// status travels with the code signature, not the path: a copy of `/bin/zsh` run from
// somewhere else is just as opaque.) A real process always has an environment, so an empty
// parse here is the read failing, and saying so is what keeps `gateway` and `grid` on
// `undefined` — "we could not look" — instead of hardening into the finding "on no grid".
//
// This is not hypothetical or rare: it is every pane between `tmux new-session` and the
// engine's `exec`, which for an engine Harness installs first is the whole install.
resolve(Object.keys(env).length > 0 ? env : null)
})
})
}
/**
* A process's environment cannot change under it, so a read is permanent — for that process.
*
* The TTL is not about the environment changing. It is about having read the WRONG process: a launcher
* like `ori claude` is discoverable for the ~100ms before it `execve`s the vendor binary away, and on
* macOS `comm` truncates to 16 characters, so `~/.local/bin/ori` and `~/.local/bin/claude` are the same
* cache key and the exec is invisible. Expiring the entry closes that window — and every other one like
* it — for one extra `ps` per agent per minute. See `gatewayRuntime.ts`, where this was measured.
*
* A failed read is never cached: "we could not look" must not harden into an answer.
*/
const cache = new Map<string, { env: Record<string, string>; at: number }>()
const CACHE_LIMIT = 256
const TTL_MS = 60_000
export function processEnvCacheKey(identity: ProcessIdentity): string {
return `${identity.pid}\u0000${identity.startMarker}\u0000${identity.executable}`
}
export function clearProcessEnvCache(): void {
cache.clear()
}
/** The live environment of `identity`, or null when it could not be read. */
export async function readProcessEnv(identity: ProcessIdentity): Promise<Record<string, string> | null> {
const key = processEnvCacheKey(identity)
const hit = cache.get(key)
if (hit && Date.now() - hit.at < TTL_MS) return hit.env
const env = await read(identity.pid)
if (!env) return null
// A later read of the same process may see LESS than an earlier one — and when it does, the earlier
// one was right. That follows from the invariant above: the environment did not change, so a key
// that has gone missing was dropped by the READING, not by the process.
//
// Only macOS can lose one. `/proc/<pid>/environ` is all-or-nothing; `ps eww -o command=` prints
// argv and then the environment into one field, so a partial answer is a shape that exists there
// and nowhere else. It is also invisible: `ps` exits 0 and the caller gets a map that simply lacks
// a variable, which reads exactly like a process that never had it.
//
// That difference is not cosmetic here. `readOpencodeGridAssignment` treats a missing
// `OPENCODE_CONFIG` as "this agent is on no grid", so one partial read turned an agent's grid into
// "own login" in the app — briefly, then back, for the length of a turn.
//
// Merging restores only what a partial read lost, and never invents: the key includes the pid AND
// its start marker, so another process cannot reach this entry.
const merged = hit ? { ...hit.env, ...env } : env
if (cache.size >= CACHE_LIMIT) cache.clear()
cache.set(key, { env: merged, at: Date.now() })
return merged
}
d9b6d6416This file is pinned to the selected release. Download the original to open it in your design tool.