feat: file upload/download and pinned sessions, text size, git identity and clone
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
|
||||
* mounted at `/api/me/git`: the SSH key a clone uses (make, replace, delete —
|
||||
* only the PUBLIC key is ever returned) and the HTTPS logins (save, delete —
|
||||
* `hasToken` only, never a password), and the commit author (`user.name` /
|
||||
* `user.email` of the account's global git config — a member's commits fail
|
||||
* without one). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const identity = require("../lib/git-identity");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STATUS = {
|
||||
EBADHOST: 400,
|
||||
EBADCRED: 400,
|
||||
EEXISTS: 409,
|
||||
EMEMBERGONE: 409,
|
||||
EHOSTIDENTITY: 403,
|
||||
EBADAUTHOR: 400,
|
||||
ESTOREUNREADABLE: 409,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
res
|
||||
.status(STATUS[e.code] || 500)
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
async function view(account) {
|
||||
return {
|
||||
account: account.kind,
|
||||
writable: account.kind === "member" || account.writable !== false,
|
||||
ssh: identity.sshKeyInfo(account),
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
};
|
||||
}
|
||||
|
||||
/** The request's account; a write to the host's identity needs an admin. */
|
||||
function accountOf(req, { write = false } = {}) {
|
||||
const account = identity.accountForRequest(req);
|
||||
if (write && account.kind === "host" && !account.writable) {
|
||||
throw Object.assign(new Error("the host's git identity is an admin's to change"), {
|
||||
code: "EHOSTIDENTITY",
|
||||
});
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
res.json(await view(accountOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||
});
|
||||
res.status(201).json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.removeSshKey(account)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/https", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
const { host, username, password } = req.body || {};
|
||||
identity.saveHttps(account, { protocol: "https", host, username, password });
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/https/:host", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.removeHttps(account, "https", req.params.host)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/** The account's commit author (`git config --global user.name/email`). */
|
||||
router.put("/author", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
await identity.setAuthor(account, req.body || {});
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+196
-1
@@ -8,7 +8,8 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
@@ -35,6 +36,7 @@ const { sameOriginGuard } = require("./run");
|
||||
const { isLocalRequest } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer");
|
||||
const { pickPath, startDir } = require("../lib/native-dialog");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
@@ -716,6 +718,122 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||||
res.status(201).json({ path: target });
|
||||
});
|
||||
|
||||
/**
|
||||
* Add workspace → Clone: `git clone <url>` into `<parent>/<name>` and make
|
||||
* that folder a workspace (like `POST /ensure`). The URL passes the same
|
||||
* allowlist as `publish` (`validateRemoteUrl`: https with no userinfo, ssh://,
|
||||
* scp-like), `name` is one plain folder name that must not exist yet, and
|
||||
* `parent` is held to the user's own folder exactly like New folder above.
|
||||
* The clone runs AS the folder's account (S3: the member) with THAT
|
||||
* account's own identity (`server/lib/git-identity.js`) — the user's SSH key
|
||||
* or saved HTTPS login, or `credentials` typed for this one https host.
|
||||
* 409 `ECREDENTIALS` (with `auth: {protocol, host}`) asks the browser for a
|
||||
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
|
||||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||||
*/
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const identity = require("../lib/git-identity");
|
||||
let url;
|
||||
try {
|
||||
url = require("../lib/git-ops").validateRemoteUrl(
|
||||
typeof body.url === "string" ? body.url.trim() : body.url
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
? body.name.trim()
|
||||
: identity.defaultName(url);
|
||||
if (
|
||||
!name ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
/[/\\\0]/.test(name) ||
|
||||
Buffer.byteLength(name) > 255
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||||
}
|
||||
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
|
||||
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||||
}
|
||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
}
|
||||
const once = body.credentials;
|
||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||
}
|
||||
const own = scope.ownFolder(req);
|
||||
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||||
if (limited && !own) return refuseFolder(res);
|
||||
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
|
||||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
||||
try {
|
||||
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||||
}
|
||||
const target = path.join(dir, name);
|
||||
if (fs.existsSync(target) || isSymlink(target)) {
|
||||
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||||
}
|
||||
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
|
||||
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
|
||||
const ownerId = scope.ownerFor(req);
|
||||
try {
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
await identity.cloneRepo({
|
||||
url,
|
||||
parent: dir,
|
||||
name,
|
||||
once: once
|
||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||
? 400
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
return res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
...(err.code === "ECREDENTIALS"
|
||||
? { auth: { protocol: err.protocol, host: err.host } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const lane = lanesLib.createLane({
|
||||
cwd: lanesLib.canonicalCwd(target),
|
||||
owner_id: ownerId,
|
||||
title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name,
|
||||
...(body.tool ? { tool: body.tool } : {}),
|
||||
});
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({ lane: payload(lane), created: true, path: target });
|
||||
} catch (err) {
|
||||
// The clone stays: it is the user's now, and Repo mode can adopt it.
|
||||
const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "ELANE", message: err.message }, path: target });
|
||||
}
|
||||
});
|
||||
|
||||
function isSymlink(p) {
|
||||
try {
|
||||
return fs.lstatSync(p).isSymbolicLink();
|
||||
@@ -1258,6 +1376,83 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Download one file of the workspace (`?path=`), any type — lane-file-transfer.js's
|
||||
* guards (the file read's own, `openSafe`, a size cap). Always an attachment
|
||||
* under a `sandbox` CSP with `nosniff`, so nothing in it renders or runs on the
|
||||
* dashboard's origin. A read, so unguarded like `/git/file`.
|
||||
*/
|
||||
router.get("/:id/git/download", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
let file;
|
||||
try {
|
||||
file = await openDownload(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
|
||||
} catch (err) {
|
||||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: "EREAD", message: "could not read the file" } });
|
||||
}
|
||||
// RFC 6266: an ASCII fallback plus the exact UTF-8 name.
|
||||
const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
||||
res.set({
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(file.size),
|
||||
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
file.stream.on("error", () => res.destroy());
|
||||
file.stream.pipe(res);
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload one file into a folder of the workspace (`?dir=` — "" is its root —
|
||||
* and the `X-File-Name` header, URI-encoded; the body is the raw bytes). It
|
||||
* only CREATES (409 EEXISTS for a taken name), writes into an existing folder
|
||||
* only, never under `.git`, as the folder's member under S3 —
|
||||
* lane-file-transfer.js. Same-origin, like every lane write.
|
||||
*/
|
||||
const uploadBody = (req, res, next) =>
|
||||
express.raw({ type: () => true, limit: UPLOAD_MAX_BYTES })(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
const big = err.type === "entity.too.large";
|
||||
res.status(big ? 413 : 400).json({
|
||||
error: big
|
||||
? { code: "ETOOBIG", message: "a file may be at most 25 MB" }
|
||||
: { code: "EBADBODY", message: "could not read the upload" },
|
||||
});
|
||||
});
|
||||
router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
let name = String(req.headers["x-file-name"] || "");
|
||||
try {
|
||||
name = decodeURIComponent(name);
|
||||
} catch {
|
||||
/* a header that is not URI-encoded is used as sent */
|
||||
}
|
||||
try {
|
||||
const saved = await saveUpload(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
name,
|
||||
Buffer.isBuffer(req.body) ? req.body : null
|
||||
);
|
||||
res.status(201).json(saved);
|
||||
} catch (err) {
|
||||
const status =
|
||||
{ ESECRET: 403, EFOREIGN: 403, EEXISTS: 409, ETOOBIG: 413, EMEMBERGONE: 403 }[err.code] ??
|
||||
(["EBADPATH", "EBADNAME", "ENOFILE", "EBADCONTENT"].includes(err.code) ? 400 : 500);
|
||||
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/** Tracked paths matching `?q=`, for quick-open. */
|
||||
router.get("/:id/git/ls", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
|
||||
@@ -42,6 +42,8 @@ const PREF_KEYS = [
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.terminal.fontSize",
|
||||
"agenthub.ui.fontSize",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agenthub.pet.names",
|
||||
|
||||
@@ -266,6 +266,12 @@ router.get("/", (req, res) => {
|
||||
} else if (sortBy === "duration") {
|
||||
orderSql = `(julianday(COALESCE(s.ended_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) - julianday(s.started_at)) ${sortDesc ? "DESC" : "ASC"}`;
|
||||
}
|
||||
// `pinned_first=1` (a workspace's sidebar list, issue #80): pinned sessions
|
||||
// first, in the order they were pinned, BEFORE the limit — so a pinned old
|
||||
// session is never pushed off the first page by newer ones.
|
||||
if (req.query.pinned_first === "1") {
|
||||
orderSql = `(s.pinned_at IS NULL), s.pinned_at ASC, ${orderSql}`;
|
||||
}
|
||||
|
||||
rows = db
|
||||
.prepare(
|
||||
@@ -718,14 +724,30 @@ router.patch("/:id", (req, res) => {
|
||||
"UPDATE sessions SET archived_at = CASE WHEN ? THEN COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
|
||||
).run(req.body.archived ? 1 : 0, req.params.id);
|
||||
}
|
||||
// `pinned`: to the top of its workspace's sidebar list, or back (issue #80).
|
||||
if (Object.hasOwn(req.body, "pinned")) {
|
||||
if (typeof req.body.pinned !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "pinned must be a boolean" } });
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE sessions SET pinned_at = CASE WHEN ? THEN COALESCE(pinned_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) END WHERE id = ?"
|
||||
).run(req.body.pinned ? 1 : 0, req.params.id);
|
||||
}
|
||||
|
||||
stmts.updateSession.run(
|
||||
name || null,
|
||||
status || null,
|
||||
ended_at || null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
req.params.id
|
||||
);
|
||||
// A pin alone is not activity: it leaves `updated_at` (the sidebar's "last
|
||||
// active", and the list's time order) where it was.
|
||||
const pinOnly = Object.keys(req.body).every((k) => k === "pinned");
|
||||
if (!pinOnly) {
|
||||
stmts.updateSession.run(
|
||||
name || null,
|
||||
status || null,
|
||||
ended_at || null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
req.params.id
|
||||
);
|
||||
}
|
||||
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
broadcast("session_updated", session);
|
||||
|
||||
Reference in New Issue
Block a user