From 0cd853e33070be6bb28000c1d4b5c98c1b3ce3e4 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 5 Nov 2026 09:29:58 +0700 Subject: [PATCH] fix(chat): a member's live transcript is found by stat, not access(2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under S3 the chat of a member's session lagged a whole turn behind the terminal: claude-home.js located transcripts with fs.existsSync, which is access(2) — run by the kernel with capabilities cleared — so the unit's CAP_DAC_READ_SEARCH could not see past the member's 0700 ~/.claude/projects. Every lookup fell back to the import-time snapshot, which is only refreshed now and then. Transcript finders and the /transcripts route now use stat. --- .../claude-home-access-blind.test.js | 66 +++++++++++++++++++ server/lib/claude-home.js | 39 +++++++---- server/routes/sessions.js | 11 ++-- 3 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 server/__tests__/claude-home-access-blind.test.js diff --git a/server/__tests__/claude-home-access-blind.test.js b/server/__tests__/claude-home-access-blind.test.js new file mode 100644 index 00000000..0531d32c --- /dev/null +++ b/server/__tests__/claude-home-access-blind.test.js @@ -0,0 +1,66 @@ +/** + * @file Regression: transcript lookups in claude-home.js find a file by + * `stat`, never `fs.existsSync`. existsSync is access(2), which the kernel runs + * with the capabilities cleared, so under S3 it reported a member's live + * transcript (behind their 0700 `~/.claude/projects`) as missing and the chat + * served the stale import-time snapshot — a whole turn shown in the terminal + * never reached the chat. Here `fs.existsSync` is stubbed to answer "no" for + * everything under the temp Claude home, as access(2) does for the unit. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-access-blind-")); +const SAVED_HOME = process.env.CLAUDE_HOME; +process.env.CLAUDE_HOME = ROOT; + +const ch = require("../lib/claude-home"); + +const SID = "11111111-2222-3333-4444-555555555555"; +const CWD = "/work/demo-repo"; +const PROJECT = path.join(ROOT, "projects", ch.encodeCwd(CWD)); +const MAIN = path.join(PROJECT, `${SID}.jsonl`); +const SUB = path.join(PROJECT, SID, "subagents", "agent-abc.jsonl"); +const NESTED = path.join(PROJECT, SID, "subagents", "workflows", "wf_1", "agent-xyz.jsonl"); + +const realExists = fs.existsSync; + +describe("claude-home transcript lookups when access(2) is blind", () => { + before(() => { + for (const f of [MAIN, SUB, NESTED]) { + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, "{}\n"); + } + fs.existsSync = (p) => (String(p).startsWith(ROOT) ? false : realExists(p)); + }); + after(() => { + fs.existsSync = realExists; + if (SAVED_HOME === undefined) delete process.env.CLAUDE_HOME; + else process.env.CLAUDE_HOME = SAVED_HOME; + fs.rmSync(ROOT, { recursive: true, force: true }); + }); + + it("getTranscriptPath finds the live main transcript", () => { + assert.equal(ch.getTranscriptPath(SID, CWD), MAIN); + }); + + it("findTranscriptPath finds it by scanning", () => { + assert.equal(ch.findTranscriptPath(SID, CWD), MAIN); + }); + + it("subagent transcripts (flat and nested Workflow) are found", () => { + assert.equal(ch.findSubagentTranscriptPath(SID, "abc", null, CWD), SUB); + assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", "wf_1", CWD), NESTED); + assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", null, CWD), NESTED); + }); + + it("a missing transcript is still reported missing", () => { + assert.equal(ch.getTranscriptPath("no-such-session", CWD), null); + assert.equal(ch.exists(path.join(ROOT, "nope")), false); + }); +}); diff --git a/server/lib/claude-home.js b/server/lib/claude-home.js index ab7fbf27..917755e7 100644 --- a/server/lib/claude-home.js +++ b/server/lib/claude-home.js @@ -11,6 +11,22 @@ const path = require("path"); const os = require("os"); const fs = require("fs"); +/** + * Whether `p` exists, by `stat`. Not `fs.existsSync`: that is access(2), which + * checks the REAL uid with capabilities cleared, so under S3 it says "no" for a + * member's transcript behind their 0700 `~/.claude/projects` — and the chat + * fell back to a stale snapshot while the unit itself reads the live file + * through its CAP_DAC_READ_SEARCH. + */ +function exists(p) { + try { + fs.statSync(p); + return true; + } catch { + return false; + } +} + function getClaudeHome() { return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude"); } @@ -143,7 +159,7 @@ function getTranscriptPath(sessionId, cwd) { if (!cwd) return null; const encoded = encodeCwd(cwd); const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`); - if (fs.existsSync(candidate)) return candidate; + if (exists(candidate)) return candidate; // Fallback: scan the owner's projects/ subdirectories return findTranscriptPath(sessionId, cwd); } @@ -170,14 +186,14 @@ function getTranscriptPath(sessionId, cwd) { function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) { if (!subagentsDir) return null; const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`); - if (fs.existsSync(flat)) return flat; + if (exists(flat)) return flat; const workflowsDir = path.join(subagentsDir, "workflows"); - if (!fs.existsSync(workflowsDir)) return null; + if (!exists(workflowsDir)) return null; if (runId) { const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`); - return fs.existsSync(nested) ? nested : null; + return exists(nested) ? nested : null; } // Unknown run: accept only an unambiguous single match across all runs. @@ -186,7 +202,7 @@ function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) { for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) { if (!d.isDirectory()) continue; const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`); - if (fs.existsSync(cand)) matches.push(cand); + if (exists(cand)) matches.push(cand); if (matches.length > 1) break; } return matches.length === 1 ? matches[0] : null; @@ -218,13 +234,13 @@ function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) { */ function findTranscriptPath(sessionId, cwd = null) { for (const projectsDir of sessionProjectsDirs(cwd)) { - if (!fs.existsSync(projectsDir)) continue; + if (!exists(projectsDir)) continue; try { const dirs = fs.readdirSync(projectsDir, { withFileTypes: true }); for (const d of dirs) { if (!d.isDirectory()) continue; const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`); - if (fs.existsSync(candidate)) return candidate; + if (exists(candidate)) return candidate; } } catch { // Permission or IO error, ignore @@ -242,7 +258,7 @@ function findTranscriptPath(sessionId, cwd = null) { */ function getSnapshotTranscriptPath(sessionId) { const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`); - return fs.existsSync(candidate) ? candidate : null; + return exists(candidate) ? candidate : null; } /** @@ -255,7 +271,7 @@ function getSnapshotTranscriptPath(sessionId) { */ function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) { const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents"); - if (!fs.existsSync(subDir)) return null; + if (!exists(subDir)) return null; const hit = resolveAgentTranscriptInDir(subDir, agentId, runId); if (hit) return hit; if (agentId.startsWith("acompact-")) { @@ -287,13 +303,13 @@ function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null } function findSubagentIn(projectsDir, sessionId, agentId, runId) { - if (!fs.existsSync(projectsDir)) return null; + if (!exists(projectsDir)) return null; try { const dirs = fs.readdirSync(projectsDir, { withFileTypes: true }); for (const d of dirs) { if (!d.isDirectory()) continue; const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents"); - if (!fs.existsSync(subagentsDir)) continue; + if (!exists(subagentsDir)) continue; // Exact match (flat or nested Workflow-tool layout) const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId); @@ -358,4 +374,5 @@ module.exports = { allProjectsDirs, sessionProjectsDirs, setClaudeHome, + exists, }; diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 0141f781..744f2f42 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -21,6 +21,7 @@ const { getSnapshotSubagentTranscriptPath, findTranscriptPath, findSubagentTranscriptPath, + exists, } = require("../lib/claude-home"); const { classifyTranscriptSender, @@ -908,7 +909,7 @@ router.get("/:id/transcripts", async (req, res) => { getTranscriptPath(req.params.id, session.cwd) || findTranscriptPath(req.params.id, session.cwd) || getSnapshotTranscriptPath(req.params.id); - if (mainPath && fs.existsSync(mainPath)) { + if (mainPath && exists(mainPath)) { // Main agent database ID format: -main const mainDbAgent = dbAgents.find((a) => a.type === "main"); result.push({ @@ -928,18 +929,18 @@ router.get("/:id/transcripts", async (req, res) => { if (encoded) { // A member's session (S3) wrote under their own home. const directDir = path.join(projectsDirFor(session.cwd), encoded, req.params.id, "subagents"); - if (fs.existsSync(directDir)) subagentDirs.push(directDir); + if (exists(directDir)) subagentDirs.push(directDir); } // Fallback: scan the owner's project directory when the direct path doesn't exist if (subagentDirs.length === 0) { for (const projectsDir of sessionProjectsDirs(session.cwd)) { - if (fs.existsSync(projectsDir)) { + if (exists(projectsDir)) { try { for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) { if (!d.isDirectory()) continue; const candidate = path.join(projectsDir, d.name, req.params.id, "subagents"); - if (fs.existsSync(candidate)) subagentDirs.push(candidate); + if (exists(candidate)) subagentDirs.push(candidate); } } catch { /* ignore */ @@ -958,7 +959,7 @@ router.get("/:id/transcripts", async (req, res) => { // Try reading meta.json for agent type info let meta = null; const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json")); - if (fs.existsSync(metaPath)) { + if (exists(metaPath)) { try { meta = JSON.parse( require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })