Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 2a31537b80 chore: v2.8.74 — a member's chat no longer lags a turn behind the terminal
- Under server mode with per-user accounts, a member's Claude session chat
  showed the transcript as of the last import snapshot: transcript lookups
  used access(2), which cannot see past the member's 0700 projects folder.
  They now use stat, so the chat reads the live transcript again.
2026-11-05 09:39:30 +07:00
nntrivi2001 0cd853e330 fix(chat): a member's live transcript is found by stat, not access(2)
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.
2026-11-05 09:29:58 +07:00
6 changed files with 104 additions and 20 deletions
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.73 (rebuild client to match)
UI build v2.8.74 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.73",
"version": "2.8.74",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.73",
"version": "2.8.74",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.73",
"version": "2.8.74",
"bin": {
"agenthub": "bin/agenthub.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ĩ <vinnt@smartgift.vn>
*/
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);
});
});
+28 -11
View File
@@ -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,
};
+6 -5
View File
@@ -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: <sessionId>-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 })