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:
@@ -46,13 +46,19 @@ function sendError(res, err) {
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
console.warn("[artifacts]", err);
|
||||
return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||
function changed(row) {
|
||||
if (!row) return;
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) });
|
||||
broadcast("artifact_changed", {
|
||||
id: row.id,
|
||||
ownerId: row.ownerId ?? row.owner_id ?? null,
|
||||
shared: !!(row.shared === true || row.shared === 1),
|
||||
});
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
@@ -101,7 +107,9 @@ router.put(
|
||||
}
|
||||
const on = req.body?.installsEnabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
}
|
||||
artifacts.setInstallsEnabled(on);
|
||||
if (req.user) {
|
||||
@@ -230,7 +238,9 @@ router.post(
|
||||
wrap((req, res) => {
|
||||
const f = (req.files || [])[0];
|
||||
if (!f) {
|
||||
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
|
||||
}
|
||||
const item = artifacts.uploadFile(req, {
|
||||
name: f.originalname,
|
||||
@@ -264,7 +274,8 @@ router.patch(
|
||||
wrap((req, res) => {
|
||||
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||
changed(item);
|
||||
res.json({ item });
|
||||
// `removed`: unsharing a Hub-only row deleted it.
|
||||
res.json(item.removed ? { item, removed: true } : { item });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -274,7 +285,8 @@ router.delete(
|
||||
wrap((req, res) => {
|
||||
const row = artifacts.remove(req, req.params.id);
|
||||
changed(row);
|
||||
res.json({ ok: true });
|
||||
// `kept`: a shared row left the owner's library but stays in the Hub.
|
||||
res.json({ ok: true, kept: !!row.kept });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -318,7 +330,8 @@ router.get(
|
||||
const download = req.query.download === "1" || !type;
|
||||
res.set({
|
||||
"Content-Type": download ? "application/octet-stream" : type,
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
...(download
|
||||
@@ -331,6 +344,12 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Who installed it, when and where (`artifacts.installsOf`).
|
||||
router.get(
|
||||
"/:id/installs",
|
||||
wrap((req, res) => res.json(artifacts.installsOf(req, req.params.id)))
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/install",
|
||||
sameOriginGuard,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* GET /api/homes/trash every Trash the caller may open, merged
|
||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
||||
*
|
||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||
@@ -36,6 +37,7 @@ function fail(res, err) {
|
||||
ENOHOME: 404,
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -314,5 +316,38 @@ router.post(
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-batch",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
}
|
||||
}, W)
|
||||
);
|
||||
router.post(
|
||||
"/:who/fs/upload-check",
|
||||
sameOriginGuard,
|
||||
handle(
|
||||
async (req, res, home) =>
|
||||
res.json(
|
||||
await fx.uploadCheck(
|
||||
home.dir,
|
||||
typeof req.body?.dir === "string" ? req.body.dir : "",
|
||||
req.body?.paths
|
||||
)
|
||||
),
|
||||
W
|
||||
)
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1586,6 +1586,7 @@ function fsFail(res, err) {
|
||||
{
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
EBADBODY: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
@@ -1782,6 +1783,40 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Many small files in ONE request (`fileExplorer.uploadBatch`): `?dir=` is the
|
||||
* folder their paths are relative to. A batch is planned whole before the
|
||||
* first write, so a taken name under `conflict=fail` is one 409 naming them all.
|
||||
*/
|
||||
router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Before a batch: `{taken, batch}` for `{dir, paths}` — no body bytes wasted on a 409. */
|
||||
router.post("/:id/fs/upload-check", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const dir = typeof req.body?.dir === "string" ? req.body.dir : "";
|
||||
res.json(await fileExplorer.uploadCheck(t.cwd, dir, req.body?.paths));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Files and folders as one zip, streamed (`?p=` once per path). */
|
||||
router.get("/:id/fs/zip", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
|
||||
+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) => {
|
||||
|
||||
@@ -846,7 +846,13 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
list.push({
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
type: "main",
|
||||
has_transcript: true,
|
||||
db_agent_id: null,
|
||||
});
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
@@ -973,6 +979,8 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
name: transcriptName,
|
||||
type: isCompact ? "compaction" : "subagent",
|
||||
subagent_type: transcriptSubagentType,
|
||||
// The Agent call that launched it: the chat opens it from that call.
|
||||
tool_use_id: typeof meta?.toolUseId === "string" ? meta.toolUseId : null,
|
||||
has_transcript: true,
|
||||
db_agent_id: null, // matched later after all transcripts are collected
|
||||
_timestamp: transcriptTimestamp,
|
||||
@@ -1260,18 +1268,32 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1574,7 +1596,10 @@ function ompMessage(entry, num, isSubagent = false) {
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
input: truncateObj(
|
||||
c.arguments && typeof c.arguments === "object" ? c.arguments : {},
|
||||
10240
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1670,6 +1695,24 @@ function askAnswers(r) {
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
@@ -1694,3 +1737,4 @@ module.exports.sessionStats = sessionStats;
|
||||
module.exports.classifyTranscriptSender = classifyTranscriptSender;
|
||||
module.exports.patchCounts = patchCounts;
|
||||
module.exports.askAnswers = askAnswers;
|
||||
module.exports.agentInfo = agentInfo;
|
||||
|
||||
Reference in New Issue
Block a user