Compare commits

...

3 Commits

Author SHA1 Message Date
nntrivi2001 b3fe10a4f3 fix: v2.7.4 — a developer's System page carries no host or other-user path
- GET /api/settings/info for a developer: no DB path, no hooks file path,
  and no transcript-cache keys — those were EVERY user's transcript paths
  (their folders and session ids). GET /api/updates/status: no repo_root.
  Test: server-mode-leaks.
- Also in this release (earlier commits): a member's missing workspace
  folder is made as them at start; --allow-dangerously-skip-permissions only
  for an account that accepted the bypass warning or a bypass run.
Full suites: server 2889/2889, client 1250/1250, tsc -b.
2026-10-12 15:56:26 +07:00
nntrivi2001 8ecb96e9fc fix: a new account's first Claude session no longer opens on the bypass warning
--allow-dangerously-skip-permissions (bypass in the pane's shift+tab cycle)
made Claude Code show its Bypass Permissions warning on any account that had
not accepted it yet — every new member's first session, which the Chat UI
cannot answer, though nobody asked for bypass. Now passed only when the run
starts in bypassPermissions (asking is consent to see it) or the account's
user settings already hold skipDangerousModePermissionPrompt (pty-run
bypassAccepted; a member's read owner-checked from their home). The host,
which accepted long ago, keeps the flag. Test: pty-run.
2026-10-12 15:56:16 +07:00
nntrivi2001 c47dd230d4 fix(s3): starting a member's workspace makes its missing folder, as the member
v2.7.3's ENOCWD left a new member no web path to a first project: their
home is no workspace (EFOLDER) and a not-yet-existing folder could not
start. run-as.makeMemberFolder: mkdir -p through write-as, AS the member,
only inside their own home (never the home itself, never a host folder,
never for a member with no account). Test: server-s3-lanes.
2026-10-12 15:56:16 +07:00
11 changed files with 141 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.7.4 (2026-10-12) — Changes hides a staged change undone on disk (#72); chat says when the session has not started (#73); a member's missing folder is made as them, bypass offered only once accepted._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+1 -1
View File
@@ -359,7 +359,7 @@ Error `409 ESESSIONLIVE` is returned when `resumeSessionId` (or `resume`) names
Error `400 EBADPROMPT` is returned for a `prompt` (or `POST /api/run`'s `initialPrompt`) over 128 KB: no CLI can take one argument that long (Linux's `MAX_ARG_STRLEN`, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, `command too long`): outside Windows it is written to `<data dir>/run-prompts/<run id>.txt` (0600) and the pane's own `sh` reads it back as the CLI's last argument and deletes the file. An `after-start` tool's paste still goes through `tmux set-buffer` and stays under that 16 KB.
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused.
Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (`EACCES`) is not refused. Server mode with one Linux user per account: a missing folder inside a member's own home is created instead, as that member (`mkdir -p` through `write-as`) — their home itself can be no workspace, so this is how a new member's first project folder comes to exist.
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
+14 -2
View File
@@ -822,8 +822,20 @@ agenthub users provision alice # the Linux account only (a user made before
writes three `auth.log` lines); a pane the dashboard starts or stops shows at
once, one that exits on its own within those 5 s.
- Claude Code does not carry a folder's trust down from the member's home
(which provisioning trusts): a member answers "Trust this folder" once per
workspace, in the terminal.
(which provisioning trusts): a member may be asked "Trust this folder" once
per workspace, in the terminal. AgentHub never answers it for them — it is
what keeps a cloned repository's own settings and hooks from running unasked.
- A member's first project folder: a workspace named on a folder that does not
exist yet inside their home is created at its first start, AS the member
(from v2.7.4; their home itself is no workspace).
- Bypass in a pane's shift+tab cycle (`--allow-dangerously-skip-permissions`)
is offered only to an account that already accepted Claude Code's bypass
warning (`skipDangerousModePermissionPrompt` in its `~/.claude/settings.json`)
or to a run that starts in bypass itself. Before v2.7.4 every new member's
first session opened on that warning, which the Chat UI cannot answer.
- A developer's System page (`GET /api/settings/info`, `GET /api/updates/status`)
carries no path: not the database's, the hooks file's, the install folder,
nor the transcript cache's keys — those are every user's transcript paths.
### Undo and uninstall
+21 -1
View File
@@ -374,7 +374,27 @@ describe("pty-run", () => {
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
const prevHome = process.env.CLAUDE_HOME;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-"));
process.env.CLAUDE_HOME = home;
try {
// An account that never accepted the bypass warning: no flag, so its
// first session does not open on that warning (the Chat UI can't answer).
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...unless the run asks for bypass itself: then the warning is wanted.
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" });
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
fs.writeFileSync(
path.join(home, "settings.json"),
JSON.stringify({ skipDangerousModePermissionPrompt: true })
);
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
} finally {
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prevHome;
fs.rmSync(home, { recursive: true, force: true });
}
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...as an OPTION only: the starting mode stays the requested one, and
// `--dangerously-skip-permissions` (which would enable it) is never passed.
@@ -148,3 +148,23 @@ describe("server mode: a member's skills/commands come from their own workspaces
assert.ok(names(r).includes("max-skill"));
});
});
describe("the System page's facts for a developer", () => {
// Every string that looks like an absolute path, anywhere in a body.
const paths = (o, out = []) => {
if (typeof o === "string" && o.startsWith("/")) out.push(o);
else if (o && typeof o === "object") for (const v of Object.values(o)) paths(v, out);
return out;
};
it("carries no host or other-user path (DB, hooks file, transcript cache keys)", async () => {
const mia = await login("mia", "member-pass-1");
const info = await call("/api/settings/info", mia);
assert.equal(info.status, 200);
assert.deepEqual(paths(info.body), []);
assert.equal(typeof info.body.db.size, "number", "the numbers stay");
const boss = await login("boss", "admin-pass-1");
const full = await call("/api/settings/info", boss);
assert.ok(full.body.db.path, "an admin still sees the host's paths");
});
});
+19
View File
@@ -173,3 +173,22 @@ describe("an analyst's workspace is theirs, like a developer's", () => {
}
});
});
describe("a member's new project folder", () => {
it("is made as the member, and nothing else is made for anyone", () => {
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
const fresh = path.join(USERS, "mia", "brand-new", "sub");
assert.equal(runAs.makeMemberFolder(fresh), true);
assert.ok(fs.statSync(fresh).isDirectory());
const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir"));
sudo.calls.length = 0;
assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself");
assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's");
assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account");
assert.deepEqual(sudo.calls, []);
assert.equal(fs.existsSync(path.join(TMP, "host-new")), false);
});
});
+29 -1
View File
@@ -165,6 +165,24 @@ function promptFileArgv(id, argv) {
return ["sh", "-c", READ_PROMPT, "sh", file, ...argv.slice(0, -1)];
}
/**
* Whether the account a pane runs as has accepted Claude Code's bypass
* warning — Claude writes `skipDangerousModePermissionPrompt: true` to its
* user settings once it is. A member's (S3) is read from their home, owner-
* checked; the host's from CLAUDE_HOME. Unreadable reads as "not yet".
*/
function bypassAccepted(member) {
const file = member
? path.join(member.home, ".claude", "settings.json")
: path.join(require("./claude-home").getClaudeHome(), "settings.json");
try {
const text = require("./run-as").readFileSafe(file, "utf8", { max: 1 << 20 });
return JSON.parse(text).skipDangerousModePermissionPrompt === true;
} catch {
return false;
}
}
function buildClaudeArgv({
model,
permissionMode,
@@ -177,6 +195,7 @@ function buildClaudeArgv({
addDirs = [],
systemPromptFile = null,
proxyArgv = [],
offerBypass = true,
platform = process.platform,
}) {
const argv = ["claude"];
@@ -210,7 +229,11 @@ function buildClaudeArgv({
// plan/auto/manual/acceptEdits, so a run started in any non-bypass mode could
// never reach bypass from inside the pane, while a real terminal could.
// The starting mode is still whatever --permission-mode says above.
argv.push("--allow-dangerously-skip-permissions");
// Only once the account has accepted Claude Code's bypass warning
// (`offerBypass`, see bypassAccepted): before that the flag alone puts the
// warning in front of a brand-new account's first session — one the Chat UI
// cannot answer — although nobody asked for bypass.
if (offerBypass) argv.push("--allow-dangerously-skip-permissions");
if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
@@ -512,6 +535,11 @@ function spawnRun(args) {
resumeSessionId,
forkSession: forkSession === true,
newSessionId,
// Asking for bypass is consent to see its warning; otherwise only an
// account that already accepted it gets bypass in its shift+tab cycle.
offerBypass:
permissionMode === "bypassPermissions" ||
(entry.launcher === "claude" && bypassAccepted(require("./run-as").accountForLane(laneId))),
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
resumeLast,
bypass: bypass === true,
+21
View File
@@ -488,6 +488,26 @@ function sameOwner(...paths) {
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir");
/**
* S3: make a missing workspace folder inside a member's own home, AS them
* (`mkdir -p`, so only where they could themselves). A new member has no
* other way to their first project: their home is no workspace (EFOLDER).
* False — nothing made — for any other folder, the home itself, or a member
* with no usable account.
*/
function makeMemberFolder(dir) {
let acct;
try {
acct = accountForPath(dir);
} catch {
return false;
}
const abs = path.resolve(dir);
if (!acct || abs === acct.home) return false;
mkdirAs(acct, abs);
return true;
}
const removeAs = (acct, p) => writeAs(acct, p, "remove");
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
@@ -528,6 +548,7 @@ module.exports = {
handoffWrite,
writeAs,
mkdirAs,
makeMemberFolder,
removeAs,
launchArgv,
sameOwner,
+3 -1
View File
@@ -2078,7 +2078,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (!fs.statSync(lane.cwd).isDirectory())
throw Object.assign(new Error(), { code: "ENOTDIR" });
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
// A member's new project folder is made for them, as them.
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
return res.status(409).json({
error: {
code: "ENOCWD",
+9 -4
View File
@@ -35,7 +35,7 @@ const {
importExportBundle,
exportableRun,
} = require("../lib/data-transfer");
const { sessionWhere, bySessionWhere } = require("../lib/scope");
const { sessionWhere, bySessionWhere, memberId } = require("../lib/scope");
function getDbSize() {
try {
@@ -127,15 +127,20 @@ router.get("/info", (req, res) => {
h1: getCount(60 * 60 * 1000),
};
// A developer (server mode) gets the numbers, never a path of the host's:
// the transcript cache is keyed by EVERY user's transcript path (their
// folders, their session ids), and the DB/settings paths are the host's.
const developer = memberId(req) != null;
const cache = transcriptCache.stats();
res.json({
db: {
path: DB_PATH,
path: developer ? null : DB_PATH,
size: dbSize,
counts,
pragmas,
load_stats,
},
hooks: hookStatus,
hooks: developer ? { installed: hookStatus.installed } : hookStatus,
server: {
version: APP_VERSION,
uptime: process.uptime(),
@@ -152,7 +157,7 @@ router.get("/info", (req, res) => {
free_mem: os.freemem(),
cpus: os.cpus().length,
},
transcript_cache: transcriptCache.stats(),
transcript_cache: developer ? { ...cache, keys: undefined } : cache,
});
});
+3 -1
View File
@@ -24,9 +24,11 @@ const { schedulePluginUpdate } = require("../lib/plugin-update");
const router = Router();
router.get("/status", async (_req, res) => {
router.get("/status", async (req, res) => {
try {
const status = await getUpdatesStatus();
// The install folder is the host's; a developer sees versions only.
if (require("../lib/scope").memberId(req) != null) status.repo_root = null;
res.json(status);
} catch (err) {
res.status(500).json({