This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(root): warn when PTY_ROOT masks a co-set PTY_SESSION_DIR (#67)

Follow-up to the scratch-session leak smalltalk-claude hit: a harness that
sets the deprecated PTY_SESSION_DIR to isolate, while running in an env that
already exports PTY_ROOT (e.g. a supervised session tree), had its
PTY_SESSION_DIR silently ignored — getSessionDir() prefers the canonical
PTY_ROOT — so its scratch sessions landed in the ambient registry.

Precedence is correct and unchanged (canonical PTY_ROOT wins over the
deprecated legacy var). The fix is visibility: getSessionDir() now warns once
(unless PTY_ROOT_LEGACY_SILENT) when BOTH are set, naming both dirs and
pointing at PTY_ROOT as the isolation mechanism — so the masking is obvious
instead of an invisible leak.

- src/sessions.ts: one-time masking warning when PTY_ROOT + PTY_SESSION_DIR
are both set.
- tests/pty-root.test.ts: regression that a `run -d` session with PTY_ROOT set
lands ONLY under it (not the co-set PTY_SESSION_DIR, not the default), plus
warning visible / PTY_ROOT_LEGACY_SILENT suppresses it.
- README: PTY_ROOT is the isolation mechanism; document the masking + warning.

Verified: pty-root suite 16/16; npm run typecheck clean.

authored by

Nathan and committed by
GitHub
(Jul 9, 2026, 6:45 PM +0200) 7d17be8a bc4d70ce

+81 -1
+2
README.md
··· 168 168 169 169 `PTY_SESSION_DIR` (the pre-Phase-2 name for the same env var) still works and emits a one-time deprecation notice. Set `PTY_ROOT_LEGACY_SILENT=1` to suppress the notice while migrating. 170 170 171 + **`PTY_ROOT` is the isolation mechanism — use it, not `PTY_SESSION_DIR`.** When both are set, `PTY_ROOT` (canonical) wins and the deprecated `PTY_SESSION_DIR` is ignored — with a one-time warning so the masking is visible. So a scratch/test harness running inside an environment that already exports `PTY_ROOT` (e.g. a supervised session tree) must set `PTY_ROOT` to isolate; setting only `PTY_SESSION_DIR` would be silently overridden by the ambient `PTY_ROOT` and its sessions would land in the ambient registry. 172 + 171 173 ### Project Files 172 174 173 175 A project can include a `pty.toml` to declare its sessions:
+16 -1
src/sessions.ts
··· 12 12 export const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "pty"); 13 13 14 14 let hasWarnedLegacyRootEnv = false; 15 + let hasWarnedRootMasksLegacy = false; 15 16 16 17 const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 17 18 ··· 70 71 71 72 export function getSessionDir(): string { 72 73 const root = process.env.PTY_ROOT; 73 - if (root && root.length > 0) return root; 74 74 const legacy = process.env.PTY_SESSION_DIR; 75 + if (root && root.length > 0) { 76 + // PTY_ROOT (canonical) wins. If a caller ALSO set the deprecated 77 + // PTY_SESSION_DIR — e.g. a test/scratch harness trying to isolate — it's 78 + // silently masked, so their sessions land under PTY_ROOT instead of the dir 79 + // they asked for. Warn once (unless silenced) so the masking is visible 80 + // rather than an invisible leak into the wrong registry. 81 + if (legacy && legacy.length > 0 && !hasWarnedRootMasksLegacy && !process.env.PTY_ROOT_LEGACY_SILENT) { 82 + hasWarnedRootMasksLegacy = true; 83 + process.stderr.write( 84 + `pty: both PTY_ROOT and PTY_SESSION_DIR are set — using PTY_ROOT (${root}); ` + 85 + `PTY_SESSION_DIR (${legacy}) is ignored (deprecated). For isolation, set PTY_ROOT.\n` 86 + ); 87 + } 88 + return root; 89 + } 75 90 if (legacy && legacy.length > 0) { 76 91 if (!hasWarnedLegacyRootEnv && !process.env.PTY_ROOT_LEGACY_SILENT) { 77 92 hasWarnedLegacyRootEnv = true;
+63
tests/pty-root.test.ts
··· 193 193 expect(plist).toContain("<string>com.myobie.pty.gc.weird-name-with-spaces</string>"); 194 194 }); 195 195 }); 196 + 197 + describe("run -d honors PTY_ROOT for isolation; PTY_SESSION_DIR masking is visible", () => { 198 + // Regression for the scratch-session leak: a detached session must land in 199 + // PTY_ROOT and NOT in a co-set (deprecated) PTY_SESSION_DIR or the default 200 + // registry. PTY_ROOT is the isolation mechanism; PTY_SESSION_DIR is legacy. 201 + it("a -d session lands ONLY under PTY_ROOT (not the co-set PTY_SESSION_DIR, not the default)", () => { 202 + const root = makeRoot(); 203 + const scratch = makeRoot(); // legacy var that must be ignored 204 + const name = `rd${Math.random().toString(36).slice(2, 7)}`; 205 + // `cat` waits on stdin, so the session stays running while we inspect. 206 + const res = runCli(["run", "-d", "--id", name, "--", "cat"], { 207 + PATH: process.env.PATH, 208 + HOME: process.env.HOME, 209 + PTY_ROOT: root, 210 + PTY_SESSION_DIR: scratch, 211 + PTY_ROOT_LEGACY_SILENT: "1", 212 + }); 213 + try { 214 + expect(res.status).toBe(0); 215 + // Landed under PTY_ROOT. 216 + expect(fs.existsSync(path.join(root, `${name}.json`))).toBe(true); 217 + expect(fs.existsSync(path.join(root, `${name}.sock`))).toBe(true); 218 + // NOT under the deprecated PTY_SESSION_DIR. 219 + expect(fs.existsSync(path.join(scratch, `${name}.json`))).toBe(false); 220 + // NOT in the real default registry. 221 + const def = path.join(os.homedir(), ".local", "state", "pty", `${name}.json`); 222 + expect(fs.existsSync(def)).toBe(false); 223 + } finally { 224 + runCli(["kill", name], { 225 + PATH: process.env.PATH, 226 + HOME: process.env.HOME, 227 + PTY_ROOT: root, 228 + PTY_ROOT_LEGACY_SILENT: "1", 229 + }); 230 + } 231 + }); 232 + 233 + it("warns (once) that PTY_ROOT wins when PTY_SESSION_DIR is also set", () => { 234 + const res = runCli(["list", "--json"], { 235 + PATH: process.env.PATH, 236 + HOME: process.env.HOME, 237 + PTY_ROOT: makeRoot(), 238 + PTY_SESSION_DIR: makeRoot(), 239 + // no PTY_ROOT_LEGACY_SILENT → the masking warning should surface 240 + }); 241 + expect(res.status).toBe(0); 242 + expect(res.stderr).toMatch(/both PTY_ROOT and PTY_SESSION_DIR are set/); 243 + // Exactly once per invocation. 244 + expect(res.stderr.match(/both PTY_ROOT and PTY_SESSION_DIR are set/g)?.length).toBe(1); 245 + }); 246 + 247 + it("PTY_ROOT_LEGACY_SILENT suppresses the masking warning", () => { 248 + const res = runCli(["list", "--json"], { 249 + PATH: process.env.PATH, 250 + HOME: process.env.HOME, 251 + PTY_ROOT: makeRoot(), 252 + PTY_SESSION_DIR: makeRoot(), 253 + PTY_ROOT_LEGACY_SILENT: "1", 254 + }); 255 + expect(res.status).toBe(0); 256 + expect(res.stderr).not.toMatch(/both PTY_ROOT and PTY_SESSION_DIR/); 257 + }); 258 + });