This repository has no description
0

Configure Feed

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

feat(root): PTY_ROOT canonical + --root flag + per-root gc plist (#55)

Phase-2 per-namespace isolation. `PTY_SESSION_DIR` becomes a legacy
alias (still functional, emits a one-time deprecation notice per
process; suppress with PTY_ROOT_LEGACY_SILENT=1). New canonical env
`PTY_ROOT` and matching global `pty --root <path>` flag pin the
state registry per call — every subcommand transparently scopes
via getSessionDir().

`pty gc --print-launchd-plist` parameterizes off the current root:
default root keeps the pre-Phase-2 Label (com.myobie.pty.gc) for
backwards compat with existing installs; non-default roots get a
sanitized Label suffix (com.myobie.pty.gc.<basename>) and a per-root
log path (<root>/gc.log). Emitted plist writes PTY_ROOT (canonical),
not the legacy name — a mid-migration installer sees loud stderr in
gc.log until it's cleaned up.

README gains a "Namespaces" section documenting soft (tag-filter)
and hard (--root/PTY_ROOT) isolation. smalltalk's Phase-1 `st.network`
tag composes with this cleanly.

Tests in tests/pty-root.test.ts cover: env-var precedence, one-time
deprecation notice + silence hatch, --root overrides both envs,
--root value validation, default vs non-default plist Label,
per-root logPath, pathological-basename sanitization.

authored by

Nathan and committed by
GitHub
(Jul 6, 2026, 11:28 AM +0200) e76b6778 072791cc

+306 -12
+12
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Per-namespace isolation — `PTY_ROOT` + `pty --root <path>` 6 + 7 + - **New canonical env var `PTY_ROOT`** for the state registry directory (default `~/.local/state/pty`). The pre-existing `PTY_SESSION_DIR` still works and continues to point at the same registry; when only the legacy name is set, `pty` emits a one-time deprecation notice to stderr per process. Precedence: `PTY_ROOT > PTY_SESSION_DIR > default`. 8 + - **New env var `PTY_ROOT_LEGACY_SILENT=1`** — suppresses the `PTY_SESSION_DIR` deprecation notice for callers (test suites, long-lived legacy scripts) that are on a migration schedule. 9 + - **New global flag `pty --root <path>`** — pins the state registry for a single invocation. Parsed before subcommand dispatch, so every subcommand (`list`, `gc`, `kill`, `tag`, `attach`, `up`, `run`, …) transparently scopes. Equivalent to `PTY_ROOT=<path> pty <cmd>`. 10 + - **`pty gc --print-launchd-plist` per-root parametrization.** On the default root the emitted plist retains its Label `com.myobie.pty.gc` (backwards-compatible with existing installs) and its previous log path. On a non-default root the Label becomes `com.myobie.pty.gc.<basename-of-root>` — reverse-DNS-safe (non-`[A-Za-z0-9._-]` runs collapse to a single hyphen) — and the log path is `<PTY_ROOT>/gc.log`. Two networks can each install their own gc plist without a launchd-Label collision, and each network's gc noise stays inside its own registry. 11 + - **`PTY_ROOT` added to the isolate-env allow-list** (`src/server.ts:ISOLATED_ENV_ALLOWLIST`) alongside `PTY_SESSION_DIR`, so `pty run --isolate-env` children still see the pinned registry. 12 + - **Emitted plist now uses `PTY_ROOT`** in its `EnvironmentVariables` block — the deprecation notice fires anew every launchd tick if you re-use the legacy env, so a mid-migration installer gets loud, actionable feedback in `gc.log` instead of silent drift. 13 + - **README** — new "Namespaces" section under "On-disk format" documenting soft (tag-filter) and hard (`--root`/`PTY_ROOT`) namespacing, with the smalltalk `st.network=<value>` tag called out as a specific application of the soft primitive. 14 + - **`docs/disk-layout.md`** — directory table now references `$PTY_ROOT`; legacy env name called out with the migration hint. 15 + - Tests in `tests/pty-root.test.ts` cover: env-var precedence, deprecation notice fires exactly once and is silenced by `PTY_ROOT_LEGACY_SILENT`, `--root` overrides both env vars, `--root` without a value errors clearly, default-root plist retains legacy Label, non-default root gets a suffixed Label + per-root logPath, and a pathological basename (spaces) sanitizes into a launchd-safe suffix. 16 + 5 17 ### Fixes 6 18 - **`@myobie/pty/tui` `CellBuffer` — wide-char (2-cell) glyph rendering fixed.** Two root causes, both in `src/tui/buffer.ts`: 7 19 1. `writeAnsi` iterated the input string by UTF-16 code unit, splitting astral-plane codepoints (emoji like `📬`, `📭`, `📫` — U+1F4XX) into two lone surrogate halves stored as separate width-1 cells. Downstream `diff()` and `fullRender()` emitted the halves independently; a modern host terminal (kitty, iTerm2, Ghostty) recombined them as one wide glyph, but the CellBuffer's cell-index → terminal-column mapping was off by one on every emoji. Fixed by detecting surrogate pairs and combining them into one Cell whose `char` holds the full 2-code-unit string.
+26 -1
README.md
··· 171 171 172 172 ### On-disk format 173 173 174 - Session metadata, events, and supporting files all live under `$PTY_SESSION_DIR` (default `~/.local/state/pty`). The full layout — file naming, JSON shape, atomic-write contract, event types, stability tiers — is documented in [docs/disk-layout.md](docs/disk-layout.md). Third parties can read these files directly to skip the Node CLI's startup cost; `git`-style command forwarding (`pty <subcommand>` resolves to a `pty-<subcommand>` binary on `$PATH`) lets you ship native fast-path readers as `pty` subcommands. 174 + Session metadata, events, and supporting files all live under `$PTY_ROOT` (default `~/.local/state/pty`). The full layout — file naming, JSON shape, atomic-write contract, event types, stability tiers — is documented in [docs/disk-layout.md](docs/disk-layout.md). Third parties can read these files directly to skip the Node CLI's startup cost; `git`-style command forwarding (`pty <subcommand>` resolves to a `pty-<subcommand>` binary on `$PATH`) lets you ship native fast-path readers as `pty` subcommands. 175 + 176 + ### Namespaces 177 + 178 + `pty` is single-registry by default — every `list`, `gc`, `kill`, `tag`, and `attach` operates on the same directory tree. Two tools sharing a machine can compose two levels of isolation on top of that: **filter by tag** (soft: everyone still sees each other but scoped views are cheap) and **switch registry** (hard: the underlying state directory is different, sessions are invisible across). 179 + 180 + **Soft isolation via tags** — any tool can stamp a namespace tag at spawn and filter on it: 181 + 182 + ```sh 183 + pty run --tag app=payments -- ./bin/worker 184 + pty list --filter-tag app=payments # only payments sessions 185 + pty list --filter-tag app=payments --filter-tag role=worker # combine, ALL must match 186 + ``` 187 + 188 + The primitive is `--filter-tag k=v` (repeatable, matches ALL). Any tool layering on top — smalltalk uses `st.network=<root>` to distinguish agents in its network from an operator's ad-hoc pty use — reads and writes tags through the same primitive; no pty semantics for the outer tool's key. 189 + 190 + **Hard isolation via `--root`** — pin the state registry per call, or per environment: 191 + 192 + ```sh 193 + pty --root /var/lib/pty-eval list # one-off scope for this invocation 194 + PTY_ROOT=/var/lib/pty-eval pty list # scope for a whole shell / process tree 195 + ``` 196 + 197 + Distinct roots share no sockets, no metadata, no events, no gc. A launchd cron (`pty gc --print-launchd-plist`) inherits the current `$PTY_ROOT` and bakes a per-root Label (`com.myobie.pty.gc.<basename>`) plus per-root log path, so N isolated registries can each install their own gc plist without collision. On the default root the Label stays `com.myobie.pty.gc` — existing installs survive an upgrade unchanged. 198 + 199 + `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. 175 200 176 201 ### Project Files 177 202
+2 -2
docs/disk-layout.md
··· 6 6 7 7 ## Directory 8 8 9 - `$PTY_SESSION_DIR` (default `~/.local/state/pty`, mode `0700`, single-user). Every CLI command honors the env var. 9 + `$PTY_ROOT` (default `~/.local/state/pty`, mode `0700`, single-user). Every CLI command honors the env var. The pre-Phase-2 name `$PTY_SESSION_DIR` still works with a one-time deprecation notice; set `PTY_ROOT_LEGACY_SILENT=1` to suppress it. 10 10 11 11 | file | purpose | tier | 12 12 |---|---|---| ··· 86 86 ## Reading from outside pty 87 87 88 88 ```sh 89 - jq -r '.tags["role"] // empty' "$PTY_SESSION_DIR/myserver.json" 89 + jq -r '.tags["role"] // empty' "$PTY_ROOT/myserver.json" 90 90 ``` 91 91 92 92 For live updates, tail `<name>.events.jsonl` via `inotify` / `kqueue`. Subscribe instead of polling — `state.set` / `tags_change` / `display_name_change` fire on every mutation.
+43 -5
src/cli.ts
··· 25 25 writeMetadata, 26 26 atomicWriteFileSync, 27 27 getSessionDir, 28 + DEFAULT_SESSION_DIR, 28 29 getState, getStateKey, setState, deleteState, listStateKeys, 29 30 type SessionInfo, 30 31 } from "./sessions.ts"; ··· 75 76 pty Interactive session manager 76 77 pty --preselect-new Open the TUI with "Create new session..." pre-selected 77 78 pty --filter-tag key=value Filter TUI to sessions with a tag; auto-applied to new sessions 79 + pty --root <path> <subcommand> [...] Pin the state registry for this call (same as PTY_ROOT env) 78 80 pty run -- <command> [args...] Create a session and attach (random id + auto label) 79 81 pty run --id <id> -- <command> Pin the on-disk id (sock + json filename) 80 82 pty run --name <dn> -- <command> Set the display label (arbitrary length / chars) ··· 204 206 205 207 async function main(): Promise<void> { 206 208 const args = process.argv.slice(2); 209 + 210 + // Global --root <path>: pin the state registry for this invocation. 211 + // Consumed here so every subcommand transparently scopes via 212 + // getSessionDir(). Equivalent to PTY_ROOT=<path> for one call. 213 + // Scanned across the full argv because no subcommand uses --root. 214 + const rootIdx = args.indexOf("--root"); 215 + if (rootIdx !== -1) { 216 + const val = args[rootIdx + 1]; 217 + if (!val || val.startsWith("-")) { 218 + console.error("pty: --root requires a path (e.g. pty --root /var/lib/pty-eval list)"); 219 + process.exit(1); 220 + } 221 + process.env.PTY_ROOT = val; 222 + args.splice(rootIdx, 2); 223 + } 207 224 208 225 // Interactive-mode flags (--preselect-new, --filter-tag) can appear before 209 226 // the subcommand. Peek at the subcommand without consuming flags; if it's ··· 1995 2012 * command, inheriting PATH and PTY_SESSION_DIR. If the SSD where node 1996 2013 * lives isn't mounted at boot, the invocation fails — the next tick 1997 2014 * tries again. That's the whole point. */ 2015 + /** Launchd `Label` values are reverse-DNS strings; the docs require 2016 + * each service to have a unique label. When emitting per-network gc 2017 + * plists we suffix the label with the root's basename so N networks 2018 + * install N distinct services. Non-URL-safe characters (spaces, 2019 + * slashes, etc.) collapse to a single hyphen so a pathological root 2020 + * name can't produce a plist launchd rejects. */ 2021 + function labelBasenameFromRoot(sessionDir: string): string { 2022 + return path 2023 + .basename(sessionDir) 2024 + .replace(/[^A-Za-z0-9._-]+/g, "-") 2025 + .replace(/^-+|-+$/g, ""); 2026 + } 2027 + 1998 2028 function printLaunchdPlist(interval: number): void { 1999 - const logPath = path.join(os.homedir(), ".local", "state", "pty", "gc.log"); 2029 + const sessionDir = getSessionDir(); 2030 + const isDefault = sessionDir === DEFAULT_SESSION_DIR; 2031 + // Default root keeps the pre-Phase-2 label untouched so existing 2032 + // installs (`launchctl load ... com.myobie.pty.gc.plist`) survive 2033 + // an upgrade. Non-default roots get a suffixed label so two networks 2034 + // can each install their own plist without a `Label` collision. 2035 + const suffix = isDefault ? "" : `.${labelBasenameFromRoot(sessionDir)}`; 2036 + const label = `com.myobie.pty.gc${suffix}`; 2037 + // Per-root log so a network's gc noise stays inside its own registry. 2038 + const logPath = path.join(sessionDir, "gc.log"); 2000 2039 const ptyBin = process.argv[1]; 2001 2040 const envPath = process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 2002 - const sessionDir = getSessionDir(); 2003 2041 const escape = (s: string) => 2004 2042 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 2005 2043 ··· 2007 2045 // plist doesn't depend on the `pty` shim staying on PATH at launchd's 2008 2046 // (minimal) shell. EnvironmentVariables still carries PATH so the 2009 2047 // spawned children (and any `which` inside pty itself) find the user's 2010 - // tools. 2048 + // tools. PTY_ROOT (canonical) pins the target registry. 2011 2049 const plist = `<?xml version="1.0" encoding="UTF-8"?> 2012 2050 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 2013 2051 <plist version="1.0"> 2014 2052 <dict> 2015 2053 <key>Label</key> 2016 - <string>com.myobie.pty.gc</string> 2054 + <string>${escape(label)}</string> 2017 2055 <key>ProgramArguments</key> 2018 2056 <array> 2019 2057 <string>${escape(process.execPath)}</string> ··· 2032 2070 <dict> 2033 2071 <key>PATH</key> 2034 2072 <string>${escape(envPath)}</string> 2035 - <key>PTY_SESSION_DIR</key> 2073 + <key>PTY_ROOT</key> 2036 2074 <string>${escape(sessionDir)}</string> 2037 2075 </dict> 2038 2076 </dict>
+1
src/server.ts
··· 78 78 "PATH", "HOME", "USER", "LOGNAME", "SHELL", 79 79 "TERM", "COLORTERM", "LANG", "TZ", "PWD", "TMPDIR", 80 80 // pty-internal 81 + "PTY_ROOT", 81 82 "PTY_SESSION_DIR", 82 83 ]); 83 84
+16 -2
src/sessions.ts
··· 8 8 // from inside functions, never at module-init time. 9 9 import { appendEventSync } from "./events.ts"; 10 10 11 - const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "pty"); 11 + export const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "pty"); 12 + 13 + let hasWarnedLegacyRootEnv = false; 12 14 13 15 const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 14 16 ··· 66 68 } 67 69 68 70 export function getSessionDir(): string { 69 - return process.env.PTY_SESSION_DIR ?? DEFAULT_SESSION_DIR; 71 + const root = process.env.PTY_ROOT; 72 + if (root && root.length > 0) return root; 73 + const legacy = process.env.PTY_SESSION_DIR; 74 + if (legacy && legacy.length > 0) { 75 + if (!hasWarnedLegacyRootEnv && !process.env.PTY_ROOT_LEGACY_SILENT) { 76 + hasWarnedLegacyRootEnv = true; 77 + process.stderr.write( 78 + "pty: PTY_SESSION_DIR is deprecated; use PTY_ROOT (same shape, canonical name).\n" 79 + ); 80 + } 81 + return legacy; 82 + } 83 + return DEFAULT_SESSION_DIR; 70 84 } 71 85 72 86 export function ensureSessionDir(): void {
+6 -2
tests/gc.test.ts
··· 269 269 const result = runCli(dir, "gc", "--print-launchd-plist"); 270 270 expect(result.status).toBe(0); 271 271 expect(result.stdout).toContain("<!DOCTYPE plist"); 272 - expect(result.stdout).toContain("<string>com.myobie.pty.gc</string>"); 272 + // Non-default root (tests use a tmp registry) — Label carries the 273 + // basename suffix. Match either the default or the non-default shape. 274 + expect(result.stdout).toMatch(/<string>com\.myobie\.pty\.gc(?:\.[A-Za-z0-9._-]+)?<\/string>/); 273 275 expect(result.stdout).toContain("<key>StartInterval</key>"); 274 276 expect(result.stdout).toContain("<integer>30</integer>"); 275 - expect(result.stdout).toContain("<key>PTY_SESSION_DIR</key>"); 277 + // Phase-2: emitted env var is PTY_ROOT (canonical). Legacy 278 + // PTY_SESSION_DIR readers migrate via the alias in getSessionDir(). 279 + expect(result.stdout).toContain("<key>PTY_ROOT</key>"); 276 280 }); 277 281 278 282 it("--print-launchd-plist --interval=N sets the interval", () => {
+195
tests/pty-root.test.ts
··· 1 + // Phase-2 per-network isolation: PTY_ROOT canonical env, --root flag, 2 + // PTY_SESSION_DIR legacy alias + one-time deprecation notice, per-root 3 + // gc plist Label + logPath. 4 + 5 + import { describe, it, expect, afterAll } from "vitest"; 6 + import * as fs from "node:fs"; 7 + import * as os from "node:os"; 8 + import * as path from "node:path"; 9 + import { fileURLToPath } from "node:url"; 10 + import { spawnSync } from "node:child_process"; 11 + 12 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 13 + const nodeBin = process.execPath; 14 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 15 + 16 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-root-")); 17 + afterAll(() => { 18 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 19 + }); 20 + 21 + function makeRoot(): string { 22 + return fs.mkdtempSync(path.join(testRoot, "r-")); 23 + } 24 + 25 + function runCli(args: string[], env: Record<string, string | undefined>) { 26 + const cleanEnv: Record<string, string> = {}; 27 + for (const [k, v] of Object.entries(env)) { 28 + if (v !== undefined) cleanEnv[k] = v; 29 + } 30 + return spawnSync(nodeBin, [cliPath, ...args], { 31 + encoding: "utf8", 32 + env: cleanEnv, 33 + }); 34 + } 35 + 36 + describe("PTY_ROOT canonical + PTY_SESSION_DIR legacy alias", () => { 37 + it("PTY_ROOT wins over PTY_SESSION_DIR when both set", () => { 38 + const winner = makeRoot(); 39 + const loser = makeRoot(); 40 + const res = runCli(["list", "--json"], { 41 + PATH: process.env.PATH, 42 + HOME: process.env.HOME, 43 + PTY_ROOT: winner, 44 + PTY_SESSION_DIR: loser, 45 + PTY_ROOT_LEGACY_SILENT: "1", 46 + }); 47 + expect(res.status).toBe(0); 48 + // Empty registry → empty list. Both dirs exist and are empty, so 49 + // the assertion is really about "no crash on either path" — the 50 + // real precedence check is the deprecation-notice absence test 51 + // below, which only fires when PTY_SESSION_DIR is actually consulted. 52 + expect(JSON.parse(res.stdout)).toEqual([]); 53 + }); 54 + 55 + it("PTY_SESSION_DIR-only path emits deprecation notice once", () => { 56 + const dir = makeRoot(); 57 + const res = runCli(["list", "--json"], { 58 + PATH: process.env.PATH, 59 + HOME: process.env.HOME, 60 + PTY_SESSION_DIR: dir, 61 + }); 62 + expect(res.status).toBe(0); 63 + expect(res.stderr).toMatch(/PTY_SESSION_DIR is deprecated/); 64 + // Emitted exactly once (single invocation → single warning line). 65 + expect(res.stderr.match(/PTY_SESSION_DIR is deprecated/g)?.length).toBe(1); 66 + }); 67 + 68 + it("PTY_ROOT-only path emits no deprecation notice", () => { 69 + const dir = makeRoot(); 70 + const res = runCli(["list", "--json"], { 71 + PATH: process.env.PATH, 72 + HOME: process.env.HOME, 73 + PTY_ROOT: dir, 74 + }); 75 + expect(res.status).toBe(0); 76 + expect(res.stderr).not.toMatch(/deprecated/); 77 + }); 78 + 79 + it("PTY_ROOT_LEGACY_SILENT suppresses the deprecation notice", () => { 80 + const dir = makeRoot(); 81 + const res = runCli(["list", "--json"], { 82 + PATH: process.env.PATH, 83 + HOME: process.env.HOME, 84 + PTY_SESSION_DIR: dir, 85 + PTY_ROOT_LEGACY_SILENT: "1", 86 + }); 87 + expect(res.status).toBe(0); 88 + expect(res.stderr).not.toMatch(/deprecated/); 89 + }); 90 + }); 91 + 92 + describe("pty --root <path> global flag", () => { 93 + it("--root scopes list to the given registry (empty on unused root)", () => { 94 + const dir = makeRoot(); 95 + const res = runCli(["--root", dir, "list", "--json"], { 96 + PATH: process.env.PATH, 97 + HOME: process.env.HOME, 98 + PTY_ROOT_LEGACY_SILENT: "1", 99 + }); 100 + expect(res.status).toBe(0); 101 + expect(JSON.parse(res.stdout)).toEqual([]); 102 + }); 103 + 104 + it("--root overrides PTY_ROOT env when both are given", () => { 105 + const flagRoot = makeRoot(); 106 + const envRoot = makeRoot(); 107 + // Drop a fake metadata into envRoot so a leak (--root ignored) 108 + // would show up in the list. The flag path stays empty. 109 + fs.writeFileSync( 110 + path.join(envRoot, "leak.json"), 111 + JSON.stringify({ 112 + command: "sh", args: [], displayCommand: "sh", 113 + cwd: os.tmpdir(), rows: 24, cols: 80, tags: {}, pid: 999999, 114 + createdAt: new Date().toISOString(), 115 + }), 116 + ); 117 + const res = runCli(["--root", flagRoot, "list", "--json"], { 118 + PATH: process.env.PATH, 119 + HOME: process.env.HOME, 120 + PTY_ROOT: envRoot, 121 + }); 122 + expect(res.status).toBe(0); 123 + expect(JSON.parse(res.stdout)).toEqual([]); 124 + }); 125 + 126 + it("--root without a value exits non-zero with a clear error", () => { 127 + const res = runCli(["--root"], { 128 + PATH: process.env.PATH, 129 + HOME: process.env.HOME, 130 + }); 131 + expect(res.status).not.toBe(0); 132 + expect(res.stderr).toMatch(/--root requires a path/); 133 + }); 134 + 135 + it("--root followed by another flag exits non-zero (no value swallow)", () => { 136 + const res = runCli(["--root", "--json", "list"], { 137 + PATH: process.env.PATH, 138 + HOME: process.env.HOME, 139 + }); 140 + expect(res.status).not.toBe(0); 141 + expect(res.stderr).toMatch(/--root requires a path/); 142 + }); 143 + }); 144 + 145 + describe("pty gc --print-launchd-plist per-root Label + logPath", () => { 146 + function plistFor(env: Record<string, string | undefined>) { 147 + const res = runCli(["gc", "--print-launchd-plist"], { 148 + PATH: process.env.PATH, 149 + HOME: process.env.HOME, 150 + PTY_ROOT_LEGACY_SILENT: "1", 151 + ...env, 152 + }); 153 + expect(res.status).toBe(0); 154 + return res.stdout; 155 + } 156 + 157 + it("default root keeps the legacy Label (backwards compat)", () => { 158 + // No PTY_ROOT / PTY_SESSION_DIR — resolves to DEFAULT_SESSION_DIR. 159 + const plist = plistFor({}); 160 + expect(plist).toContain("<string>com.myobie.pty.gc</string>"); 161 + expect(plist).not.toContain("<string>com.myobie.pty.gc."); 162 + }); 163 + 164 + it("non-default root gets a suffixed Label (basename of root)", () => { 165 + const dir = path.join(testRoot, "my-network"); 166 + fs.mkdirSync(dir, { recursive: true }); 167 + const plist = plistFor({ PTY_ROOT: dir }); 168 + expect(plist).toContain("<string>com.myobie.pty.gc.my-network</string>"); 169 + }); 170 + 171 + it("non-default root's logPath lives inside that root", () => { 172 + const dir = path.join(testRoot, "another-net"); 173 + fs.mkdirSync(dir, { recursive: true }); 174 + const plist = plistFor({ PTY_ROOT: dir }); 175 + expect(plist).toContain(`<string>${dir}/gc.log</string>`); 176 + }); 177 + 178 + it("emits PTY_ROOT (canonical) in the plist's EnvironmentVariables", () => { 179 + const dir = path.join(testRoot, "envcheck"); 180 + fs.mkdirSync(dir, { recursive: true }); 181 + const plist = plistFor({ PTY_ROOT: dir }); 182 + // The key line for PTY_ROOT appears; PTY_SESSION_DIR does NOT appear 183 + // in the emitted plist (we no longer bake the legacy name). 184 + expect(plist).toContain("<key>PTY_ROOT</key>"); 185 + expect(plist).not.toContain("<key>PTY_SESSION_DIR</key>"); 186 + }); 187 + 188 + it("sanitizes a pathological basename into a safe Label suffix", () => { 189 + const dir = path.join(testRoot, "weird name with spaces"); 190 + fs.mkdirSync(dir, { recursive: true }); 191 + const plist = plistFor({ PTY_ROOT: dir }); 192 + // Spaces collapse to a single hyphen; label stays reverse-DNS-safe. 193 + expect(plist).toContain("<string>com.myobie.pty.gc.weird-name-with-spaces</string>"); 194 + }); 195 + });
+5
tests/setup/vitest-global.ts
··· 13 13 runRoot = fs.mkdtempSync(path.join(shortBaseTmp(), `pv-`)); 14 14 process.env.PTY_VITEST_RUN_ROOT = runRoot; 15 15 process.env.TMPDIR = runRoot; 16 + // Existing tests set the legacy PTY_SESSION_DIR env for isolation; 17 + // silence its Phase-2 deprecation notice so test stderr stays clean. 18 + // A single dedicated test (tests/pty-root.test.ts) opts back in 19 + // by unsetting this in the spawned child's env. 20 + process.env.PTY_ROOT_LEGACY_SILENT = "1"; 16 21 process.stderr.write(`[vitest-global] runRoot=${runRoot}\n`); 17 22 } 18 23