feat: Discard all, claude-style image pastes, large local drops, per-account / list
- Changes panel: **Discard all** beside Commit discards every non-conflicted file in one `discard`, after a dialog naming how many untracked files go. - Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local browser's copied `file://` paths and text-drag file lists paste by path. - `POST /api/run/drop-file`: a loopback browser outside server mode may drop up to 2 GB, streamed to disk; remote stays 20 MB. - cc-config skills/commands/plugins read the USER scope of the account the pane runs as (S3 member home by `cwd`), never another user's or the host's for a non-admin without an account; the run form passes its cwd to plugins.
This commit is contained in:
@@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
|
||||
// The UI shows the server's limit: a loopback browser's, here.
|
||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
|
||||
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
|
||||
method: "POST",
|
||||
raw: { type: "application/octet-stream", buf },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
|
||||
assert.equal(fs.statSync(r.body.path).size, buf.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the run form's `/` list", () => {
|
||||
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
|
||||
const skill = (claude, name) => {
|
||||
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claude, "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\n---\nx\n`
|
||||
);
|
||||
};
|
||||
const hostClaude = process.env.CLAUDE_HOME;
|
||||
skill(hostClaude, "host-only");
|
||||
skill(path.join(MIA, ".claude"), "mia-skill");
|
||||
skill(path.join(BOB, ".claude"), "bob-skill");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
|
||||
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
|
||||
);
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
|
||||
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
|
||||
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
|
||||
assert.deepEqual(names(cmds), ["mia-cmd"]);
|
||||
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
|
||||
const plugins = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins.body.plugins.map((p) => p.key),
|
||||
["mp@market"],
|
||||
JSON.stringify(plugins.body)
|
||||
);
|
||||
// A folder picks its own account's home.
|
||||
const bob = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
|
||||
// An admin's Config Explorer view, no folder named, stays the host's.
|
||||
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(boss), ["host-only"]);
|
||||
});
|
||||
|
||||
it("never lists another member's or the host's skills as someone's own", async () => {
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(bob), ["bob-skill"]);
|
||||
const intoMia = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.equal(intoMia.status, 403);
|
||||
assert.equal(intoMia.body.error.code, "EFOLDER");
|
||||
// A developer with no Linux account of their own gets nothing, not the host's.
|
||||
await auth.createUser("eve", "eve-pass-123", "developer");
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
|
||||
});
|
||||
cookies.eve = login.headers.get("set-cookie").split(";")[0];
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items, [], kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
|
||||
@@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) {
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
|
||||
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
|
||||
* S3); the host's Claude home when absent.
|
||||
*/
|
||||
function readSkills(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readSkillsAt("user", getClaudeHome()));
|
||||
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
||||
@@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) {
|
||||
return (opts = {}) => {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
|
||||
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
||||
@@ -423,18 +428,18 @@ function readPluginContributions(installPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function readEnabledPluginsMap() {
|
||||
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
|
||||
function readEnabledPluginsMap(home = getClaudeHome()) {
|
||||
const userSettings = readJson(path.join(home, "settings.json"));
|
||||
if (!userSettings.ok || !userSettings.data) return {};
|
||||
const ep = userSettings.data.enabledPlugins;
|
||||
return ep && typeof ep === "object" ? ep : {};
|
||||
}
|
||||
|
||||
function readPlugins() {
|
||||
const home = getClaudeHome();
|
||||
/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
|
||||
function readPlugins(home = getClaudeHome()) {
|
||||
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
|
||||
const manifest = readJson(manifestPath);
|
||||
const enabledMap = readEnabledPluginsMap();
|
||||
const enabledMap = readEnabledPluginsMap(home);
|
||||
const plugins = [];
|
||||
if (manifest.ok && manifest.data && manifest.data.plugins) {
|
||||
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
|
||||
@@ -847,7 +852,11 @@ function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
if (
|
||||
!rel.startsWith("..") &&
|
||||
!path.isAbsolute(rel) &&
|
||||
rel.split(path.sep)[0].startsWith("agenthub-")
|
||||
)
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
|
||||
@@ -95,16 +95,45 @@ function cwdOf(req) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd}` for the two lists a member may read (the run form's `/`
|
||||
* The Claude home whose USER skills, commands and plugins a request's sessions
|
||||
* load — so the run form's `/` list matches what its pane's Claude can call.
|
||||
* Under S3 a pane runs as its FOLDER's account: `?cwd=` picks that account; a
|
||||
* non-admin with no `?cwd=` reads their own. Undefined = the host's (local
|
||||
* mode, or an admin in a host folder / the Config Explorer); null = none, for
|
||||
* a non-admin with no account of their own — another user's or the host's
|
||||
* skills are never listed as theirs.
|
||||
*/
|
||||
function userClaudeDir(req) {
|
||||
if (!req.user) return undefined;
|
||||
const runAs = require("../lib/run-as");
|
||||
const cwd = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
let acct = null;
|
||||
try {
|
||||
if (cwd && path.isAbsolute(cwd)) acct = runAs.ownerForRead(path.resolve(cwd));
|
||||
else if (req.user.role !== "admin") acct = runAs.accountForUser(req.user.username);
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (acct) return path.join(acct.home, ".claude");
|
||||
return req.user.role === "admin" ? undefined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{scope, cwd, userClaudeDir}` for the two lists a member may read (the run form's `/`
|
||||
* autocomplete). Server mode keeps a member's project items to their own
|
||||
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
|
||||
* no project at all (never the dashboard's own working directory). Returns null
|
||||
* once it has answered; `none` = nothing to list.
|
||||
*/
|
||||
function listOpts(req, res) {
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req) };
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) };
|
||||
const asked = typeof req.query.cwd === "string" && req.query.cwd;
|
||||
// No account of their own: only a named workspace's project items, if any.
|
||||
if (opts.userClaudeDir === null) {
|
||||
if (opts.scope === "user" || !asked) return { none: true };
|
||||
opts.scope = "project";
|
||||
}
|
||||
if (scope.memberId(req) == null) return opts;
|
||||
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
|
||||
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
|
||||
res.status(403).json({
|
||||
@@ -137,8 +166,10 @@ router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/plugins", (_req, res) => {
|
||||
res.json(cc.readPlugins());
|
||||
router.get("/plugins", (req, res) => {
|
||||
const home = userClaudeDir(req);
|
||||
if (home === null) return res.json({ manifestPath: null, manifestExists: false, plugins: [] });
|
||||
res.json(cc.readPlugins(home));
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
|
||||
+94
-31
@@ -114,21 +114,31 @@ const DROP_TYPES = {
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
// A browser on this machine (loopback, never server mode) crosses no network:
|
||||
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
|
||||
// Chrome hands a dropped file no `file://` path, so it cannot attach by path.
|
||||
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
if (!(Number(req.headers["content-length"]) > DROP_MAX_BYTES)) return next();
|
||||
const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
|
||||
const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES);
|
||||
function tooBig(res, max) {
|
||||
res.set("Connection", "close");
|
||||
res.status(413).json({
|
||||
error: {
|
||||
code: "ETOOBIG",
|
||||
message: `a dropped file may be at most ${DROP_MAX_BYTES / 1024 / 1024} MB`,
|
||||
max_bytes: DROP_MAX_BYTES,
|
||||
message: `a dropped file may be at most ${max / 1024 / 1024} MB`,
|
||||
max_bytes: max,
|
||||
},
|
||||
});
|
||||
}
|
||||
/** A body declared over the limit is refused before a byte of it is read, and
|
||||
* the connection is closed: express.raw would read (and discard) all of it
|
||||
* first, so a 4 GB drop crossed the network in full just to be told 413. */
|
||||
function refuseOversize(req, res, next) {
|
||||
const max = dropLimit(req);
|
||||
if (!(Number(req.headers["content-length"]) > max)) return next();
|
||||
tooBig(res, max);
|
||||
}
|
||||
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
|
||||
* workspace `?laneId=` names (scopeGuard already refused one this user may
|
||||
* not see), else the requester's own — written AS that member under S3, so
|
||||
@@ -147,28 +157,33 @@ function dropDir(req) {
|
||||
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path. Pruned on
|
||||
// write: files older than a day go.
|
||||
// The drop dir, made and pruned: files older than a day go.
|
||||
function prepareDropDir(req) {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
return { dir, member };
|
||||
}
|
||||
|
||||
// Saves one dropped body under the drop dir and answers its path.
|
||||
function saveDrop(req, res, name, body) {
|
||||
try {
|
||||
const { dir, member } = dropDir(req);
|
||||
const runAs = require("../lib/run-as");
|
||||
if (member) runAs.mkdirAs(member, dir);
|
||||
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, entry);
|
||||
try {
|
||||
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
|
||||
if (member) runAs.removeAs(member, f);
|
||||
else fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
const file = path.join(dir, name);
|
||||
if (member) runAs.handoffWrite(member, path.join("drops", name), body);
|
||||
if (member) require("../lib/run-as").handoffWrite(member, path.join("drops", name), body);
|
||||
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
@@ -198,16 +213,64 @@ router.post(
|
||||
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
|
||||
// random prefix, so it can neither leave the drop dir nor collide, and the
|
||||
// space-joined paste of several paths stays parseable.
|
||||
const dropName = (req) =>
|
||||
path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
|
||||
/** A local browser's drop is piped to its file as it arrives, never held in
|
||||
* memory whole; a body that runs past the limit (or is empty) leaves no file. */
|
||||
function streamLocalDrop(req, res, next) {
|
||||
if (!localDrop(req)) return next();
|
||||
const safe = dropName(req);
|
||||
if (!safe) return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
let file;
|
||||
let out;
|
||||
try {
|
||||
const { dir, member } = prepareDropDir(req);
|
||||
if (member) return next(); // a member's file is written as them, by saveDrop
|
||||
file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
|
||||
out = fs.createWriteStream(file, { mode: 0o600, flags: "wx" });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
let bytes = 0;
|
||||
let failed = null;
|
||||
const fail = (status, send) => {
|
||||
if (failed) return;
|
||||
failed = status;
|
||||
req.unpipe(out);
|
||||
out.destroy();
|
||||
fs.rm(file, { force: true }, () => {});
|
||||
if (!res.headersSent) send();
|
||||
};
|
||||
req.on("data", (c) => {
|
||||
bytes += c.length;
|
||||
if (bytes > DROP_LOCAL_MAX_BYTES) fail(413, () => tooBig(res, DROP_LOCAL_MAX_BYTES));
|
||||
});
|
||||
req.on("aborted", () => fail(400, () => {}));
|
||||
out.on("error", (err) => fail(500, () => res.status(500).json({ error: err.message })));
|
||||
out.on("finish", () => {
|
||||
if (failed) return;
|
||||
if (bytes === 0) {
|
||||
return fail(400, () =>
|
||||
res.status(400).json({ error: "?name= and a non-empty file body required" })
|
||||
);
|
||||
}
|
||||
res.json({ path: file });
|
||||
});
|
||||
req.pipe(out);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/drop-file",
|
||||
refuseOversize,
|
||||
streamLocalDrop,
|
||||
express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const safe = path
|
||||
.basename(String(req.query.name || ""))
|
||||
.replace(/[^A-Za-z0-9._-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(-100);
|
||||
const safe = dropName(req);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user