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:
2026-10-21 14:27:36 +07:00
parent 0547488f13
commit aee4fc190e
16 changed files with 380 additions and 65 deletions
+36 -5
View File
@@ -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
View File
@@ -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" });
}