diff --git a/README.md b/README.md index 81e3f94..1b35048 100644 --- a/README.md +++ b/README.md @@ -871,6 +871,21 @@ openclaw gateway restart # so OpenClaw re-reads the config Future `ocp update` invocations sync automatically. +### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick) + +A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix. Root cause: when `CLAUDE_CODE_OAUTH_TOKEN` is **unset**, the interactive `claude` authenticates via `~/.claude/.credentials.json`, whose single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. + +Fix: set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host (then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env). The TUI `claude` then authenticates via the stable long-lived token and never touches credentials.json. Verify the env reached the process: + +```bash +# Linux (systemd): confirm the token is in the service env +tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN +# Re-login once to repair the credentials file (belt-and-braces), then it stays unused: +claude /login +``` + +See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C amendment. + ## Environment Variables | Variable | Default | Description | @@ -894,6 +909,7 @@ Future `ocp update` invocations sync automatically. | `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). | | `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. | | `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. | +| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source). **Recommended for TUI-mode hosts:** when set, the interactive `claude` authenticates via this long-lived token and never touches `~/.claude/.credentials.json`, avoiding the refresh-token corruption that caused a permanent `Please run /login` 401 on a long-running TUI host (see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. | | `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. | | `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `/.claude/projects//`. Created automatically. | | `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. | @@ -946,6 +962,11 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup # Enable export CLAUDE_TUI_MODE=true +# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token +# so the interactive claude never touches ~/.claude/.credentials.json (whose single-use +# refresh token can get corrupted by the per-request spawn/teardown cycle → permanent +# "Please run /login" 401). See the auth note below + ADR 0007. +export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... # Optionally tune: export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed @@ -965,6 +986,8 @@ Then restart OCP. At boot you will see: - **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens. - **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely. - **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. +- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the token set, the interactive `claude` authenticates via the stable long-lived token and **never touches `~/.claude/.credentials.json`**. Without it, claude falls back to credentials.json, whose single-use OAuth refresh token can be corrupted by the per-request spawn + `kill-session` teardown racing claude's token rotation — on a long-running host this produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it). Setting the token mirrors how the stable hosts already run. (The token is then visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C amendment. +- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment. - **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today. - **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once. diff --git a/docs/adr/0007-tui-interactive-mode.md b/docs/adr/0007-tui-interactive-mode.md index ce12e67..136de22 100644 --- a/docs/adr/0007-tui-interactive-mode.md +++ b/docs/adr/0007-tui-interactive-mode.md @@ -234,6 +234,41 @@ This amendment **is** that authorization. The argument: --- +## Authentication + defunct-reaping (PR-C amendment) + +**Date:** 2026-06-13 +**Status:** Accepted — amends ADR 0007. +**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck. + +### How the TUI `claude` authenticates + +The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference: + +1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below. +2. **`/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token. + +The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it. + +### Why the fallback path corrupts (the PI231 incident) + +When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN= claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401). + +**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment. + +**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn. + +### Defunct `` reaping + +The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct ``** (a live `tmux kill-server` dropped it 25→3). + +The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment). + +### ALIGNMENT authorization (Class B) + +Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched. + +--- + ## Consequences ### Positive diff --git a/lib/tui/session.mjs b/lib/tui/session.mjs index f565fe7..7cc77e2 100644 --- a/lib/tui/session.mjs +++ b/lib/tui/session.mjs @@ -23,16 +23,44 @@ const defaultTmux = (args, opts = {}) => // Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted // OLP test instance's `olp-tui-*` sessions are never touched. +// +// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the +// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d` +// returns the instant the server forks the pane, so node never becomes its parent and +// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and +// here that parent is the tmux server). `kill-session` destroys the session but the server +// can leave the pane's `claude` (and any grandchildren claude spawned) as `` +// zombies that only the server can reap. Over many per-request spawn+teardown cycles these +// accumulate (live evidence on PI231: 25 defunct `` over 30 days; `tmux kill-server` +// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than +// merely re-signalling — is to stop the tmux server: when the server exits, the kernel +// reparents its surviving children to init (PID 1), which reaps them immediately. +// +// So after killing our own sessions, if the server has NO sessions left of ANY prefix +// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we +// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the +// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry +// once the server is otherwise idle. export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) { const r = tmux(["list-sessions", "-F", "#{session_name}"]); if (!r || r.status !== 0) return 0; // no tmux server / no sessions + const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean); let killed = 0; - for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) { + let othersRemain = false; + for (const name of names) { if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; + } else { + othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server } } + // Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty. + // kill-server is what actually reaps (server exit reparents survivors to init); a + // per-session kill cannot, since node is not the zombies' parent. + if (!othersRemain) { + tmux(["kill-server"]); + } return killed; } @@ -217,6 +245,27 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) "CLAUDE_CODE_DISABLE_CLAUDE_MDS=1", "CLAUDE_CODE_DISABLE_AUTO_MEMORY=1", ]; + // CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the + // same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01), + // so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it, + // the TUI claude falls back to authenticating via /.claude/.credentials.json, whose + // single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown + // racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty + // string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With + // the long-lived OAuth token in env, claude authenticates via the token and never touches the + // credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run. + // + // SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the + // single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the + // same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is + // already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here, + // consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below. + // + // When the env is unset (e.g. a host that intentionally relies on credentials.json), no token + // is added — behaviour is byte-for-byte unchanged from before this fix. + if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { + sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`); + } const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]; if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli"); else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY diff --git a/server.mjs b/server.mjs index ad9bad6..78ead3d 100644 --- a/server.mjs +++ b/server.mjs @@ -495,6 +495,28 @@ const cacheCleanupInterval = setInterval(() => { } }, 600000); +// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a +// long-lived host (PI231 ran 30 days without restart) accumulates defunct `` +// zombies between restarts — the pane's claude is a child of the tmux server, not node, +// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but +// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would +// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued. +// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow +// window between this idle-check and kill-server would have its pane torn down and fail the +// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary +// mechanism and the 15-min cadence makes the window negligible). +// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off. +// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007. +const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000; +const tuiReapInterval = TUI_MODE ? setInterval(() => { + if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer + try { + const n = reapStaleTuiSessions(); + if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" }); + } catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); } +}, TUI_REAP_INTERVAL_MS) : null; +if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref(); + // ── Active child process tracking ──────────────────────────────────────── const activeProcesses = new Set(); @@ -2284,6 +2306,7 @@ function gracefulShutdown(signal) { clearInterval(sessionCleanupInterval); clearInterval(authCheckInterval); clearInterval(cacheCleanupInterval); + if (tuiReapInterval) clearInterval(tuiReapInterval); closeDb(); // 3. Kill all active child processes diff --git a/test-features.mjs b/test-features.mjs index 718d41f..88f5344 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1691,6 +1691,52 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => { assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint"); }); +// CLAUDE_CODE_OAUTH_TOKEN passthrough (PI231 401 incident): tmux doesn't forward the parent +// env to the pane, so the token must be set explicitly on the pane command or the TUI claude +// falls back to credentials.json (whose refresh token gets corrupted by the spawn/kill cycle). +test("buildTuiCmd passes CLAUDE_CODE_OAUTH_TOKEN when the env is set (shq-escaped)", () => { + const save = process.env.CLAUDE_CODE_OAUTH_TOKEN; + try { + process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-abc123"; + const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-tok", "/home/u", "cli"); + // shq wraps in single quotes; a plain token renders as 'token'. + assert.ok(cmd.includes("CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-abc123'"), + "token must be set on the pane command, shq-escaped"); + } finally { + if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + else process.env.CLAUDE_CODE_OAUTH_TOKEN = save; + } +}); + +test("buildTuiCmd does NOT add CLAUDE_CODE_OAUTH_TOKEN when the env is unset", () => { + const save = process.env.CLAUDE_CODE_OAUTH_TOKEN; + try { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-notok", "/home/u", "cli"); + assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN/.test(cmd), + "no token added when env unset (credentials.json-only hosts unaffected)"); + } finally { + if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + else process.env.CLAUDE_CODE_OAUTH_TOKEN = save; + } +}); + +test("buildTuiCmd shq-escapes a token containing shell metacharacters (no injection)", () => { + const save = process.env.CLAUDE_CODE_OAUTH_TOKEN; + try { + // A token with a single quote must be escaped via the '\'' idiom so it can't break out + // of the shell string tmux runs via sh -c. + process.env.CLAUDE_CODE_OAUTH_TOKEN = "tok'; rm -rf /;'"; + const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-inj", "/home/u", "cli"); + assert.ok(cmd.includes(`CLAUDE_CODE_OAUTH_TOKEN='tok'\\''; rm -rf /;'\\'''`), + "single quote must be shq-escaped, not left bare"); + assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN=tok'; rm/.test(cmd), "raw unescaped token must NOT appear"); + } finally { + if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + else process.env.CLAUDE_CODE_OAUTH_TOKEN = save; + } +}); + test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => { const save = { ...process.env }; const restore = () => { @@ -1766,6 +1812,41 @@ test("reaper returns 0 for empty session list", () => { assert.equal(killed.length, 0); }); +// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server, +// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains. +console.log("\nTUI defunct-zombie reaping (kill-server):"); + +test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => { + const calls = []; + const fakeTmux = (args) => { + calls.push(args.join(" ")); + if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nocp-tui-bbbb\n" }; + return { status: 0, stdout: "" }; + }; + const n = reapStaleTuiSessions({ tmux: fakeTmux }); + assert.equal(n, 2, "killed both of our sessions"); + assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog"); +}); + +test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => { + const calls = []; + const fakeTmux = (args) => { + calls.push(args.join(" ")); + if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\n" }; + return { status: 0, stdout: "" }; + }; + const n = reapStaleTuiSessions({ tmux: fakeTmux }); + assert.equal(n, 1, "killed only our own session"); + assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*"); +}); + +test("reaper does NOT kill-server when there is no server (status !== 0)", () => { + const calls = []; + const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; }; + reapStaleTuiSessions({ tmux: fakeTmux }); + assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)"); +}); + // ── TUI home preparation (scratch vs real) ─────────────────────────────── import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs"; import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";