chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
+66
-10
@@ -29,7 +29,7 @@ const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { sameOriginGuard, isLocalRequest } = require("../lib/same-origin");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
const scope = require("../lib/scope");
|
||||
@@ -115,6 +115,20 @@ const DROP_TYPES = {
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
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();
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
/** 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
|
||||
@@ -164,6 +178,7 @@ function saveDrop(req, res, name, body) {
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
refuseOversize,
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
@@ -183,16 +198,57 @@ 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.
|
||||
router.post("/drop-file", 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);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
router.post(
|
||||
"/drop-file",
|
||||
refuseOversize,
|
||||
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);
|
||||
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "?name= and a non-empty file body required" });
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
}
|
||||
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
|
||||
);
|
||||
|
||||
// A browser on this machine is handed a file manager's files as `file://`
|
||||
// paths beside their bytes; the composer attaches such a path instead of
|
||||
// uploading — but only once this answers that the path is a regular file of
|
||||
// the dropped size HERE. "localhost" in the address bar is not proof: an SSH
|
||||
// tunnel's browser is on another machine, and a server-mode pane runs as an
|
||||
// account that cannot read the host's files, so server mode and anything not
|
||||
// from loopback (a proxy counts as remote) get 403 and the client uploads.
|
||||
router.post("/local-paths", (req, res) => {
|
||||
if (require("../lib/auth").isServerMode() || !isLocalRequest(req)) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EREMOTE", message: "local paths are checked only for a local browser" },
|
||||
});
|
||||
}
|
||||
const paths = req.body?.paths;
|
||||
if (
|
||||
!Array.isArray(paths) ||
|
||||
paths.length === 0 ||
|
||||
paths.length > 100 ||
|
||||
!paths.every((p) => typeof p === "string" && path.isAbsolute(p))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADFIELD", message: "paths must be 1-100 absolute paths" },
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
sizes: paths.map((p) => {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return st.isFile() ? st.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user