From 47a97c4b5f593d8a0e2bd323117a94a6a49ef214 Mon Sep 17 00:00:00 2001 From: mp Date: Sat, 24 Oct 2026 23:47:37 +0900 Subject: [PATCH 1/2] fix(omp): find transcripts in the store the pane's omp process moved The bridge only knew ~/.omp/agent and --profile stores, read from its own HOME, so an omp pane started with --session-dir, PI_CODING_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR, PI_CONFIG_DIR or XDG data/state roots showed no chat. ompStoreLayout now derives the store from the pane's omp process argv and routing environment the way omp's own dirs.ts does (verified against the real omp 18.8.7 by scripts/omp-store-layout-probe.ts, 47 cases, fixture server/fixtures/omp-18.8.7-store-layouts.json). herdr's path and the held fallback are both held to that store: links out of it, another folder's session in a session directory of its own, unsupported session versions, unreadable environments and conflicting stores show no chat. Fixes #1613 --- changelog.d/omp-moved-session-store.md | 9 + docs/guide.md | 2 +- scripts/omp-store-layout-probe.ts | 229 +++++ server/conversation.ts | 43 +- server/fixtures/omp-18.8.7-store-layouts.json | 779 ++++++++++++++++++ server/gjc-runtime.ts | 66 +- server/omp-process.test.ts | 25 +- server/omp-process.ts | 20 +- server/omp.contract.test.ts | 3 +- server/omp.test.ts | 294 ++++++- server/omp.ts | 244 +++++- 11 files changed, 1631 insertions(+), 83 deletions(-) create mode 100644 changelog.d/omp-moved-session-store.md create mode 100644 scripts/omp-store-layout-probe.ts create mode 100644 server/fixtures/omp-18.8.7-store-layouts.json diff --git a/changelog.d/omp-moved-session-store.md b/changelog.d/omp-moved-session-store.md new file mode 100644 index 0000000000..00214727d2 --- /dev/null +++ b/changelog.d/omp-moved-session-store.md @@ -0,0 +1,9 @@ +### Fixed +- An omp pane whose sessions live outside `~/.omp/agent` shows its chat: the bridge finds the store + the pane's own omp process writes to, from its `--session-dir` or profile and the `HOME`, + `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR` or XDG data and state + directories it was started with, never from the bridge's own environment. A session herdr names + outside that store, a link out of it, another folder's session in a session directory of its own, + a session version omp does not write, an omp whose environment cannot be read and two omp + processes using different stores show no chat rather than a guess. Values omp reads from its own + `.env` files are not seen, and Windows keeps reading `~/.omp` and its profiles only. diff --git a/docs/guide.md b/docs/guide.md index 8bea00f417..754d2c651c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -156,7 +156,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | --- | --- | --- | | **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) | | **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) | -| **omp** | Native session file | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | +| **omp** | Native session file, in the store the pane's omp process writes to: `~/.omp/agent`, a profile's, or where its `--session-dir`, or the `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR` or XDG data and state directories it was started with, put it (absolute paths only, not values from omp's `.env` files; on Windows only `~/.omp` and its profiles) | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file reported by the web UI's GJC extension, verified against the pane's process | — use Terminal | | **pi** | Native session file, resolved through herdr; after `/tree`, the branch in play | ✓ its dialogs: a question, a confirmation, an answer typed in | diff --git a/scripts/omp-store-layout-probe.ts b/scripts/omp-store-layout-probe.ts new file mode 100644 index 0000000000..8c98607e74 --- /dev/null +++ b/scripts/omp-store-layout-probe.ts @@ -0,0 +1,229 @@ +/** + * Where the installed omp keeps its sessions and terminal breadcrumbs under each routing option, + * and whether the bridge's resolver (server/omp.ts ompStoreLayout) finds that pane's transcript + * there. Starts omp in a temporary HOME and fictional project for each case, waits for its terminal + * breadcrumb, reads the process's argv and routing environment through the bridge's own helper, + * records the breadcrumb and the session path it names, then writes a synthetic header at that path + * and asks the resolver for it. No prompt is sent, and provider and update traffic goes to a closed + * local port. + * + * bun scripts/omp-store-layout-probe.ts [--fixture server/fixtures/omp--store-layouts.json] + * + * Prints one JSON line per case; --fixture also writes the observed roots, with every temporary + * path as $CASE, for server/omp.test.ts. Exits 1 when a case cannot be observed or lacks proof: no + * argv and environment read from the process, a secret in the helper's answer, a supported layout + * whose roots differ from omp's or that does not find omp's own session, or an unsupported one + * that resolves. + */ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { heldTranscript } from "../server/gjc-runtime.ts"; +import { ompStoreLayout } from "../server/omp.ts"; +import { createOmpProcessProbe } from "../server/omp-process.ts"; + +const STARTUP_MS = 30_000; +/** The bridge's 1 s helper bound is for a live pane; a loaded host may need longer to answer here. */ +const ompProcessMemory = createOmpProcessProbe({ timeoutMs: 15_000 }); +/** Directories omp resolves against one nothing records (it changes into its --cwd): the resolver must give nothing. */ +const UNSUPPORTED = new Set(["relative PI_CODING_AGENT_DIR", "relative session-dir", "relative cwd and relative session-dir"]); +const omp = Bun.which("omp"); +const root = realpathSync(mkdtempSync(join(tmpdir(), "omp-store-layout-"))); +const closed = "http://127.0.0.1:9"; + +interface Case { name: string; args: string[]; env?: Record; mkdirs?: string[] } +/** What one case did, every temporary path as $CASE: the native truth server/omp.test.ts holds ompStoreLayout to. */ +interface FixtureCase { + name: string; args: string[]; env: Record; mkdirs: string[]; + /** null when omp wrote no absolute session path */ + observed: { sessions: string | null; depth: 1 | 2 | null; terminalSessions: string | null; cwd: string | null }; +} +interface Observed { + name: string; outcome: string; pid: number; argv: string[] | null; routingEnv: string[] | null; + breadcrumb: string | null; sessionPath: string | null; + layout: { sessions: string; terminalSessions: string; depth: 1 | 2 } | null; held: string | null; + fixture: FixtureCase; + /** null when the case is proved: the supported resolution found omp's own session, or the unsupported one gave nothing */ + proof: string | null; +} + +/** Every file under a `terminal-sessions` directory below `dir`. */ +function breadcrumbs(dir: string, depth = 0, found: string[] = []): string[] { + if (depth > 9) return found; + let entries: import("node:fs").Dirent[] = []; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "natives") breadcrumbs(path, depth + 1, found); + else if (entry.isFile() && dir.endsWith("/terminal-sessions")) found.push(path); + } + return found; +} + +async function observe(spec: Case, index: number): Promise { + const caseRoot = join(root, `case-${index}`); + const home = join(caseRoot, "home"); + const project = join(home, "project"); + mkdirSync(join(project, "sub"), { recursive: true }); + const fill = (value: string) => value.replaceAll("$CASE", caseRoot); + for (const dir of spec.mkdirs ?? []) mkdirSync(fill(dir), { recursive: true }); + const env: Record = { + HOME: home, PATH: process.env["PATH"] ?? "/usr/bin:/bin", TERM: "xterm-256color", LANG: "C.UTF-8", + HTTP_PROXY: closed, HTTPS_PROXY: closed, http_proxy: closed, https_proxy: closed, NO_PROXY: "", OLLAMA_BASE_URL: closed, PI_NO_TITLE: "1", + SECRET_TOKEN: "SECRET_TOKEN_synthetic_value", + }; + for (const [key, value] of Object.entries(spec.env ?? {})) env[key] = fill(value); + let output = ""; + let settled!: () => void; + const ready = new Promise((resolve) => { settled = resolve; }); + // subscribed before the spawn: every chunk of omp's output rechecks for its breadcrumb + const child = Bun.spawn([omp!, ...spec.args.map(fill)], { + cwd: project, env, + terminal: { cols: 120, rows: 40, data(_terminal, data) { output += new TextDecoder().decode(data); if (breadcrumbs(caseRoot).length > 0) settled(); } }, + }); + try { + let timer: ReturnType | undefined; + const outcome = await Promise.race([ + ready.then(() => "breadcrumb written"), + child.exited.then((code) => `exited ${code}: ${Bun.stripANSI(output).replace(/\s+/g, " ").slice(-300)}`), + new Promise((resolve) => { timer = setTimeout(() => resolve("no breadcrumb before the startup deadline"), STARTUP_MS); }), + ]); + clearTimeout(timer); + // the helper refuses a process in disk sleep, where omp sits while it pages its binary in + let memory = await ompProcessMemory(child.pid, true); + for (const deadline = Date.now() + 15_000; memory === null && child.exitCode === null && Date.now() < deadline;) { + await Bun.sleep(100); + memory = await ompProcessMemory(child.pid, true); + } + const found = breadcrumbs(caseRoot); + let breadcrumbCwd: string | null = null, sessionPath: string | null = null; + if (found.length === 1) [breadcrumbCwd = null, sessionPath = null] = readFileSync(found[0]!, "utf8").split("\n"); + const absolute = sessionPath?.startsWith("/") ?? false; + if (absolute && breadcrumbCwd) { + // the header omp writes with its first answer, so the resolver has a transcript to find + mkdirSync(dirname(sessionPath!), { recursive: true }); + if (!existsSync(sessionPath!) || statSync(sessionPath!).size === 0) writeFileSync(sessionPath!, `${JSON.stringify({ type: "session", version: 3, id: "probe", cwd: breadcrumbCwd })}\n`); + } + const layout = memory === null ? null : ompStoreLayout(memory.argv, memory.environ, "linux"); + const held = layout !== null && breadcrumbCwd !== null ? heldTranscript(layout, breadcrumbCwd, [child.pid], () => memory!.environ).path : null; + const placeholder = (path: string | null) => path === null ? null : path.replaceAll(caseRoot, "$CASE"); + // a managed store keeps each directory's sessions in a folder named after it, which omp always + // starts with "-" (`-tmp-`, `-`, `----`) + const managed = absolute && basename(dirname(sessionPath!)).startsWith("-"); + const observed: FixtureCase["observed"] = { + sessions: absolute ? placeholder(managed ? dirname(dirname(sessionPath!)) : dirname(sessionPath!)) : null, + depth: absolute ? managed ? 2 : 1 : null, + terminalSessions: found.length === 1 ? placeholder(dirname(found[0]!)) : null, + cwd: placeholder(breadcrumbCwd), + }; + let proof: string | null = null; + if (memory === null) proof = "no argv or environment read from the process"; + else if (JSON.stringify(memory).includes("SECRET_TOKEN")) proof = "a secret reached the helper's answer"; + else if (UNSUPPORTED.has(spec.name)) { if (layout !== null) proof = "an unsupported layout resolved"; } + else if (layout === null) proof = "the resolver found no layout"; + else if (placeholder(layout.sessions) !== observed.sessions || placeholder(layout.terminalSessions) !== observed.terminalSessions || layout.depth !== observed.depth) proof = "the resolver's roots are not omp's"; + else if (held !== sessionPath) proof = "the resolver did not find omp's own session"; + return { + name: spec.name, outcome, pid: child.pid, argv: memory?.argv.map((word) => placeholder(word)!) ?? null, routingEnv: memory?.environ.map((word) => placeholder(word)!) ?? null, + breadcrumb: placeholder(found[0] ?? null), sessionPath: placeholder(sessionPath), + layout: layout && { sessions: placeholder(layout.sessions)!, terminalSessions: placeholder(layout.terminalSessions)!, depth: layout.depth }, held: placeholder(held), + fixture: { name: spec.name, args: spec.args, env: spec.env ?? {}, mkdirs: spec.mkdirs ?? [], observed }, proof, + }; + } finally { + child.kill("SIGKILL"); + await child.exited; + } +} + +const XDG = { XDG_DATA_HOME: "$CASE/xdg-data", XDG_STATE_HOME: "$CASE/xdg-state" }; +const XDG_OMP = ["$CASE/xdg-data/omp", "$CASE/xdg-state/omp"]; +const CASES: Case[] = [ + { name: "default", args: [] }, + { name: "profile flag", args: ["--profile", "work"] }, + { name: "profile flag named default", args: ["--profile", "default"] }, + { name: "profile flag with spaces", args: ["--profile", " work "] }, + { name: "OMP_PROFILE", args: [], env: { OMP_PROFILE: "work" } }, + { name: "OMP_PROFILE named default", args: [], env: { OMP_PROFILE: "default" } }, + { name: "PI_PROFILE", args: [], env: { PI_PROFILE: "work" } }, + { name: "profile flag over OMP_PROFILE", args: ["--profile", "work"], env: { OMP_PROFILE: "other" } }, + { name: "empty OMP_PROFILE with PI_PROFILE", args: [], env: { OMP_PROFILE: "", PI_PROFILE: "work" } }, + { name: "PI_PROFILE and OMP_PROFILE", args: [], env: { PI_PROFILE: "pi", OMP_PROFILE: "omp" } }, + { name: "PI_CONFIG_DIR", args: [], env: { PI_CONFIG_DIR: ".omp-alt" } }, + { name: "PI_CONFIG_DIR with profile flag", args: ["--profile", "work"], env: { PI_CONFIG_DIR: ".omp-alt" } }, + { name: "PI_CODING_AGENT_DIR", args: [], env: { PI_CODING_AGENT_DIR: "$CASE/custom-agent" } }, + { name: "PI_CODING_AGENT_DIR with profile flag", args: ["--profile", "work"], env: { PI_CODING_AGENT_DIR: "$CASE/custom-agent" } }, + { name: "PI_CODING_AGENT_DIR with OMP_PROFILE", args: [], env: { PI_CODING_AGENT_DIR: "$CASE/custom-agent", OMP_PROFILE: "work" } }, + { name: "PI_CODING_AGENT_DIR with PI_PROFILE", args: [], env: { PI_CODING_AGENT_DIR: "$CASE/custom-agent", PI_PROFILE: "work" } }, + { name: "PI_CODING_AGENT_DIR handed down by PI_PROFILE, OMP_PROFILE empty", args: [], env: { OMP_PROFILE: "", PI_PROFILE: "work", PI_CODING_AGENT_DIR: "$CASE/home/.omp/profiles/work/agent" } }, + { name: "PI_CODING_AGENT_DIR handed down by OMP_PROFILE, profile flag named default", args: ["--profile", "default"], env: { OMP_PROFILE: "work", PI_CODING_AGENT_DIR: "$CASE/home/.omp/profiles/work/agent" } }, + { name: "session-dir flag", args: ["--session-dir", "$CASE/custom-sessions"] }, + { name: "session-dir flag, equals form", args: ["--session-dir=$CASE/custom-sessions"] }, + { name: "session-dir flag naming the managed store", args: ["--session-dir", "$CASE/home/.omp/agent/sessions"] }, + { name: "PI_CODING_AGENT_SESSION_DIR", args: [], env: { PI_CODING_AGENT_SESSION_DIR: "$CASE/env-sessions" } }, + { name: "PI_CODING_AGENT_SESSION_DIR with profile flag", args: ["--profile", "work"], env: { PI_CODING_AGENT_SESSION_DIR: "$CASE/env-sessions" } }, + { name: "PI_CODING_AGENT_SESSION_DIR with PI_CODING_AGENT_DIR", args: [], env: { PI_CODING_AGENT_SESSION_DIR: "$CASE/env-sessions", PI_CODING_AGENT_DIR: "$CASE/custom-agent" } }, + { name: "PI_CODING_AGENT_SESSION_DIR with XDG omp roots existing", args: [], env: { PI_CODING_AGENT_SESSION_DIR: "$CASE/env-sessions", ...XDG }, mkdirs: XDG_OMP }, + { name: "session-dir flag over PI_CODING_AGENT_SESSION_DIR", args: ["--session-dir", "$CASE/custom-sessions"], env: { PI_CODING_AGENT_SESSION_DIR: "$CASE/env-sessions" } }, + { name: "session-dir flag with profile flag", args: ["--profile", "work", "--session-dir", "$CASE/custom-sessions"] }, + { name: "PI_CODING_AGENT_DIR with session-dir flag", args: ["--session-dir", "$CASE/custom-sessions"], env: { PI_CODING_AGENT_DIR: "$CASE/custom-agent" } }, + { name: "empty PI_CODING_AGENT_SESSION_DIR", args: [], env: { PI_CODING_AGENT_SESSION_DIR: "" } }, + { name: "empty PI_CODING_AGENT_DIR", args: [], env: { PI_CODING_AGENT_DIR: "" } }, + { name: "XDG roots absent", args: [], env: XDG }, + { name: "XDG roots existing", args: [], env: XDG, mkdirs: ["$CASE/xdg-data", "$CASE/xdg-state"] }, + { name: "XDG omp roots existing", args: [], env: XDG, mkdirs: XDG_OMP }, + { name: "XDG data omp root only", args: [], env: XDG, mkdirs: ["$CASE/xdg-data/omp"] }, + { name: "XDG state omp root only", args: [], env: XDG, mkdirs: ["$CASE/xdg-state/omp"] }, + { name: "XDG omp roots existing with profile flag", args: ["--profile", "work"], env: XDG, mkdirs: XDG_OMP }, + { name: "XDG omp profile roots existing with profile flag", args: ["--profile", "work"], env: XDG, mkdirs: ["$CASE/xdg-data/omp/profiles/work", "$CASE/xdg-state/omp/profiles/work"] }, + { name: "XDG omp roots existing with OMP_PROFILE", args: [], env: { ...XDG, OMP_PROFILE: "work" }, mkdirs: XDG_OMP }, + { name: "XDG omp roots existing with PI_CODING_AGENT_DIR", args: [], env: { ...XDG, PI_CODING_AGENT_DIR: "$CASE/custom-agent" }, mkdirs: XDG_OMP }, + { name: "XDG omp roots existing with PI_CODING_AGENT_DIR naming the default agent", args: [], env: { ...XDG, PI_CODING_AGENT_DIR: "$CASE/home/.omp/agent" }, mkdirs: XDG_OMP }, + { name: "XDG omp roots existing with PI_CODING_AGENT_DIR naming the default agent with a trailing slash", args: [], env: { ...XDG, PI_CODING_AGENT_DIR: "$CASE/home/.omp/agent/" }, mkdirs: XDG_OMP }, + { name: "XDG omp roots existing with session-dir flag", args: ["--session-dir", "$CASE/custom-sessions"], env: XDG, mkdirs: XDG_OMP }, + { name: "XDG unset, default XDG omp roots existing", args: [], mkdirs: ["$CASE/home/.local/share/omp", "$CASE/home/.local/state/omp"] }, + { name: "empty XDG roots, default XDG omp roots existing", args: [], env: { XDG_DATA_HOME: "", XDG_STATE_HOME: "" }, mkdirs: ["$CASE/home/.local/share/omp", "$CASE/home/.local/state/omp"] }, + { name: "relative PI_CODING_AGENT_DIR", args: [], env: { PI_CODING_AGENT_DIR: "rel-agent" } }, + { name: "relative cwd and relative session-dir", args: ["--cwd", "sub", "--session-dir", "rel-sessions"] }, + { name: "relative session-dir", args: ["--session-dir", "rel-sessions"] }, +]; + +let exit = 0; +const results: Observed[] = []; +try { + if (!omp) { + console.log(JSON.stringify({ omp: null, note: "omp is not installed: nothing observed" })); + exit = 1; + } else { + const version = Bun.spawnSync([omp, "--version"], { env: { HOME: root, PATH: process.env["PATH"] ?? "" }, stdout: "pipe", stderr: "pipe" }); + console.log(JSON.stringify({ omp, version: version.stdout.toString().trim(), exit: version.exitCode, root: "$ROOT" })); + for (const [index, spec] of CASES.entries()) { + const result = await observe(spec, index); + results.push(result); + console.log(JSON.stringify(result)); + if (result.outcome !== "breadcrumb written") exit = 1; + if (result.proof !== null) { console.error(`PROOF_MISSING ${result.name}: ${result.proof}`); exit = 1; } + } + const unsupported = results.filter((result) => UNSUPPORTED.has(result.name)); + console.log(JSON.stringify({ + cases: results.length, resolved: results.filter((result) => !UNSUPPORTED.has(result.name) && result.proof === null).length, + unsupportedUnavailable: unsupported.filter((result) => result.proof === null).length, missingProof: results.filter((result) => result.proof !== null).length, + })); + const fixture = process.argv[process.argv.indexOf("--fixture") + 1]; + if (process.argv.includes("--fixture") && fixture && exit === 0) { + const provenance = { omp: version.stdout.toString().trim(), platform: process.platform, command: "bun scripts/omp-store-layout-probe.ts --fixture", home: "$CASE/home", start: "$CASE/home/project", unsupported: [...UNSUPPORTED] }; + writeFileSync(fixture, `${JSON.stringify({ provenance, cases: results.map((result) => result.fixture) }, null, 2)}\n`); + console.log(`fixture: ${fixture}`); + } + } +} finally { + for (const result of results) { + let gone = false; + try { process.kill(result.pid, 0); } catch { gone = true; } + console.log(`cleanup: pid ${result.pid} ${gone ? "gone (kill -0 fails)" : "STILL RUNNING"}`); + if (!gone) exit = 1; + } + rmSync(root, { recursive: true, force: true }); + console.log(`cleanup: ${root} ${existsSync(root) ? "STILL PRESENT" : "removed"}`); +} +process.exit(exit); diff --git a/server/conversation.ts b/server/conversation.ts index 147b989150..ff755cc41b 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -8,8 +8,9 @@ * at ~/.claude/projects//.jsonl (claude-store.ts finds it). * - omp: herdr's agent.get hands us the session jsonl path outright under * ~/.omp/agent/sessions//, or ~/.omp/profiles//agent/sessions// - * for `omp --profile ` — same shape of truth, one less hop. When herdr names none, the - * pane's omp process does (omp.ts). + * for `omp --profile ` — same shape of truth, one less hop. The path is held to the store + * the pane's omp process writes to, which its argv and environment may move (omp.ts + * ompStoreLayout); when herdr names none of it, the pane's omp process does. * - omo: herdr knows nothing about its store and its label for the pane flips * between `pi` and `claude` as omo spawns model CLIs, so the pane's process * tree routes it and process/session evidence selects a unique transcript @@ -46,7 +47,7 @@ import { AmbiguousClaudeStore, attachedJob, claudeJobSession, claudeProcessSessi import { forgetGjcPane, forgetGjcState, gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, storeRelative } from "./gjc-runtime.ts"; import { isOmoProcess, omoProcessIdentity, omoSessionForPane, processEnviron } from "./omo.ts"; import { processStartedAt } from "./process-start.ts"; -import { ompHeldTranscript } from "./omp.ts"; +import { ompPaneRoute, ompRouteOwns, ompRouteStore, ompRouteTranscript } from "./omp.ts"; import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeConversation, opencodeDatabasePath, opencodeImage, opencodeReadKey, opencodeSessionId, opencodeToolOutput } from "./opencode.ts"; import { piTranscriptInStore, piTranscriptPath, unwrittenSession } from "./pi.ts"; import { cmdTranscriptPath } from "./cmd.ts"; @@ -891,27 +892,35 @@ export function ompSessionStore(value: unknown, home: string, paths: PlatformPat } /** - * omp's transcript: herdr hands over the absolute path, accepted only inside the user's own - * stores. When herdr names none (or another agent's session), the pane's omp process does. + * omp's transcript: herdr hands over the absolute path, accepted only inside the store the pane's + * omp processes write to (omp.ts ompPaneRoute, from their own argv and environment), as a session + * of the pane's directory; else the pane's omp process names it. Only where herdr shows no omp + * process, and on Windows, which shows no process's environment, is herdr's path held to the + * user's own default and profile stores instead. */ async function ompTranscriptPath(paneId: string, cwd: string): Promise { const info = await herdrRpc<{ agent: { agent_session?: { kind?: unknown; value?: unknown } } }>("agent.get", { target: paneId }); const session = info.agent.agent_session; const path = session?.kind === "path" && typeof session.value === "string" ? session.value : null; // a Windows bridge starts with HOME set to the profile directory (remote-entry.ts) - const store = ompSessionStore(path, process.env["HOME"] ?? ""); - if (path === null || store === null) { - const held = await ompHeldTranscript(paneId, cwd); - if (held === null) throw new ConversationUnavailable("no_session_path"); - return held; + const home = process.env["HOME"] ?? ""; + const route = process.platform === "win32" ? null : await ompPaneRoute(paneId); + // an omp whose store nothing proves, or two keeping different ones + if (route === "unproved") throw new ConversationUnavailable("no_session_path"); + const store = route === null ? ompSessionStore(path, home) : path === null ? null : ompRouteStore(route, path); + if (path !== null && store !== null) { + // omp names its session file at start and writes it with the first answer + const unwritten = unwrittenSession(path, store); + if (unwritten !== null) throw new ConversationNotStarted(unwritten.id, "omp-transcript", unwritten.path); + // the check above is lexical: a link out of the store is no transcript of this pane + const inStore = piTranscriptInStore(path, store); + if (inStore !== null && (route === null || ompRouteOwns(route, inStore, cwd))) return inStore; + if (route === null) throw new ConversationUnavailable("no_session_path"); } - // omp names its session file at start and writes it with the first answer - const unwritten = unwrittenSession(path, store); - if (unwritten !== null) throw new ConversationNotStarted(unwritten.id, "omp-transcript", unwritten.path); - // the check above is lexical: a link out of the store is no transcript of this pane - const inStore = piTranscriptInStore(path, store); - if (inStore === null) throw new ConversationUnavailable("no_session_path"); - return inStore; + // herdr names no session of this store (or another agent's): the pane's omp process does + const held = route === null ? null : ompRouteTranscript(route, cwd); + if (held === null) throw new ConversationUnavailable("no_session_path"); + return held; } /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ diff --git a/server/fixtures/omp-18.8.7-store-layouts.json b/server/fixtures/omp-18.8.7-store-layouts.json new file mode 100644 index 0000000000..ee702a9e8b --- /dev/null +++ b/server/fixtures/omp-18.8.7-store-layouts.json @@ -0,0 +1,779 @@ +{ + "provenance": { + "omp": "omp/18.8.7", + "platform": "linux", + "command": "bun scripts/omp-store-layout-probe.ts --fixture", + "home": "$CASE/home", + "start": "$CASE/home/project", + "unsupported": [ + "relative PI_CODING_AGENT_DIR", + "relative session-dir", + "relative cwd and relative session-dir" + ] + }, + "cases": [ + { + "name": "default", + "args": [], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "profile flag", + "args": [ + "--profile", + "work" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "profile flag named default", + "args": [ + "--profile", + "default" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "profile flag with spaces", + "args": [ + "--profile", + " work " + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "OMP_PROFILE", + "args": [], + "env": { + "OMP_PROFILE": "work" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "OMP_PROFILE named default", + "args": [], + "env": { + "OMP_PROFILE": "default" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_PROFILE", + "args": [], + "env": { + "PI_PROFILE": "work" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "profile flag over OMP_PROFILE", + "args": [ + "--profile", + "work" + ], + "env": { + "OMP_PROFILE": "other" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "empty OMP_PROFILE with PI_PROFILE", + "args": [], + "env": { + "OMP_PROFILE": "", + "PI_PROFILE": "work" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_PROFILE and OMP_PROFILE", + "args": [], + "env": { + "PI_PROFILE": "pi", + "OMP_PROFILE": "omp" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CONFIG_DIR", + "args": [], + "env": { + "PI_CONFIG_DIR": ".omp-alt" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp-alt/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp-alt/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CONFIG_DIR with profile flag", + "args": [ + "--profile", + "work" + ], + "env": { + "PI_CONFIG_DIR": ".omp-alt" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp-alt/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp-alt/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_DIR": "$CASE/custom-agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/custom-agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR with profile flag", + "args": [ + "--profile", + "work" + ], + "env": { + "PI_CODING_AGENT_DIR": "$CASE/custom-agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR with OMP_PROFILE", + "args": [], + "env": { + "PI_CODING_AGENT_DIR": "$CASE/custom-agent", + "OMP_PROFILE": "work" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR with PI_PROFILE", + "args": [], + "env": { + "PI_CODING_AGENT_DIR": "$CASE/custom-agent", + "PI_PROFILE": "work" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR handed down by PI_PROFILE, OMP_PROFILE empty", + "args": [], + "env": { + "OMP_PROFILE": "", + "PI_PROFILE": "work", + "PI_CODING_AGENT_DIR": "$CASE/home/.omp/profiles/work/agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR handed down by OMP_PROFILE, profile flag named default", + "args": [ + "--profile", + "default" + ], + "env": { + "OMP_PROFILE": "work", + "PI_CODING_AGENT_DIR": "$CASE/home/.omp/profiles/work/agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "session-dir flag", + "args": [ + "--session-dir", + "$CASE/custom-sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "session-dir flag, equals form", + "args": [ + "--session-dir=$CASE/custom-sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "session-dir flag naming the managed store", + "args": [ + "--session-dir", + "$CASE/home/.omp/agent/sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_SESSION_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "$CASE/env-sessions" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/env-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_SESSION_DIR with profile flag", + "args": [ + "--profile", + "work" + ], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "$CASE/env-sessions" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/env-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_SESSION_DIR with PI_CODING_AGENT_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "$CASE/env-sessions", + "PI_CODING_AGENT_DIR": "$CASE/custom-agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/env-sessions", + "depth": 1, + "terminalSessions": "$CASE/custom-agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_SESSION_DIR with XDG omp roots existing", + "args": [], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "$CASE/env-sessions", + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/env-sessions", + "depth": 1, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "session-dir flag over PI_CODING_AGENT_SESSION_DIR", + "args": [ + "--session-dir", + "$CASE/custom-sessions" + ], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "$CASE/env-sessions" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "session-dir flag with profile flag", + "args": [ + "--profile", + "work", + "--session-dir", + "$CASE/custom-sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "PI_CODING_AGENT_DIR with session-dir flag", + "args": [ + "--session-dir", + "$CASE/custom-sessions" + ], + "env": { + "PI_CODING_AGENT_DIR": "$CASE/custom-agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/custom-agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "empty PI_CODING_AGENT_SESSION_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_SESSION_DIR": "" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "empty PI_CODING_AGENT_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_DIR": "" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG roots absent", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG roots existing", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data", + "$CASE/xdg-state" + ], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/xdg-data/omp/sessions", + "depth": 2, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG data omp root only", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp" + ], + "observed": { + "sessions": "$CASE/xdg-data/omp/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG state omp root only", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with profile flag", + "args": [ + "--profile", + "work" + ], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp profile roots existing with profile flag", + "args": [ + "--profile", + "work" + ], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp/profiles/work", + "$CASE/xdg-state/omp/profiles/work" + ], + "observed": { + "sessions": "$CASE/xdg-data/omp/profiles/work/sessions", + "depth": 2, + "terminalSessions": "$CASE/xdg-state/omp/profiles/work/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with OMP_PROFILE", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state", + "OMP_PROFILE": "work" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/home/.omp/profiles/work/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/profiles/work/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with PI_CODING_AGENT_DIR", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state", + "PI_CODING_AGENT_DIR": "$CASE/custom-agent" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/custom-agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/custom-agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with PI_CODING_AGENT_DIR naming the default agent", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state", + "PI_CODING_AGENT_DIR": "$CASE/home/.omp/agent" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/xdg-data/omp/sessions", + "depth": 2, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with PI_CODING_AGENT_DIR naming the default agent with a trailing slash", + "args": [], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state", + "PI_CODING_AGENT_DIR": "$CASE/home/.omp/agent/" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/xdg-data/omp/sessions", + "depth": 2, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG omp roots existing with session-dir flag", + "args": [ + "--session-dir", + "$CASE/custom-sessions" + ], + "env": { + "XDG_DATA_HOME": "$CASE/xdg-data", + "XDG_STATE_HOME": "$CASE/xdg-state" + }, + "mkdirs": [ + "$CASE/xdg-data/omp", + "$CASE/xdg-state/omp" + ], + "observed": { + "sessions": "$CASE/custom-sessions", + "depth": 1, + "terminalSessions": "$CASE/xdg-state/omp/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "XDG unset, default XDG omp roots existing", + "args": [], + "env": {}, + "mkdirs": [ + "$CASE/home/.local/share/omp", + "$CASE/home/.local/state/omp" + ], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "empty XDG roots, default XDG omp roots existing", + "args": [], + "env": { + "XDG_DATA_HOME": "", + "XDG_STATE_HOME": "" + }, + "mkdirs": [ + "$CASE/home/.local/share/omp", + "$CASE/home/.local/state/omp" + ], + "observed": { + "sessions": "$CASE/home/.omp/agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "relative PI_CODING_AGENT_DIR", + "args": [], + "env": { + "PI_CODING_AGENT_DIR": "rel-agent" + }, + "mkdirs": [], + "observed": { + "sessions": "$CASE/home/project/rel-agent/sessions", + "depth": 2, + "terminalSessions": "$CASE/home/project/rel-agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + }, + { + "name": "relative cwd and relative session-dir", + "args": [ + "--cwd", + "sub", + "--session-dir", + "rel-sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": null, + "depth": null, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project/sub" + } + }, + { + "name": "relative session-dir", + "args": [ + "--session-dir", + "rel-sessions" + ], + "env": {}, + "mkdirs": [], + "observed": { + "sessions": null, + "depth": null, + "terminalSessions": "$CASE/home/.omp/agent/terminal-sessions", + "cwd": "$CASE/home/project" + } + } + ] +} diff --git a/server/gjc-runtime.ts b/server/gjc-runtime.ts index 38ab8d1964..1cf51b539a 100644 --- a/server/gjc-runtime.ts +++ b/server/gjc-runtime.ts @@ -168,6 +168,11 @@ export function parseGjcPs(output: string): GjcTerminal | null { * header decides, and a rejected candidate can be megabytes. */ function transcriptCwd(path: string): string | null { + return transcriptHeader(path)?.cwd ?? null; +} + +/** transcriptCwd's header, with the session version it names (undefined when it names none). */ +export function transcriptHeader(path: string): { cwd: string; version: unknown } | null { let fd: number; try { fd = openSync(path, "r"); @@ -178,9 +183,9 @@ function transcriptCwd(path: string): string | null { const buffer = Buffer.alloc(4096); const size = readSync(fd, buffer, 0, buffer.length, 0); const lines = buffer.subarray(0, size).toString("utf8").split("\n"); - let header = JSON.parse(lines[0] ?? "") as { type?: string; cwd?: unknown }; - if (header.type === "title") header = JSON.parse(lines[1] ?? "") as { type?: string; cwd?: unknown }; - return header.type === "session" && typeof header.cwd === "string" ? header.cwd : null; + let header = JSON.parse(lines[0] ?? "") as { type?: string; cwd?: unknown; version?: unknown }; + if (header.type === "title") header = JSON.parse(lines[1] ?? "") as { type?: string; cwd?: unknown; version?: unknown }; + return header.type === "session" && typeof header.cwd === "string" ? { cwd: header.cwd, version: header.version } : null; } catch { return null; // not a gjc transcript, or a header longer than the read } finally { @@ -190,30 +195,51 @@ function transcriptCwd(path: string): string | null { /** * Shared breadcrumb/descriptor lookup: GJC and omp subagents live beside their session file, so - * this helper maps such a path to its parent. The pane resolver uses the explicit lifecycle record. + * this helper maps such a path to its parent, which must itself be a session inside the store once + * links are followed. `depth` is where sessions sit: 2 for `/.jsonl`, 1 for a session + * directory of omp's own holding `.jsonl`. The pane resolver uses the explicit lifecycle record. */ -export function gjcSessionFile(root: string, path: string, paths: PlatformPath = nodePath): string | null { +export function gjcSessionFile(root: string, path: string, paths: PlatformPath = nodePath, depth: 1 | 2 = 2): string | null { const parts = path.endsWith(".jsonl") ? storeRelative(root, path, paths) : null; if (!parts) return null; - if (parts.length === 2) return path; - if (parts.length !== 3) return null; - const session = paths.join(root, parts[0]!, `${parts[1]!}.jsonl`); - try { return statSync(session).isFile() ? session : null; } catch { return null; } + if (parts.length === depth) return path; + if (parts.length !== depth + 1) return null; + const session = paths.join(root, ...parts.slice(0, depth - 1), `${parts[depth - 1]!}.jsonl`); + try { + if (!statSync(session).isFile()) return null; + // a parent linked out of the store is no session of it + return storeRelative(realpathSync(root), realpathSync(session), paths)?.length === depth ? session : null; + } catch { return null; } } -/** Validate the shared native breadcrumb under `agentDir/terminal-sessions` and reject stale leftovers. */ -export function terminalBreadcrumb(agentDir: string, cwd: string, terminalId: string, startedAt: number): string | null { +/** + * Where a store keeps its sessions and terminal breadcrumbs: `/sessions//.jsonl` + * and `/terminal-sessions` by default. omp can move either (omp.ts ompStoreLayout). + */ +export interface StoreRoots { sessions: string; terminalSessions: string; depth: 1 | 2 } + +const defaultStoreRoots = (agentDir: string): StoreRoots => + ({ sessions: join(agentDir, "sessions"), terminalSessions: join(agentDir, "terminal-sessions"), depth: 2 }); + +/** + * Validate the shared native breadcrumb under the store's terminal sessions (an agent directory's + * own, or the roots given) and reject stale leftovers. + */ +export function terminalBreadcrumb(store: string | StoreRoots, cwd: string, terminalId: string, startedAt: number): string | null { if (!/^(?:pts-\d+|tty[\w-]+|tmux-%\d+)$/.test(terminalId) || !Number.isFinite(startedAt)) return null; + const roots = typeof store === "string" ? defaultStoreRoots(store) : store; try { - const marker = join(agentDir, "terminal-sessions", terminalId); + const marker = join(roots.terminalSessions, terminalId); const stat = statSync(marker); if (!stat.isFile() || stat.size > 8192 || stat.mtimeMs < startedAt - 1000) return null; const [savedCwd, savedPath] = readFileSync(marker, "utf8").split("\n"); if (!savedCwd || !savedPath || realpathSync(savedCwd) !== realpathSync(cwd)) return null; - const root = realpathSync(join(agentDir, "sessions")); + // omp writes a relative session directory as given, relative to a directory nothing proves + if (!nodePath.isAbsolute(savedPath)) return null; + const root = realpathSync(roots.sessions); const saved = realpathSync(savedPath); if (!statSync(saved).isFile()) return null; - const path = gjcSessionFile(root, saved); + const path = gjcSessionFile(root, saved, nodePath, roots.depth); if (!path) return null; const headerCwd = transcriptCwd(path); return headerCwd && realpathSync(headerCwd) === realpathSync(cwd) ? path : null; @@ -221,23 +247,23 @@ export function terminalBreadcrumb(agentDir: string, cwd: string, terminalId: st } /** - * The transcript an agent's processes show, from its store under `agentDir` (GJC and omp lay - * theirs out alike): an exact open transcript first, then the terminal-scoped breadcrumb written + * The transcript an agent's processes show, from its store's `roots` (GJC and omp lay theirs out + * alike): an exact open transcript first, then the terminal-scoped breadcrumb written * during the process's lifetime. A directory descriptor or cwd proves only the store, not the * active session; never infer ownership from cwd or session recency. `path` is null unless * exactly one transcript qualifies; `found` says how many did. `environOf` supplies a process's * environment already read elsewhere, so none is read here; undefined for a pid reads it. */ -export function heldTranscript(agentDir: string, cwd: string, pids: readonly number[], environOf?: (pid: number) => readonly string[] | null | undefined): { path: string | null; found: number } { +export function heldTranscript(roots: StoreRoots, cwd: string, pids: readonly number[], environOf?: (pid: number) => readonly string[] | null | undefined): { path: string | null; found: number } { let root: string; - try { root = realpathSync(join(agentDir, "sessions")); } + try { root = realpathSync(roots.sessions); } catch { return { path: null, found: 0 }; } const open = new Set(); const breadcrumbs = new Set(); for (const pid of pids) { const terminal = gjcTerminal(pid, environOf?.(pid)); if (terminal) { - const path = terminalBreadcrumb(agentDir, cwd, terminal.id, terminal.startedAt); + const path = terminalBreadcrumb(roots, cwd, terminal.id, terminal.startedAt); if (path) breadcrumbs.add(path); } let fds: string[] = []; @@ -245,7 +271,7 @@ export function heldTranscript(agentDir: string, cwd: string, pids: readonly num for (const fd of fds) { try { const file = realpathSync(readlinkSync(`/proc/${pid}/fd/${fd}`)); - const target = statSync(file).isFile() ? gjcSessionFile(root, file) : null; + const target = statSync(file).isFile() ? gjcSessionFile(root, file, nodePath, roots.depth) : null; if (target && transcriptCwd(target) === cwd) open.add(target); } catch { /* closed, deleted or unreadable descriptor */ } } diff --git a/server/omp-process.test.ts b/server/omp-process.test.ts index 552e0f5b0c..a3fe23808d 100644 --- a/server/omp-process.test.ts +++ b/server/omp-process.test.ts @@ -1,5 +1,6 @@ import { expect, it, spyOn } from "bun:test"; -import { createOmpProcessProbe, processStartTicks } from "./omp-process.ts"; +import { processEnviron } from "./omo.ts"; +import { createOmpProcessProbe, ompRoutingEnviron, processStartTicks } from "./omp-process.ts"; // The probe answers null, by design, for a process in uninterruptible sleep or one whose memory // cannot be read in time. A bun target spends its first milliseconds, longer on a loaded PC, paging @@ -35,6 +36,28 @@ it.skipIf(process.platform !== "linux")("reads a live process's arguments and on } finally { target.kill(); await target.exited; } }); +it.skipIf(process.platform !== "linux")("keeps only the variables omp's store lookup reads, through the helper and the direct read", async () => { + const routing = { + HOME: "/r", OMP_PROFILE: "work", PI_PROFILE: "pi", PI_CONFIG_DIR: ".alt", PI_CODING_AGENT_DIR: "/r/agent", PI_CODING_AGENT_SESSION_DIR: "/r/sessions", + XDG_DATA_HOME: "/r/data", XDG_STATE_HOME: "/r/state", TMUX: "/tmp/tmux-1/default,1,0", TMUX_PANE: "%3", + }; + // secrets, look-alike names and the XDG roots omp's store lookup never reads + const env = { ...routing, PATH: process.env["PATH"] ?? "", SECRET_TOKEN: "SECRET_TOKEN_value", OPENAI_API_KEY: "SECRET_TOKEN_key", PI_CODING_AGENT_DIR_EXTRA: "SECRET_TOKEN_x", HOMEDIR: "SECRET_TOKEN_h", XDG_CONFIG_HOME: "/r/config" }; + const target = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { env, stdin: "ignore", stdout: "ignore", stderr: "ignore" }); + try { + const expected = Object.entries(routing).map(([key, value]) => `${key}=${value}`).sort(); + const helper = await createOmpProcessProbe()(target.pid, true); + expect([...helper?.environ ?? []].sort()).toEqual(expected); + const direct = ompRoutingEnviron(processEnviron(target.pid)); + expect([...direct ?? []].sort()).toEqual(expected); + expect(JSON.stringify([helper, direct])).not.toContain("SECRET_TOKEN"); + } finally { target.kill(); await target.exited; } + // an environment read empty is a process gone or not ours, not one without the variables + expect(ompRoutingEnviron(null)).toBeNull(); + expect(ompRoutingEnviron([""])).toBeNull(); + expect(ompRoutingEnviron(["PATH=/bin", ""])).toEqual([]); +}); + it.skipIf(process.platform !== "linux")("keeps a process's empty arguments where they stand", async () => { const { argv, target } = await startedShell(["--cwd", "", "/B", ""]); try { diff --git a/server/omp-process.ts b/server/omp-process.ts index f469816749..611ef68fd0 100644 --- a/server/omp-process.ts +++ b/server/omp-process.ts @@ -4,7 +4,21 @@ import { readFileSync } from "node:fs"; export interface ProcessMemory { argv: string[]; environ: string[] } const MAX_OUTPUT = 1024 * 1024; const MAX_CACHED = 64; -/** Only what omp's store and terminal lookups read: the rest of the environment may hold secrets. */ +/** + * The variables omp's store and terminal lookups read (omp.ts ompStoreLayout, gjcTerminal): the + * rest of the environment may hold secrets, so nothing else leaves the read. + */ +const ROUTING = /^(?:HOME|OMP_PROFILE|PI_PROFILE|PI_CONFIG_DIR|PI_CODING_AGENT_DIR|PI_CODING_AGENT_SESSION_DIR|XDG_DATA_HOME|XDG_STATE_HOME|TMUX|TMUX_PANE)=/; + +/** + * The routing words of a process's environment, or null when it could not be read. An environment + * read empty is a process gone or not ours to read, not one without the variables. + */ +export function ompRoutingEnviron(environ: readonly string[] | null): string[] | null { + if (environ === null || !environ.some((word) => word !== "")) return null; + return environ.filter((word) => ROUTING.test(word)); +} + const SCRIPT = ` const fs = require("node:fs"), pid = process.argv[1]; try { @@ -17,7 +31,9 @@ try { if (words.at(-1) === "") words.pop(); return words; }; - const environ = read("environ").filter(word => /^(?:OMP_PROFILE|TMUX|TMUX_PANE)=/.test(word)); + const raw = read("environ"); + if (!raw.some(word => word !== "")) process.exit(1); + const environ = raw.filter(word => ${ROUTING}.test(word)); console.log(JSON.stringify({ start: fields[19], argv: process.argv[2] === "1" ? read("cmdline") : [], environ })); } catch { process.exit(1); } `; diff --git a/server/omp.contract.test.ts b/server/omp.contract.test.ts index b4897ce05a..c4afb8afcd 100644 --- a/server/omp.contract.test.ts +++ b/server/omp.contract.test.ts @@ -69,7 +69,8 @@ beforeAll(async () => { const split = await herdrRpc<{ pane: { pane_id: string } }>("pane.split", { target_pane_id: paneId, direction: "down", focus: false }); handoffPaneId = split.pane.pane_id; const screens = ["generating-narrow.txt", "cancelled-narrow.txt"].map((name) => fileURLToPath(new URL(`./fixtures/omp-handoff-18.8.7-${name}`, import.meta.url))); - await herdrRpc("pane.send_text", { pane_id: handoffPaneId, text: `exec '${join(handoffBin, "omp")}' '${join(root, "handoff.js")}' '${screens[0]}' '${screens[1]}' '${handoffMarker}'\n` }); + // omp keeps its sessions under its own home, which the bridge reads from the process + await herdrRpc("pane.send_text", { pane_id: handoffPaneId, text: `HOME='${root}' exec '${join(handoffBin, "omp")}' '${join(root, "handoff.js")}' '${screens[0]}' '${screens[1]}' '${handoffMarker}'\n` }); for (const deadline = Date.now() + 5_000; (await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === handoffPaneId)?.agent !== "omp";) { if (Date.now() > deadline) throw new Error("the stand-in omp did not start in the handoff pane"); await Bun.sleep(50); diff --git a/server/omp.test.ts b/server/omp.test.ts index fbb31f944e..9dc7cd7a1e 100644 --- a/server/omp.test.ts +++ b/server/omp.test.ts @@ -1,14 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { mkdirSync, mkdtempSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import * as fs from "node:fs"; -import { tmpdir } from "node:os"; +import { tmpdir, userInfo } from "node:os"; import { join } from "node:path"; import type { HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; import { forgetTranscriptState, paneConversation } from "./conversation.ts"; import * as herdr from "./herdr/client.ts"; import { terminalBreadcrumb } from "./gjc-runtime.ts"; -import { isOmpHelper, isOmpProcess, ompAgentDir, ompHeldTranscript, ompLaunch, ompLaunchDir } from "./omp.ts"; +import { isOmpHelper, isOmpProcess, ompAgentDir, ompHeldTranscript, ompLaunch, ompLaunchDir, ompStoreLayout } from "./omp.ts"; +import layouts from "./fixtures/omp-18.8.7-store-layouts.json"; import * as ompProbe from "./omp-process.ts"; import * as omo from "./omo.ts"; @@ -44,7 +45,7 @@ it("keeps a process's sessions where its profile puts them, and nowhere it canno }); it("reads omp's cwd and store options from its argv the way omp does", () => { - const cases: [string[], { profile: string | null; sessionDir: boolean; cwd: string | null } | null][] = [ + const cases: [string[], { profile: string | null; sessionDir: boolean; cwd: string | null; sessionDirValue?: string } | null][] = [ [["omp"], { profile: null, sessionDir: false, cwd: null }], // split and equal forms, the last one winning [["omp", "--cwd", "/a", "--cwd=/b"], { profile: null, sessionDir: false, cwd: "/b" }], @@ -72,7 +73,7 @@ it("reads omp's cwd and store options from its argv the way omp does", () => { [["omp", "fix --cwd /x", "--cwd", "/a"], { profile: null, sessionDir: false, cwd: "/a" }], // interpreter-launched: options start after omp's script [["bun", "/opt/omp/dist/omp.js", "--cwd", "/a"], { profile: null, sessionDir: false, cwd: "/a" }], - [["omp", "--session-dir=/s", "--cwd", "/a"], { profile: null, sessionDir: true, cwd: "/a" }], + [["omp", "--session-dir=/s", "--cwd", "/a"], { profile: null, sessionDir: true, cwd: "/a", sessionDirValue: "/s" }], // omp ignores a trailing cwd option and stays where it started on an empty one [["omp", "--cwd"], { profile: null, sessionDir: false, cwd: null }], [["omp", "--cwd", "/a", "--cwd"], { profile: null, sessionDir: false, cwd: "/a" }], @@ -135,6 +136,69 @@ it("reads omp's breadcrumb, whose transcript starts with a title record before t } finally { rmSync(home, { recursive: true, force: true }); } }); +it("keeps each process's store where native omp put it", () => { + expect(layouts.provenance.omp).toBe("omp/18.8.7"); + const unsupported = new Set(layouts.provenance.unsupported); + const root = realpathSync(mkdtempSync(join(tmpdir(), "omp-layouts-"))); + try { + for (const [index, entry] of layouts.cases.entries()) { + const at = join(root, String(index)); + const fill = (value: string) => value.replaceAll("$CASE", at); + for (const dir of entry.mkdirs) mkdirSync(fill(dir), { recursive: true }); + // the probe started every case in its own home, which the case may replace + const env: Record = { HOME: join(at, "home"), ...entry.env as Record }; + const environ = Object.entries(env).map(([key, value]) => `${key}=${fill(value)}`); + const layout = ompStoreLayout(["omp", ...entry.args.map(fill)], environ, "linux"); + const { sessions, terminalSessions, depth } = entry.observed; + const expected = unsupported.has(entry.name) ? null : { sessions: fill(sessions!), terminalSessions: fill(terminalSessions!), depth }; + expect([entry.name, layout]).toEqual([entry.name, expected]); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("reads a process's store from its own argv and environment, and nothing it cannot prove", () => { + const home = "/home/u"; + const managed = (agent: string) => ({ sessions: join(agent, "sessions"), terminalSessions: join(agent, "terminal-sessions"), depth: 2 as const }); + const own = (agent: string, sessions: string) => ({ ...managed(agent), sessions, depth: 1 as const }); + const env = (...words: string[]) => [`HOME=${home}`, ...words]; + // the process's own home and config folder, never the bridge's; an unset or empty home is the account's + expect(ompStoreLayout(["omp"], env(), "linux")).toEqual(managed("/home/u/.omp/agent")); + expect(ompStoreLayout(["omp"], env("PI_CONFIG_DIR=.alt"), "linux")).toEqual(managed("/home/u/.alt/agent")); + for (const environ of [[], ["HOME="]]) expect(ompStoreLayout(["omp"], environ, "linux")).toEqual(managed(join(userInfo().homedir, ".omp/agent"))); + // a relative home, or a variable given twice, proves nothing + expect(ompStoreLayout(["omp"], ["HOME=rel"], "linux")).toBeNull(); + expect(ompStoreLayout(["omp"], env("HOME=/other"), "linux")).toBeNull(); + // profile names as omp reads them: trimmed, `default` naming none, a refused one starting nothing + expect(ompStoreLayout(["omp", "--profile", "default"], env("OMP_PROFILE=work"), "linux")).toEqual(managed("/home/u/.omp/agent")); + expect(ompStoreLayout(["omp", "--profile", " work "], env(), "linux")).toEqual(managed("/home/u/.omp/profiles/work/agent")); + expect(ompStoreLayout(["omp", "--profile", "work"], env("OMP_PROFILE=Bad!"), "linux")).toEqual(managed("/home/u/.omp/profiles/work/agent")); + expect(ompStoreLayout(["omp"], env("OMP_PROFILE=", "PI_PROFILE=work"), "linux")).toEqual(managed("/home/u/.omp/agent")); + for (const name of ["Bad!", "..", "con", "a."]) expect(ompStoreLayout(["omp"], env(`OMP_PROFILE=${name}`), "linux")).toBeNull(); + // a profile ignores PI_CODING_AGENT_DIR, and so does omp when the environment's profile handed it down + expect(ompStoreLayout(["omp"], env("PI_CODING_AGENT_DIR=/a/../agent/"), "linux")).toEqual(managed("/agent")); + expect(ompStoreLayout(["omp", "--profile", "work"], env("PI_CODING_AGENT_DIR=/agent"), "linux")).toEqual(managed("/home/u/.omp/profiles/work/agent")); + expect(ompStoreLayout(["omp"], env("OMP_PROFILE=", "PI_PROFILE=work", "PI_CODING_AGENT_DIR=/home/u/.omp/profiles/work/agent"), "linux")).toEqual(managed("/home/u/.omp/agent")); + expect(ompStoreLayout(["omp"], env("PI_CODING_AGENT_DIR=rel"), "linux")).toBeNull(); + // a session directory of its own: the last flag, else the variable; nothing after `--` + expect(ompStoreLayout(["omp", "--session-dir=/a", "--session-dir", "/b/"], env("PI_CODING_AGENT_SESSION_DIR=/c"), "linux")).toEqual(own("/home/u/.omp/agent", "/b")); + expect(ompStoreLayout(["omp"], env("PI_CODING_AGENT_SESSION_DIR=/c", "PI_CODING_AGENT_DIR=/agent"), "linux")).toEqual(own("/agent", "/c")); + expect(ompStoreLayout(["omp", "--", "--session-dir", "/s"], env("PI_CODING_AGENT_SESSION_DIR="), "linux")).toEqual(managed("/home/u/.omp/agent")); + for (const argv of [["omp", "--session-dir="], ["omp", "--session-dir"], ["omp", "--session-dir", "rel"]]) expect(ompStoreLayout(argv, env(), "linux")).toBeNull(); + expect(ompStoreLayout(["omp", "--profile"], env(), "linux")).toBeNull(); + // an environment that could not be read proves nothing, not even the default store + for (const platform of ["linux", "darwin", "win32"] as const) expect(ompStoreLayout(["omp", "--profile", "work"], null, platform)).toBeNull(); + const root = realpathSync(mkdtempSync(join(tmpdir(), "omp-xdg-"))); + try { + mkdirSync(join(root, "data", "omp"), { recursive: true }); + const xdg = env(`XDG_DATA_HOME=${join(root, "data")}`, `XDG_STATE_HOME=${join(root, "state")}`); + expect(ompStoreLayout(["omp"], xdg, "linux")).toEqual({ ...managed("/home/u/.omp/agent"), sessions: join(root, "data/omp/sessions") }); + // omp reads XDG roots on macOS only from 18.12: which one runs is not known + expect(ompStoreLayout(["omp"], xdg, "darwin")).toBeNull(); + expect(ompStoreLayout(["omp"], env(), "darwin")).toEqual(managed("/home/u/.omp/agent")); + expect(ompStoreLayout(["omp"], env("XDG_DATA_HOME=rel"), "linux")).toBeNull(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + describe("an omp pane's chat", () => { let home = ""; let savedHome: string | undefined; @@ -192,7 +256,8 @@ describe("an omp pane's chat", () => { const move = moveTo === undefined ? "" : `process.chdir(${JSON.stringify(moveTo)}); `; writeFileSync(script, `${move}for (const file of ${JSON.stringify(held)}) require("node:fs").openSync(file, "a"); console.log("ready"); setInterval(() => {}, 1000);`); const argv = [process.execPath, script, ...options]; - const child = Bun.spawn(argv, { cwd, env: env ? { ...process.env, ...env } : undefined, stdout: "pipe", stderr: "ignore", stdin: "ignore" }); + // the test's HOME: omp keeps its store under its own home, and a spawn would inherit the real one + const child = Bun.spawn(argv, { cwd, env: { ...process.env, ...env }, stdout: "pipe", stderr: "ignore", stdin: "ignore" }); reaped.push(async () => { child.kill(); await child.exited; }); const first = await Promise.race([ child.stdout.getReader().read(), @@ -361,7 +426,7 @@ describe("an omp pane's chat", () => { let helperArgv: string[] = []; const wsl = spyOn(ompProbe, "isWsl").mockReturnValue(true); const probe = spyOn(ompProbe, "ompProcessMemory").mockImplementation(async (pid: number) => - ({ argv: pid === install.pid ? helperArgv : launched.argv, environ: [] })); + ({ argv: pid === install.pid ? helperArgv : launched.argv, environ: [`HOME=${home}`] })); restores.push(() => wsl.mockRestore(), () => probe.mockRestore()); for (const argv of [[binary, "add", "--cwd", env, "--ignore-scripts", "lodash"], [binary, "__omp_worker_tts"]]) { helperArgv = argv; @@ -422,7 +487,7 @@ describe("an omp pane's chat", () => { mkdirSync(bin); const script = join(bin, "omp.js"); writeFileSync(script, `require("node:fs").openSync(${JSON.stringify(path)}, "a"); console.log("ready"); setInterval(() => {}, 1000);`); - const child = Bun.spawn([process.execPath, script], { stdout: "pipe", stderr: "ignore", stdin: "ignore" }); + const child = Bun.spawn([process.execPath, script], { env: { ...process.env }, stdout: "pipe", stderr: "ignore", stdin: "ignore" }); try { const reader = child.stdout.getReader(); const first = await Promise.race([ @@ -454,7 +519,7 @@ describe("an omp pane's chat", () => { const script = join(bin, "omp.js"); writeFileSync(script, `require("node:fs").openSync(${JSON.stringify(path)}, "a"); console.log("ready"); setInterval(() => {}, 1000);`); const argv = [process.execPath, script, ...(profile === null ? [] : ["--profile", profile])]; - const child = Bun.spawn(argv, { stdout: "pipe", stderr: "ignore", stdin: "ignore" }); + const child = Bun.spawn(argv, { env: { ...process.env }, stdout: "pipe", stderr: "ignore", stdin: "ignore" }); try { const reader = child.stdout.getReader(); const first = await Promise.race([ @@ -511,7 +576,7 @@ describe("an omp pane's chat", () => { writeFileSync(path, transcript(pane.cwd!)); mkdirSync(join(agentDir, "terminal-sessions")); writeFileSync(join(agentDir, "terminal-sessions", "tmux-%4242"), `${pane.cwd}\n${path}\n`); - const probe = spyOn(ompProbe, "ompProcessMemory").mockResolvedValue({ argv: ["omp"], environ: ["TMUX=/tmp/tmux-1/default,1,0", "TMUX_PANE=%4242"] }); + const probe = spyOn(ompProbe, "ompProcessMemory").mockResolvedValue({ argv: ["omp"], environ: [`HOME=${home}`, "TMUX=/tmp/tmux-1/default,1,0", "TMUX_PANE=%4242"] }); const environ = spyOn(omo, "processEnviron").mockImplementation(() => { throw new Error("unsafe synchronous read"); }); const read = fs.readFileSync; const files = spyOn(fs, "readFileSync").mockImplementation(((file: fs.PathOrFileDescriptor, options?: unknown) => { @@ -525,4 +590,215 @@ describe("an omp pane's chat", () => { expect(environ).not.toHaveBeenCalled(); expect(files.mock.calls.some(([file]) => String(file).endsWith("/environ"))).toBe(false); }); + + /** A transcript at `path` whose header names `cwd`, answering `answer`, of session `version`. */ + const writeTranscript = (path: string, cwd: string, { answer = "synthetic answer", version = 3 }: { answer?: string; version?: number } = {}) => { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, transcript(cwd).replace("synthetic answer", answer).replace('"version":3', `"version":${version}`)); + return path; + }; + const slug = (cwd: string) => cwd.replaceAll("/", "-"); + const unavailable = async () => { + forgetTranscriptState(); + await expect(paneConversation(pane.pane_id)).rejects.toThrow("no_session_path"); + }; + const only = async (omp: { pid: number; argv: string[] }) => { + processes = [{ pid: omp.pid, name: "omp", argv: omp.argv }]; + }; + const reapAll = async () => { for (const reap of reaped.splice(0)) await reap(); }; + + it.skipIf(process.platform !== "linux")("finds a moved store's session through the held file, the breadcrumb and herdr's path", async () => { + const own = join(home, "own-sessions"); + // omp keeps a session directory of its own flat: .jsonl + const path = writeTranscript(join(own, "2026-10-01_s1.jsonl"), pane.cwd!); + for (const [options, env] of [[[], { PI_CODING_AGENT_SESSION_DIR: own }], [["--session-dir", own], {}], [[`--session-dir=${own}/`], {}]] as const) { + await only(await standIn([...options], [path], { env })); + for (const session of [undefined, otherAgent, { agent: "omp", kind: "path", value: path }]) { + agentSession = session; + expect(await chat()).toEqual(synthetic); + } + await reapAll(); + } + // the breadcrumb stays where the agent directory puts it: here the XDG state root + const data = join(home, "xdg-data"), state = join(home, "xdg-state"); + mkdirSync(join(data, "omp"), { recursive: true }); + mkdirSync(join(state, "omp", "terminal-sessions"), { recursive: true }); + await only(await standIn([], [], { env: { PI_CODING_AGENT_SESSION_DIR: own, XDG_DATA_HOME: data, XDG_STATE_HOME: state, TMUX: "/tmp/tmux-1/default,1,0", TMUX_PANE: "%4260" } })); + agentSession = undefined; + // a breadcrumb where this process does not write names nothing + mkdirSync(join(home, ".omp/agent/terminal-sessions"), { recursive: true }); + writeFileSync(join(home, ".omp/agent/terminal-sessions/tmux-%4260"), `${pane.cwd}\n${path}\n`); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + writeFileSync(join(state, "omp/terminal-sessions/tmux-%4260"), `${pane.cwd}\n${path}\n`); + expect(await chat()).toEqual(synthetic); + await reapAll(); + // a custom agent directory's managed store, and the XDG data root's + const custom = join(home, "custom-agent"); + const managed = writeTranscript(join(custom, "sessions", slug(pane.cwd!), "2026-10-02_s2.jsonl"), pane.cwd!); + const xdgManaged = writeTranscript(join(data, "omp", "sessions", slug(pane.cwd!), "2026-10-03_s3.jsonl"), pane.cwd!); + for (const [held, env] of [[managed, { PI_CODING_AGENT_DIR: custom }], [xdgManaged, { XDG_DATA_HOME: data }]] as const) { + await only(await standIn([], [held], { env })); + for (const session of [undefined, { agent: "omp", kind: "path", value: held }]) { + agentSession = session; + expect(await chat()).toEqual(synthetic); + } + await reapAll(); + } + }); + + it.skipIf(process.platform !== "linux")("holds herdr's path to the store the pane's omp process writes to", async () => { + const own = join(home, "own-sessions"); + const mine = writeTranscript(join(own, "2026-10-01_s1.jsonl"), pane.cwd!); + // a session of the pane's directory in the default store, which this omp does not write to + const decoy = writeTranscript(join(home, ".omp/agent/sessions", slug(pane.cwd!), "2026-10-02_decoy.jsonl"), pane.cwd!, { answer: "decoy answer" }); + const decoyChat = [synthetic[0], { kind: "text" as const, text: "decoy answer" }]; + agentSession = { agent: "omp", kind: "path", value: decoy }; + // the session it holds is the answer, not the decoy + await only(await standIn(["--session-dir", own], [mine])); + expect(await chat()).toEqual(synthetic); + await reapAll(); + // and with nothing held, there is none + await only(await standIn([], [], { env: { PI_CODING_AGENT_SESSION_DIR: own } })); + await unavailable(); + await reapAll(); + // a profile's process does not write to the default store either + await only(await standIn(["--profile", "other"], [])); + await unavailable(); + await reapAll(); + // a session of another directory in the process's own flat store is not this pane's + const elsewhere = writeTranscript(join(own, "2026-10-03_s3.jsonl"), join(home, "elsewhere"), { answer: "decoy answer" }); + await only(await standIn(["--session-dir", own], [])); + agentSession = { agent: "omp", kind: "path", value: elsewhere }; + await unavailable(); + await reapAll(); + // a plain omp's own default store: herdr's path reads, the decoy among them + await only(await standIn([], [])); + agentSession = { agent: "omp", kind: "path", value: decoy }; + expect(await chat()).toEqual(decoyChat); + await reapAll(); + // where herdr shows no omp process, its path in the user's own default store reads as before + processes = []; + expect(await chat()).toEqual(decoyChat); + }); + + it.skipIf(process.platform !== "linux")("refuses links out of the store and session versions omp does not read", async () => { + const env = { TMUX: "/tmp/tmux-1/default,1,0", TMUX_PANE: "%4261" }; + const breadcrumb = (path: string) => { + mkdirSync(join(home, ".omp/agent/terminal-sessions"), { recursive: true }); + writeFileSync(join(home, ".omp/agent/terminal-sessions/tmux-%4261"), `${pane.cwd}\n${path}\n`); + }; + // a flat store nested in the default store: a link out of it stays out, though it lands in the default store + const outside = writeTranscript(join(home, ".omp/agent/sessions", slug(pane.cwd!), "2026-10-01_s1.jsonl"), pane.cwd!); + const nested = join(home, ".omp/agent/sessions/private"); + mkdirSync(nested, { recursive: true }); + const linked = join(nested, "2026-10-01_s1.jsonl"); + symlinkSync(outside, linked); + await only(await standIn(["--session-dir", nested], [linked], { env })); + breadcrumb(linked); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + for (const value of [linked, outside]) { + agentSession = { agent: "omp", kind: "path", value }; + await unavailable(); + } + await reapAll(); + // a subagent's file whose session links out of the store + const own = join(home, "own-sessions"); + const subagent = writeTranscript(join(own, "2026-10-02_s2", "task.jsonl"), pane.cwd!); + symlinkSync(outside, join(own, "2026-10-02_s2.jsonl")); + await only(await standIn(["--session-dir", own], [subagent], { env })); + breadcrumb(subagent); + agentSession = undefined; + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + await reapAll(); + // a session version omp does not read, held, named by its breadcrumb or by herdr + const future = writeTranscript(join(own, "2026-10-03_s3.jsonl"), pane.cwd!, { version: 4 }); + await only(await standIn(["--session-dir", own], [future], { env })); + breadcrumb(future); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + agentSession = { agent: "omp", kind: "path", value: future }; + await unavailable(); + // the version omp writes reads + writeTranscript(future, pane.cwd!); + expect(await chat()).toEqual(synthetic); + }); + + it.skipIf(process.platform !== "linux")("reads herdr's path in a moved store with nothing held, and waits on one omp has not written", async () => { + const own = join(home, "own-sessions"); + const path = writeTranscript(join(own, "2026-10-01_s1.jsonl"), pane.cwd!); + await only(await standIn([], [], { env: { PI_CODING_AGENT_SESSION_DIR: own } })); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + agentSession = { agent: "omp", kind: "path", value: path }; + expect(await chat()).toEqual(synthetic); + // omp names its session file at start and writes it with the first answer + agentSession = { agent: "omp", kind: "path", value: join(own, "2026-10-02_s2.jsonl") }; + forgetTranscriptState(); + expect(await paneConversation(pane.pane_id)).toMatchObject({ source: "omp-transcript", turns: [], history_id: "unwritten:2026-10-02_s2" }); + }); + + it.skipIf(process.platform !== "linux")("reads herdr's path in a managed store whose session names a folder that is gone", async () => { + // omp resumes a session of a deleted folder without moving, so its header and herdr's directory differ + const gone = join(home, "gone"); + const path = writeTranscript(join(home, ".omp/agent/sessions", slug(gone), "2026-10-01_s1.jsonl"), gone); + await only(await standIn([], [])); + agentSession = { agent: "omp", kind: "path", value: path }; + expect(await chat()).toEqual(synthetic); + }); + + it.skipIf(process.platform !== "linux")("herdr's path to the launch directory's session never outranks the live directory's", async () => { + const a = join(home, "A"), b = join(home, "B"), own = join(home, "own-sessions"); + mkdirSync(a); mkdirSync(b); + // started in A, then resumed a session of B, which it holds; herdr still names A's + const old = writeTranscript(join(own, "2026-10-01_old.jsonl"), a, { answer: "decoy answer" }); + const current = writeTranscript(join(own, "2026-10-02_current.jsonl"), b); + const omp = await standIn(["--cwd", a, "--session-dir", own], [current], { moveTo: b }); + await only(omp); + pane.foreground_cwd = liveDir(omp.pid); + agentSession = { agent: "omp", kind: "path", value: old }; + expect(await chat()).toEqual(synthetic); + // herdr's path to a session of the live directory reads as named, over the held one + const named = writeTranscript(join(own, "2026-10-03_named.jsonl"), b, { answer: "named answer" }); + agentSession = { agent: "omp", kind: "path", value: named }; + expect(await chat()).toEqual([synthetic[0], { kind: "text", text: "named answer" }]); + }); + + it.skipIf(process.platform !== "linux")("an agent process whose argv cannot be read beside a readable omp shows no chat", async () => { + const plain = await standIn([], [storeTranscript(pane.cwd!)]); + const unread = await standIn(["--session-dir", join(home, "other")], []); + // herdr leaves out the argv of a process it takes for an agent; the helper cannot read this one + const read = ompProbe.ompProcessMemory; + const probe = spyOn(ompProbe, "ompProcessMemory").mockImplementation(async (pid: number, argv: boolean) => pid === unread.pid ? null : read(pid, argv)); + restores.push(() => probe.mockRestore()); + processes = [{ pid: plain.pid, name: "omp", argv: plain.argv }, { pid: unread.pid, name: "omp" }]; + agentSession = { agent: "omp", kind: "path", value: storeTranscript(pane.cwd!) }; + await unavailable(); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + // once that process is gone, the readable one's store is the pane's + await reapAll(); + await only(await standIn([], [storeTranscript(pane.cwd!)])); + processes.push({ pid: unread.pid, name: "omp" }); + expect(await chat()).toEqual(synthetic); + }); + + it.skipIf(process.platform !== "linux")("omp processes keeping different stores, or one whose environment cannot be read, show no chat", async () => { + const a = join(home, "a"), b = join(home, "b"); + const mine = writeTranscript(join(a, "2026-10-01_s1.jsonl"), pane.cwd!); + const theirs = writeTranscript(join(b, "2026-10-01_s2.jsonl"), pane.cwd!); + const first = await standIn([], [mine], { env: { PI_CODING_AGENT_SESSION_DIR: a } }); + const second = await standIn([], [theirs], { env: { PI_CODING_AGENT_SESSION_DIR: b } }); + processes = [first, second].map((omp) => ({ pid: omp.pid, name: "omp", argv: omp.argv })); + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + for (const value of [mine, theirs]) { + agentSession = { agent: "omp", kind: "path", value }; + await unavailable(); + } + // alone, each finds its own + await only(first); + agentSession = undefined; + expect(await chat()).toEqual(synthetic); + // with its environment unreadable, not even herdr's path in the default store reads + const environ = spyOn(omo, "processEnviron").mockReturnValue(null); + restores.push(() => environ.mockRestore()); + agentSession = { agent: "omp", kind: "path", value: storeTranscript(pane.cwd!) }; + await unavailable(); + }); }); diff --git a/server/omp.ts b/server/omp.ts index b73c1e67fe..a0faff8bac 100644 --- a/server/omp.ts +++ b/server/omp.ts @@ -3,12 +3,15 @@ * Herdr deliberately omits recognized agents' argv on WSL to avoid blocking procfs reads. * Recover process identity outside the bridge, then use the open transcript or breadcrumb. */ -import { join, resolve } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; +import { userInfo } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { darwinProcess } from "./claude-store.ts"; import { paneProcessInfo } from "./herdr/client.ts"; -import { heldTranscript, runsAgent } from "./gjc-runtime.ts"; +import { heldTranscript, runsAgent, storeRelative, type StoreRoots, transcriptHeader } from "./gjc-runtime.ts"; import { processEnviron } from "./omo.ts"; -import { isWsl, ompProcessMemory } from "./omp-process.ts"; +import { isWsl, ompProcessMemory, ompRoutingEnviron, processStartTicks } from "./omp-process.ts"; const OMP_EXECUTABLE = /(^|[\\/])omp(?:\.exe|\.[cm]?js)?$/i; @@ -43,8 +46,12 @@ const VALUE_OPTIONS = new Set([ "--approval-mode", ]); -/** What an omp command line says about where it keeps its session: the last of each option wins. */ -export interface OmpLaunch { profile: string | null; sessionDir: boolean; cwd: string | null } +/** + * What an omp command line says about where it keeps its session: the last of each option wins. + * `sessionDirValue` is the last `--session-dir`'s value ("" for an empty one, undefined when it + * ends argv without one or none is given). + */ +export interface OmpLaunch { profile: string | null; sessionDir: boolean; cwd: string | null; sessionDirValue?: string } /** * The store options of omp's own argv (as the process has it, never a shell string), read the way @@ -73,6 +80,7 @@ export function ompLaunch(argv: readonly string[]): OmpLaunch | null { if (VALUE_OPTIONS.has(word) && word !== "--plan" && at + 1 < argv.length) rest.push(argv[++at]!); } let cwd = "", sessionDir = false; + let sessionDirValue: string | undefined; for (let at = 0; at < rest.length; at++) { let word = rest[at]!; let value: string | undefined; @@ -81,10 +89,10 @@ export function ompLaunch(argv: readonly string[]): OmpLaunch | null { if (word === "--") break; if (equals < 0 && VALUE_OPTIONS.has(word)) value = rest[++at]; // a moved store counts even without its value: omp's environment can move it too - if (word === "--session-dir") sessionDir = true; + if (word === "--session-dir") { sessionDir = true; sessionDirValue = value; } else if (word === "--cwd" && value !== undefined) cwd = value; } - return { profile, sessionDir, cwd: cwd || null }; + return { profile, sessionDir, cwd: cwd || null, sessionDirValue }; } /** @@ -115,49 +123,221 @@ export function ompLaunchDir(argv: readonly string[]): string | null { return absolute ? resolve(cwd) : null; } +const PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const WINDOWS_DEVICE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i; + /** - * The transcript the pane's omp process holds open or last pointed its terminal breadcrumb at, or - * null. `cwd` is the pane's live directory, herdr's foreground cwd where it reports one: omp changes - * into its `--cwd` when it starts and into a resumed session's project later, and names that - * directory in its session header, so the live one is matched first. An absolute `--cwd` is tried - * only when that finds nothing (herdr reports a directory that is not omp's own, or none), and - * only when every omp process in the pane started with the same one. omp's helpers count for neither. + * A profile name read the way omp reads it: trimmed, none for an empty one or `default`, and null + * for one omp refuses to start with. + */ +function ompProfileName(value: string | undefined): string | undefined | null { + const name = value?.trim(); + if (!name || name === "default") return undefined; + return name === "." || name === ".." || name.endsWith(".") || !PROFILE_NAME.test(name) || WINDOWS_DEVICE.test(name) ? null : name; +} + +/** + * Where one omp process writes its sessions and terminal breadcrumbs (gjc-runtime.ts StoreRoots): + * `sessions//.jsonl` (depth 2), or a session directory of its own holding `.jsonl` + * (depth 1) while its breadcrumbs stay where the agent directory puts them. + */ +export type OmpLayout = StoreRoots; + +/** + * The stores of one omp process, from its own argv and routing environment (never the bridge's), + * as omp 18.8.7 and 18.12 pick them (scripts/omp-store-layout-probe.ts, + * server/fixtures/omp-18.8.7-store-layouts.json): + * - the config root is `$HOME/`, and a profile's `/profiles/`; + * - the profile is the last `--profile`, else OMP_PROFILE when set (even empty), else PI_PROFILE; + * empty and `default` name none; + * - the agent directory is the profile's `agent`. Without a profile PI_CODING_AGENT_DIR overrides + * it, unless it names the agent directory of the profile the environment names (a parent omp's + * profile handed it down); + * - an agent directory that is the default one keeps its sessions under + * `$XDG_DATA_HOME/omp[/profiles/]/sessions` and its breadcrumbs under + * `$XDG_STATE_HOME/omp[/profiles/]/terminal-sessions`, each only when that directory exists; + * - the last `--session-dir`, else PI_CODING_AGENT_SESSION_DIR, is a session directory of its own. + * Only the environment omp was started with counts: values omp loads later from its `.env` files + * are not seen, so such a store is not found (the transcripts lie outside the store this names). + * null when nothing proves the store: argv omp would refuse, an environment that could not be read + * (Linux and macOS show a same-user process's own; Windows none), a variable given twice, a + * profile name omp refuses, a relative directory (omp resolved it against a directory nothing + * records), an empty or missing `--session-dir` value, or on macOS an XDG omp root (whether omp + * reads those there depends on its version). + */ +export function ompStoreLayout(argv: readonly string[], environ: readonly string[] | null, platform: NodeJS.Platform = process.platform): OmpLayout | null { + const launch = ompLaunch(argv); + if (launch === null || environ === null) return null; + const variables = new Map(); + for (const word of environ) { + const equals = word.indexOf("="); + if (equals <= 0) continue; + // which of two the process read is not proven + if (variables.has(word.slice(0, equals))) return null; + variables.set(word.slice(0, equals), word.slice(equals + 1)); + } + const variable = (key: string) => variables.get(key); + let user: string; + // omp's home is $HOME, or the account's own when it is unset or empty + try { user = variable("HOME") || userInfo().homedir; } catch { return null; } + if (!isAbsolute(user)) return null; + const root = join(user, variable("PI_CONFIG_DIR") || ".omp"); + const fromEnv = ompProfileName(variable("OMP_PROFILE") !== undefined ? variable("OMP_PROFILE") : variable("PI_PROFILE")); + const profile = launch.profile !== null ? ompProfileName(launch.profile) : fromEnv; + if (profile === null) return null; + const defaultAgent = join(profile === undefined ? root : join(root, "profiles", profile), "agent"); + let agent = defaultAgent; + if (profile === undefined) { + const override = variable("PI_CODING_AGENT_DIR"); + // a name omp refuses counts as none here: it read it only under a `--profile` that named none + const named = (name: string | null | undefined) => name === null ? undefined : name; + const handedDown = named(fromEnv) ?? named(ompProfileName(variable("PI_PROFILE"))); + if (override && !(handedDown !== undefined && override === join(root, "profiles", handedDown, "agent"))) { + if (!isAbsolute(override)) return null; + agent = resolve(override); + } + } + let sessions = join(agent, "sessions"), terminalSessions = join(agent, "terminal-sessions"); + if (agent === defaultAgent) { + const xdg = (key: string): string | null | undefined => { + const base = variable(key); + if (!base) return undefined; + if (!isAbsolute(base)) return null; + const app = profile === undefined ? join(base, "omp") : join(base, "omp", "profiles", profile); + return existsSync(app) ? app : undefined; + }; + const data = xdg("XDG_DATA_HOME"), state = xdg("XDG_STATE_HOME"); + if (data === null || state === null || platform !== "linux" && (data || state)) return null; + if (data) sessions = join(data, "sessions"); + if (state) terminalSessions = join(state, "terminal-sessions"); + } + if (launch.sessionDir && !launch.sessionDirValue) return null; + const own = launch.sessionDir ? launch.sessionDirValue : variable("PI_CODING_AGENT_SESSION_DIR") || undefined; + if (own === undefined) return { sessions, terminalSessions, depth: 2 }; + return isAbsolute(own) ? { sessions: resolve(own), terminalSessions, depth: 1 } : null; +} + +/** + * The routing words of a process's environment where the system shows a same-user process's own + * (procfs on Linux, the kernel's argument area on macOS), or null. + */ +function routingEnviron(pid: number): readonly string[] | null { + if (process.platform === "linux") return ompRoutingEnviron(processEnviron(pid)); + if (process.platform === "darwin") return ompRoutingEnviron(darwinProcess.environment(pid)?.split("\0") ?? null); + return null; +} + +/** The pane's omp processes and the one layout they share (ompPaneRoute). */ +export interface OmpRoute { + layout: OmpLayout; + pids: number[]; + /** The routing environment read for each process, standing in for gjcTerminal's own read. */ + environOf: (pid: number) => readonly string[] | undefined; + /** The absolute `--cwd` every omp process in the pane started with, or null (ompLaunchDir). */ + launchDir: string | null; +} + +/** + * Where the pane's omp processes keep their sessions: the one layout their own argv and routing + * environment give (ompStoreLayout). null when herdr shows no omp process in the pane, or none + * whose argv could be read; "unproved" when one's store cannot be told, two keep different ones, + * or a live process whose argv herdr left out could not be read beside one that was, since + * nothing then says which session the pane shows. omp's helpers count for nothing. */ -export async function ompHeldTranscript(paneId: string, cwd: string, home = process.env["HOME"] ?? ""): Promise { +export async function ompPaneRoute(paneId: string): Promise { const info = await paneProcessInfo(paneId).catch(() => null); - let agentDir: string | null = null; + let layout: OmpLayout | null = null; const pids: number[] = []; const launchDirs = new Set(); const environs = new Map(); + let unread = false; for (const foreground of info?.foreground_processes ?? []) { if (typeof foreground.pid !== "number") continue; let argv = Array.isArray(foreground.argv) ? foreground.argv.map(String) : []; if (argv.length > 0 && (!isOmpProcess(argv) || isOmpHelper(argv))) continue; - // Herdr omits a recognized agent's argv on WSL; there, and only there, procfs memory reads - // may block, so they go through the helper. Elsewhere herdr's argv and a direct read hold. + // Herdr omits a recognized agent's argv (on Linux, WSL included); procfs memory reads may + // block on WSL, so these go through the helper. Elsewhere herdr's argv and a direct read hold. const probe = process.platform === "linux" && (argv.length === 0 || isWsl()); - let environ: readonly string[] | undefined; + let environ: readonly string[] | null; if (probe) { const memory = await ompProcessMemory(foreground.pid, argv.length === 0); - if (memory === null) continue; + // an omp herdr named whose environment cannot be read may keep its sessions anywhere + if (memory === null && argv.length > 0) return "unproved"; + // a live agent whose argv could not be read may be an omp keeping another store + if (memory === null) { unread ||= processStartTicks(foreground.pid) !== null; continue; } if (argv.length === 0) argv = memory.argv; environ = memory.environ; - } + } else environ = routingEnviron(foreground.pid); if (!isOmpProcess(argv) || isOmpHelper(argv)) continue; - const dir = ompAgentDir(argv, environ ?? processEnviron(foreground.pid), home); - // a moved store, or two omp processes keeping different ones: nothing says which the pane shows - if (dir === null || agentDir !== null && dir !== agentDir) return null; - agentDir = dir; + const next = ompStoreLayout(argv, environ); + if (next === null || layout !== null && (next.sessions !== layout.sessions || next.terminalSessions !== layout.terminalSessions || next.depth !== layout.depth)) return "unproved"; + layout = next; pids.push(foreground.pid); launchDirs.add(ompLaunchDir(argv)); - if (environ !== undefined) environs.set(foreground.pid, environ); + if (environ !== null) environs.set(foreground.pid, environ); } - if (agentDir === null) return null; - // a probed process's environment stands in for gjcTerminal's /proc read, which could block too - const environOf = (pid: number) => environs.get(pid); - const live = heldTranscript(agentDir, cwd, pids, environOf); - if (live.found > 0) return live.path; + if (layout === null) return null; + if (unread) return "unproved"; const [launchDir] = launchDirs; - if (launchDirs.size !== 1 || !launchDir || launchDir === cwd) return null; - return heldTranscript(agentDir, launchDir, pids, environOf).path; + return { layout, pids, environOf: (pid) => environs.get(pid), launchDir: launchDirs.size === 1 ? launchDir ?? null : null }; +} + +/** + * Whether omp reads a session of this header's version: 18.8.7 and 18.12 write version 3 and + * migrate 1, 2 and a header naming none. A later version's records are unproved here. + */ +const ompSessionVersion = (version: unknown): boolean => version === undefined || version === 1 || version === 2 || version === 3; + +const sameDirectory = (a: string, b: string): boolean => { + if (a === b) return true; + try { return realpathSync(a) === realpathSync(b); } catch { return false; } +}; + +/** + * The transcript a route's processes hold open or last pointed their terminal breadcrumb at, or + * null. `cwd` is the pane's live directory, herdr's foreground cwd where it reports one: omp changes + * into its `--cwd` when it starts and into a resumed session's project later, and names that + * directory in its session header, so the live one is matched first. The absolute `--cwd` every + * process started with is tried only when that finds nothing (herdr reports a directory that is + * not omp's own, or none). + */ +export function ompRouteTranscript(route: OmpRoute, cwd: string): string | null { + const { layout, pids, environOf, launchDir } = route; + const live = heldTranscript(layout, cwd, pids, environOf); + const path = live.found > 0 ? live.path : launchDir && launchDir !== cwd ? heldTranscript(layout, launchDir, pids, environOf).path : null; + return path !== null && ompSessionVersion(transcriptHeader(path)?.version) ? path : null; +} + +/** + * The store of a route that holds `path`, the transcript herdr names, as written: `/.jsonl` + * in a managed store, `.jsonl` in a session directory of its own, or a subagent's file beside + * either. null when it is not there. The caller still follows links and checks the header + * (ompRouteOwns). + */ +export function ompRouteStore({ layout }: OmpRoute, path: string): string | null { + if (!path.endsWith(".jsonl") || !isAbsolute(path)) return null; + const parts = storeRelative(layout.sessions, path); + return parts !== null && (parts.length === layout.depth || parts.length === layout.depth + 1) ? layout.sessions : null; +} + +/** + * Whether a written transcript in a route's store, links followed, is this pane's: of the shape omp + * writes there and of a version omp reads. A session directory of its own holds every directory's + * sessions side by side, so there the header must also name the pane's live directory (one naming + * only the launch directory is left to ompRouteTranscript, which tries that one after the live + * one). A managed store keeps each directory's sessions in a folder of their own, and herdr's path + * there is taken as before, also for a session of a directory that is gone. + */ +export function ompRouteOwns({ layout }: OmpRoute, file: string, cwd: string): boolean { + let parts: string[] | null; + try { parts = storeRelative(realpathSync(layout.sessions), realpathSync(file)); } catch { return false; } + if (parts === null || parts.length !== layout.depth && parts.length !== layout.depth + 1) return false; + const header = transcriptHeader(file); + return header !== null && ompSessionVersion(header.version) && (layout.depth === 2 || sameDirectory(header.cwd, cwd)); +} + +/** The transcript the pane's omp process holds open or last pointed its terminal breadcrumb at, or null (ompRouteTranscript). */ +export async function ompHeldTranscript(paneId: string, cwd: string): Promise { + const route = await ompPaneRoute(paneId); + return route === null || route === "unproved" ? null : ompRouteTranscript(route, cwd); } From 9d8b65f2745a9293855a72c7d1c6a5106103074a Mon Sep 17 00:00:00 2001 From: mp Date: Sun, 25 Oct 2026 00:33:09 +0900 Subject: [PATCH 2/2] fix(omp): read omp's environment only through the bounded helper, and refuse a lone unread omp Merge pass gate review of #1898: - A pane whose only omp process could not be read got a null route, which fell back to the bridge's own default store for herdr's path. A live process whose argv and environment cannot be read now makes the route unproved whether or not another omp was read. - On native Linux with herdr's argv, the route read /proc//environ synchronously in the bridge. Every Linux read now goes through the bounded helper (timeout, size cap, refuses a process in D state), which fails closed to unproved. Tests: every Linux read now goes through the real helper, so the chat tests give it a deadline no busy PC reaches, and the routing-key test uses #1862's started shell target, as omp-process.test.ts does. --- server/omp-process.test.ts | 4 +-- server/omp.test.ts | 51 +++++++++++++++++++++++++++----------- server/omp.ts | 25 +++++++++---------- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/server/omp-process.test.ts b/server/omp-process.test.ts index a3fe23808d..1fc6d02450 100644 --- a/server/omp-process.test.ts +++ b/server/omp-process.test.ts @@ -43,10 +43,10 @@ it.skipIf(process.platform !== "linux")("keeps only the variables omp's store lo }; // secrets, look-alike names and the XDG roots omp's store lookup never reads const env = { ...routing, PATH: process.env["PATH"] ?? "", SECRET_TOKEN: "SECRET_TOKEN_value", OPENAI_API_KEY: "SECRET_TOKEN_key", PI_CODING_AGENT_DIR_EXTRA: "SECRET_TOKEN_x", HOMEDIR: "SECRET_TOKEN_h", XDG_CONFIG_HOME: "/r/config" }; - const target = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { env, stdin: "ignore", stdout: "ignore", stderr: "ignore" }); + const { target } = await startedShell([], env); try { const expected = Object.entries(routing).map(([key, value]) => `${key}=${value}`).sort(); - const helper = await createOmpProcessProbe()(target.pid, true); + const helper = await createOmpProcessProbe({ timeoutMs: READ_MS })(target.pid, true); expect([...helper?.environ ?? []].sort()).toEqual(expected); const direct = ompRoutingEnviron(processEnviron(target.pid)); expect([...direct ?? []].sort()).toEqual(expected); diff --git a/server/omp.test.ts b/server/omp.test.ts index 9dc7cd7a1e..095143f738 100644 --- a/server/omp.test.ts +++ b/server/omp.test.ts @@ -205,6 +205,7 @@ describe("an omp pane's chat", () => { let agentSession: unknown; let processes: { pid: number; name?: string; argv?: string[] }[] = []; let pane: HerdrPane; + let readProcess: typeof ompProbe.ompProcessMemory; const restores: Array<() => void> = []; const transcript = (cwd: string) => [ JSON.stringify({ type: "title", v: 1, title: "t" }), @@ -226,7 +227,11 @@ describe("an omp pane's chat", () => { if (method === "pane.process_info") return { process_info: { foreground_processes: processes } } as never; throw new Error(`unexpected RPC: ${method}`); }); - restores.push(() => snapshot.mockRestore(), () => rpc.mockRestore()); + // the real helper reads each process, with a deadline no busy PC reaches: it is a bun that can + // take over the default second to start there (omp-process.test.ts) + readProcess = ompProbe.createOmpProcessProbe({ timeoutMs: 30_000 }); + const memory = spyOn(ompProbe, "ompProcessMemory").mockImplementation(readProcess); + restores.push(() => snapshot.mockRestore(), () => rpc.mockRestore(), () => memory.mockRestore()); }); afterEach(() => { for (const restore of restores.splice(0)) restore(); @@ -547,25 +552,38 @@ describe("an omp pane's chat", () => { } }); - it.skipIf(process.platform !== "linux")("on WSL a failed probe with supplied argv never falls back to a synchronous environment read", async () => { - const wsl = spyOn(ompProbe, "isWsl").mockReturnValue(true); + it.skipIf(process.platform !== "linux")("a failed probe with supplied argv never falls back to a synchronous environment read, and shows no chat", async () => { const probe = spyOn(ompProbe, "ompProcessMemory").mockResolvedValue(null); - restores.push(() => wsl.mockRestore()); - const environ = spyOn(omo, "processEnviron").mockImplementation(() => { throw new Error("unsafe synchronous read"); }); - restores.push(() => probe.mockRestore(), () => environ.mockRestore()); + restores.push(() => probe.mockRestore()); processes = [{ pid: process.pid, argv: ["omp"] }]; + // herdr's path in the user's own default store is no answer for an omp whose store is unproved + agentSession = { agent: "omp", kind: "path", value: storeTranscript(pane.cwd!) }; + await unavailable(); + const environ = spyOn(omo, "processEnviron").mockImplementation(() => { throw new Error("unsafe synchronous read"); }); + restores.push(() => environ.mockRestore()); + probe.mockClear(); expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); expect(probe).toHaveBeenCalledWith(process.pid, false); expect(environ).not.toHaveBeenCalled(); }); - it.skipIf(process.platform !== "linux")("a native Linux pane whose argv herdr supplies keeps the direct read and spawns no helper", async () => { + it.skipIf(process.platform !== "linux")("a native Linux pane whose argv herdr supplies reads its environment through the bounded helper", async () => { + const path = storeTranscript(pane.cwd!); + const omp = await standIn([], [path]); const wsl = spyOn(ompProbe, "isWsl").mockReturnValue(false); const probe = spyOn(ompProbe, "ompProcessMemory"); - restores.push(() => wsl.mockRestore(), () => probe.mockRestore()); - processes = [{ pid: process.pid, argv: ["omp"] }]; - expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); - expect(probe).not.toHaveBeenCalled(); + const environ = spyOn(omo, "processEnviron").mockImplementation(() => { throw new Error("unsafe synchronous read"); }); + const read = fs.readFileSync; + const files = spyOn(fs, "readFileSync").mockImplementation(((file: fs.PathOrFileDescriptor, options?: unknown) => { + if (String(file).endsWith("/environ")) throw new Error(`unsafe synchronous read of ${String(file)}`); + return read(file, options as never); + }) as typeof fs.readFileSync); + restores.push(() => wsl.mockRestore(), () => probe.mockRestore(), () => environ.mockRestore(), () => files.mockRestore()); + processes = [{ pid: omp.pid, name: "omp", argv: omp.argv }]; + expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBe(path); + expect(probe).toHaveBeenCalledWith(omp.pid, false); + expect(environ).not.toHaveBeenCalled(); + expect(files.mock.calls.some(([file]) => String(file).endsWith("/environ"))).toBe(false); }); it.skipIf(process.platform !== "linux")("a successful probe's environment finds the tmux breadcrumb with no synchronous environ read", async () => { @@ -761,17 +779,20 @@ describe("an omp pane's chat", () => { expect(await chat()).toEqual([synthetic[0], { kind: "text", text: "named answer" }]); }); - it.skipIf(process.platform !== "linux")("an agent process whose argv cannot be read beside a readable omp shows no chat", async () => { + it.skipIf(process.platform !== "linux")("an agent process whose argv cannot be read, alone or beside a readable omp, shows no chat", async () => { const plain = await standIn([], [storeTranscript(pane.cwd!)]); const unread = await standIn(["--session-dir", join(home, "other")], []); // herdr leaves out the argv of a process it takes for an agent; the helper cannot read this one - const read = ompProbe.ompProcessMemory; + const read = readProcess; const probe = spyOn(ompProbe, "ompProcessMemory").mockImplementation(async (pid: number, argv: boolean) => pid === unread.pid ? null : read(pid, argv)); restores.push(() => probe.mockRestore()); processes = [{ pid: plain.pid, name: "omp", argv: plain.argv }, { pid: unread.pid, name: "omp" }]; agentSession = { agent: "omp", kind: "path", value: storeTranscript(pane.cwd!) }; await unavailable(); expect(await ompHeldTranscript(pane.pane_id, pane.cwd!)).toBeNull(); + // alone, it proves no store either: herdr's path in the bridge's own default store is no answer + processes = [{ pid: unread.pid, name: "omp" }]; + await unavailable(); // once that process is gone, the readable one's store is the pane's await reapAll(); await only(await standIn([], [storeTranscript(pane.cwd!)])); @@ -796,8 +817,8 @@ describe("an omp pane's chat", () => { agentSession = undefined; expect(await chat()).toEqual(synthetic); // with its environment unreadable, not even herdr's path in the default store reads - const environ = spyOn(omo, "processEnviron").mockReturnValue(null); - restores.push(() => environ.mockRestore()); + const probe = spyOn(ompProbe, "ompProcessMemory").mockResolvedValue(null); + restores.push(() => probe.mockRestore()); agentSession = { agent: "omp", kind: "path", value: storeTranscript(pane.cwd!) }; await unavailable(); }); diff --git a/server/omp.ts b/server/omp.ts index a0faff8bac..6d8581101b 100644 --- a/server/omp.ts +++ b/server/omp.ts @@ -10,8 +10,7 @@ import { isAbsolute, join, resolve } from "node:path"; import { darwinProcess } from "./claude-store.ts"; import { paneProcessInfo } from "./herdr/client.ts"; import { heldTranscript, runsAgent, storeRelative, type StoreRoots, transcriptHeader } from "./gjc-runtime.ts"; -import { processEnviron } from "./omo.ts"; -import { isWsl, ompProcessMemory, ompRoutingEnviron, processStartTicks } from "./omp-process.ts"; +import { ompProcessMemory, ompRoutingEnviron, processStartTicks } from "./omp-process.ts"; const OMP_EXECUTABLE = /(^|[\\/])omp(?:\.exe|\.[cm]?js)?$/i; @@ -218,11 +217,10 @@ export function ompStoreLayout(argv: readonly string[], environ: readonly string } /** - * The routing words of a process's environment where the system shows a same-user process's own - * (procfs on Linux, the kernel's argument area on macOS), or null. + * The routing words of a process's environment on macOS, from the kernel's argument area, or null + * (Linux reads through the bounded helper in ompPaneRoute; Windows shows none). */ function routingEnviron(pid: number): readonly string[] | null { - if (process.platform === "linux") return ompRoutingEnviron(processEnviron(pid)); if (process.platform === "darwin") return ompRoutingEnviron(darwinProcess.environment(pid)?.split("\0") ?? null); return null; } @@ -239,10 +237,10 @@ export interface OmpRoute { /** * Where the pane's omp processes keep their sessions: the one layout their own argv and routing - * environment give (ompStoreLayout). null when herdr shows no omp process in the pane, or none - * whose argv could be read; "unproved" when one's store cannot be told, two keep different ones, - * or a live process whose argv herdr left out could not be read beside one that was, since - * nothing then says which session the pane shows. omp's helpers count for nothing. + * environment give (ompStoreLayout). null when herdr shows no live omp process in the pane; + * "unproved" when one's store cannot be told, two keep different ones, or a live process whose + * argv herdr left out could not be read, since nothing then says which store, or which session, + * the pane shows. omp's helpers count for nothing. */ export async function ompPaneRoute(paneId: string): Promise { const info = await paneProcessInfo(paneId).catch(() => null); @@ -255,9 +253,10 @@ export async function ompPaneRoute(paneId: string): Promise 0 && (!isOmpProcess(argv) || isOmpHelper(argv))) continue; - // Herdr omits a recognized agent's argv (on Linux, WSL included); procfs memory reads may - // block on WSL, so these go through the helper. Elsewhere herdr's argv and a direct read hold. - const probe = process.platform === "linux" && (argv.length === 0 || isWsl()); + // Herdr omits a recognized agent's argv (on Linux, WSL included). A procfs memory read blocks + // while the process holds its memory map locked (omp does while it pages in, and on WSL while + // it exits), so on Linux every read goes through the bounded helper, never the bridge itself. + const probe = process.platform === "linux"; let environ: readonly string[] | null; if (probe) { const memory = await ompProcessMemory(foreground.pid, argv.length === 0); @@ -276,8 +275,8 @@ export async function ompPaneRoute(paneId: string): Promise environs.get(pid), launchDir: launchDirs.size === 1 ? launchDir ?? null : null }; }