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:
@@ -333,6 +333,31 @@ describe("Sessions API", () => {
|
||||
assert.equal(res.body.session.archived_at, null);
|
||||
});
|
||||
|
||||
it("pins a session to the top of its workspace's list, before the limit", async () => {
|
||||
const laneId = stmts.getSession.get("own-1").lane_id;
|
||||
db.prepare("UPDATE sessions SET updated_at = '2026-09-28T00:00:00Z' WHERE id = 'own-1'").run();
|
||||
db.prepare(
|
||||
"UPDATE sessions SET updated_at = '2026-09-30T00:00:00Z' WHERE id = 'own-sub'"
|
||||
).run();
|
||||
const first = async (q = "") =>
|
||||
(await fetch(`/api/sessions?lane=${laneId}&limit=1${q}`)).body.sessions.map((s) => s.id);
|
||||
assert.deepEqual(await first(), ["own-sub"]);
|
||||
let res = await patch("/api/sessions/own-1", { pinned: true });
|
||||
assert.equal(res.status, 200);
|
||||
const at = res.body.session.pinned_at;
|
||||
assert.ok(at);
|
||||
// The older pinned session wins the one row, but only when asked for.
|
||||
assert.deepEqual(await first("&pinned_first=1"), ["own-1"]);
|
||||
assert.deepEqual(await first(), ["own-sub"]);
|
||||
// Pinning again keeps the first pin time (its place among the pinned).
|
||||
res = await patch("/api/sessions/own-1", { pinned: true });
|
||||
assert.equal(res.body.session.pinned_at, at);
|
||||
assert.equal((await patch("/api/sessions/own-1", { pinned: 1 })).status, 400);
|
||||
res = await patch("/api/sessions/own-1", { pinned: false });
|
||||
assert.equal(res.body.session.pinned_at, null);
|
||||
assert.deepEqual(await first("&pinned_first=1"), ["own-sub"]);
|
||||
});
|
||||
|
||||
it("should paginate sessions", async () => {
|
||||
const res = await fetch("/api/sessions?limit=1&offset=0");
|
||||
assert.equal(res.body.sessions.length, 1);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @file Tests for Add workspace → Clone and a user's own git identity:
|
||||
* `POST /api/lanes/clone` (URL allowlist, name/parent checks, a clone that
|
||||
* becomes a workspace, the SSH key persisted into the clone, 409
|
||||
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
|
||||
* only the public half returned, 0600 on disk), and the member (S3) path of
|
||||
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
|
||||
* git's argv, and that a login typed without "remember" leaves the member's
|
||||
* store as it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-clone-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const identity = require("../lib/git-identity");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
const WORK = path.join(ROOT, "work");
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// A local bare repo stands in for the forge: the test's own ~/.gitconfig
|
||||
// rewrites the https and scp-like URLs to it (git reads HOME's config).
|
||||
const src = path.join(ROOT, "src");
|
||||
const bare = path.join(ROOT, "bare.git");
|
||||
execFileSync("git", ["init", "-q", "-b", "main", src]);
|
||||
fs.writeFileSync(path.join(src, "README.md"), "hi\n");
|
||||
execFileSync("git", ["-C", src, "add", "."]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
src,
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
]);
|
||||
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
|
||||
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
|
||||
const authHost = `127.0.0.1:${remote.address().port}`;
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT, ".gitconfig"),
|
||||
[
|
||||
`[url "file://${bare}"]`,
|
||||
"\tinsteadOf = https://forge.test/o/r.git",
|
||||
"\tinsteadOf = git@forge.test:o/r.git",
|
||||
`[url "file://${path.join(ROOT, "missing.git")}"]`,
|
||||
"\tinsteadOf = git@forge.test:o/missing.git",
|
||||
`[url "http://${authHost}/"]`,
|
||||
"\tinsteadOf = https://private.test/",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
fs.mkdirSync(WORK);
|
||||
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
await new Promise((r) => remote.close(r));
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/clone", () => {
|
||||
it("refuses a URL outside the allowlist", async () => {
|
||||
for (const url of [
|
||||
"file:///etc",
|
||||
"http://h/x",
|
||||
"https://u:p@h/x",
|
||||
"-oProxyCommand=x",
|
||||
"ext::sh",
|
||||
]) {
|
||||
const r = await request("POST", "/api/lanes/clone", { url, parent: WORK });
|
||||
assert.equal(r.status, 400, url);
|
||||
assert.equal(r.body.error.code, "EBADURL", url);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a name with a separator and a missing parent", async () => {
|
||||
const bad = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "a/b",
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EBADNAME");
|
||||
const gone = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: path.join(ROOT, "nope"),
|
||||
});
|
||||
assert.equal(gone.body.error.code, "ENOTFOUND");
|
||||
});
|
||||
|
||||
it("clones over https into <parent>/<repo> and makes it a workspace", async () => {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
tool: "claude",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
assert.equal(r.body.lane.title, "r");
|
||||
assert.equal(fs.readFileSync(path.join(WORK, "r", "README.md"), "utf8"), "hi\n");
|
||||
const again = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
parent: WORK,
|
||||
});
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "EEXISTS");
|
||||
});
|
||||
|
||||
it("clones over ssh with the user's key, kept in the clone's config", async () => {
|
||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(made.status, 201);
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "git@forge.test:o/r.git",
|
||||
parent: WORK,
|
||||
name: "via-ssh",
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const cmd = execFileSync(
|
||||
"git",
|
||||
["-C", path.join(WORK, "via-ssh"), "config", "core.sshCommand"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
assert.match(cmd, /agenthub-ssh\/id_ed25519/);
|
||||
assert.match(cmd, /IdentitiesOnly=yes/);
|
||||
});
|
||||
|
||||
it("answers 409 ECREDENTIALS with the host when the remote wants a login", async () => {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "https://private.test/o/r.git",
|
||||
parent: WORK,
|
||||
name: "private",
|
||||
});
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "ECREDENTIALS");
|
||||
assert.equal(r.body.error.auth.host, "private.test");
|
||||
assert.ok(!fs.existsSync(path.join(WORK, "private")));
|
||||
});
|
||||
|
||||
it("says a missing repository is missing, not that the SSH key was refused", async () => {
|
||||
const r = await request("POST", "/api/lanes/clone", {
|
||||
url: "git@forge.test:o/missing.git",
|
||||
parent: WORK,
|
||||
name: "missing",
|
||||
});
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "ENOREPO");
|
||||
});
|
||||
|
||||
it("refuses a cross-origin request", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/lanes/clone",
|
||||
{ url: "https://forge.test/o/r.git", parent: WORK, name: "x" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/me/git", () => {
|
||||
it("makes a key once, returns only its public half, and deletes it", async () => {
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(made.status, 201);
|
||||
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
|
||||
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
|
||||
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
|
||||
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||
// ssh-keygen reads it back to the same public key.
|
||||
const pub = execFileSync("ssh-keygen", ["-y", "-f", key], { encoding: "utf8" }).trim();
|
||||
assert.equal(
|
||||
pub.split(" ").slice(0, 2).join(" "),
|
||||
made.body.ssh.publicKey.split(" ").slice(0, 2).join(" ")
|
||||
);
|
||||
|
||||
const twice = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(twice.status, 409);
|
||||
const replaced = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
||||
assert.equal(replaced.status, 201);
|
||||
assert.notEqual(replaced.body.ssh.publicKey, made.body.ssh.publicKey);
|
||||
|
||||
const del = await request("DELETE", "/api/me/git/ssh-key");
|
||||
assert.equal(del.body.ssh, null);
|
||||
});
|
||||
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADAUTHOR");
|
||||
const ok = await request("PUT", "/api/me/git/author", { name: "Vi", email: "vi@example.com" });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
|
||||
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
|
||||
});
|
||||
|
||||
it("saves an https login without ever returning the token", async () => {
|
||||
const r = await request("POST", "/api/me/git/https", {
|
||||
host: "git.example.test",
|
||||
username: "vi",
|
||||
password: "tok-secret",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.account, "host");
|
||||
assert.equal(r.body.https[0].host, "git.example.test");
|
||||
assert.ok(!JSON.stringify(r.body).includes("tok-secret"));
|
||||
const del = await request("DELETE", "/api/me/git/https/git.example.test");
|
||||
assert.deepEqual(del.body.https, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
const HOME = path.join(ROOT, "users", "an");
|
||||
// This process's own uid: the key is checked to be the member's own file.
|
||||
const acct = { name: "an", login: "ah-an", home: HOME, uid: process.getuid() };
|
||||
const saved = {};
|
||||
let calls = [];
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
|
||||
for (const k of ["accountForPath", "readFileSafe", "mkdirAs", "writeAs", "removeAs"]) {
|
||||
saved[k] = runAs[k];
|
||||
}
|
||||
saved.git = worktree.git;
|
||||
const inHome = (p) => path.resolve(p).startsWith(HOME);
|
||||
runAs.accountForPath = (p) => (inHome(p) ? acct : null);
|
||||
runAs.readFileSafe = (f, enc) => fs.readFileSync(f, enc);
|
||||
runAs.mkdirAs = (_a, d) => fs.mkdirSync(d, { recursive: true });
|
||||
runAs.writeAs = (_a, f, mode, content) => {
|
||||
if (mode === "remove") return fs.rmSync(f, { recursive: true, force: true });
|
||||
fs.writeFileSync(f, content, { mode: mode === "private" ? 0o600 : 0o644 });
|
||||
};
|
||||
runAs.removeAs = (_a, f) => fs.rmSync(f, { recursive: true, force: true });
|
||||
worktree.git = async (cwd, args) => {
|
||||
calls.push({ cwd, args });
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
});
|
||||
after(() => {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (k === "git") worktree.git = v;
|
||||
else runAs[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the key and the logins in the member's own home", () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
assert.equal(account.kind, "member");
|
||||
identity.generateSshKey(account);
|
||||
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
||||
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
|
||||
assert.equal(fs.statSync(store).mode & 0o777, 0o600);
|
||||
assert.equal(fs.readFileSync(store, "utf8"), "https://an:p%40ss%2F1@gitlab.test\n");
|
||||
assert.deepEqual(identity.listHttps(account), [
|
||||
{ protocol: "https", host: "gitlab.test", username: "an", hasToken: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clones over ssh with the member's key and persists it", async () => {
|
||||
calls = [];
|
||||
await identity.cloneRepo({
|
||||
url: "git@gh.test:o/r.git",
|
||||
parent: path.join(HOME, "proj"),
|
||||
name: "r",
|
||||
});
|
||||
const { args } = calls[0];
|
||||
const i = args.indexOf("clone");
|
||||
assert.match(
|
||||
args[i - 1],
|
||||
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
|
||||
);
|
||||
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
|
||||
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
|
||||
});
|
||||
|
||||
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
|
||||
const before = fs.readFileSync(store, "utf8");
|
||||
const hosts = [{ protocol: "https", host: "gitlab.test" }];
|
||||
const saved = identity.memberHttpsConfig(account, hosts, null);
|
||||
assert.match(saved.args.join(" "), /credential\.helper=store --file=/);
|
||||
assert.deepEqual(saved.stored("https", "gitlab.test"), { username: "an" });
|
||||
assert.throws(
|
||||
() =>
|
||||
identity.memberHttpsConfig(account, hosts, {
|
||||
host: "evil.test",
|
||||
username: "u",
|
||||
password: "p",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
const typed = identity.memberHttpsConfig(account, hosts, {
|
||||
host: "gitlab.test",
|
||||
username: "an",
|
||||
password: "once-only",
|
||||
});
|
||||
assert.ok(!typed.args.join(" ").includes("once-only"));
|
||||
assert.match(fs.readFileSync(store, "utf8"), /once-only/);
|
||||
typed.finish(true); // worked, but not remembered
|
||||
assert.equal(fs.readFileSync(store, "utf8"), before);
|
||||
});
|
||||
|
||||
it("refuses to rewrite a store it cannot read rather than dropping every login", () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
const read = runAs.readFileSafe;
|
||||
runAs.readFileSafe = () => {
|
||||
throw Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => identity.saveHttps(account, { host: "x.test", username: "u", password: "p" }),
|
||||
{
|
||||
code: "ESTOREUNREADABLE",
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
runAs.readFileSafe = read;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a typed https login through the member's store, never argv, and forgets it", async () => {
|
||||
calls = [];
|
||||
const store = path.join(HOME, ".config", "agenthub", "git-credentials");
|
||||
const before = fs.readFileSync(store, "utf8");
|
||||
worktree.git = async (cwd, args) => {
|
||||
calls.push({ cwd, args, during: fs.readFileSync(store, "utf8") });
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
await identity.cloneRepo({
|
||||
url: "https://new.test/o/r.git",
|
||||
parent: path.join(HOME, "proj"),
|
||||
name: "r2",
|
||||
once: { username: "an", password: "typed-secret", remember: false },
|
||||
});
|
||||
const { args, during } = calls[0];
|
||||
assert.ok(!args.join(" ").includes("typed-secret"));
|
||||
assert.ok(args.some((a) => /^credential\.helper=store --file=/.test(a)));
|
||||
assert.match(during, /typed-secret@new\.test/);
|
||||
// Not remembered: the store is back as it was, and nothing is persisted.
|
||||
assert.equal(fs.readFileSync(store, "utf8"), before);
|
||||
assert.equal(args.slice(args.indexOf("clone") + 1).filter((a) => a === "-c").length, 0);
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,20 @@ describe("finish or abort", () => {
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("names the merge after the branch, as git merge would — not its full refname", async () => {
|
||||
await merge();
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'feat'");
|
||||
// Into a branch other than main/master, git adds where it went.
|
||||
g("switch", "-qc", "side", "feat~1");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "side");
|
||||
await runGitAction(REPO, "merge", { branch: "main" });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "Merge branch 'main' into side");
|
||||
});
|
||||
|
||||
it("aborts only with confirm, back to where it started", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @file Tests for a workspace folder that is NOT a git repo (issue #78): the
|
||||
* Files tab's tree, expand-all and quick-open read off the disk
|
||||
* (server/lib/git-diff.js `walkFolder`), and the file download / upload of
|
||||
* server/lib/lane-file-transfer.js with their guards — confined to the
|
||||
* folder, no secrets, an upload that only creates and never touches `.git`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { inRepo, listPaths, listTree, listTreeAll, readFileAt } = require("../lib/git-diff");
|
||||
const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-plainfolder-"));
|
||||
const WS = path.join(ROOT, "ws");
|
||||
const OUTSIDE = path.join(ROOT, "outside");
|
||||
|
||||
function write(file, body) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body);
|
||||
}
|
||||
|
||||
async function readAll(stream) {
|
||||
const chunks = [];
|
||||
for await (const c of stream) chunks.push(c);
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
describe("a workspace folder that is not a git repo", () => {
|
||||
before(() => {
|
||||
write(path.join(WS, "sonar_issues.json"), '{"ok":true}\n');
|
||||
write(path.join(WS, "autocoupon-api", "src", "main.py"), "print(1)\n");
|
||||
write(path.join(WS, "node_modules", "dep", "index.js"), "x\n");
|
||||
write(path.join(WS, ".claude", "settings.local.json"), '{"key":"sk"}\n');
|
||||
write(path.join(OUTSIDE, "secret.txt"), "not yours\n");
|
||||
fs.mkdirSync(path.join(WS, ".git-not"), { recursive: true });
|
||||
fs.symlinkSync(OUTSIDE, path.join(WS, "out-link"));
|
||||
});
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
it("is not a repo, and its tree lists the disk one level at a time", async () => {
|
||||
assert.equal(await inRepo(WS), false);
|
||||
const top = await listTree(WS);
|
||||
assert.deepEqual(
|
||||
top.entries.map((e) => `${e.dir ? "d" : "f"}:${e.path}${e.ignored ? "!" : ""}`),
|
||||
[
|
||||
"d:.claude",
|
||||
"d:.git-not",
|
||||
"d:autocoupon-api",
|
||||
"d:node_modules!",
|
||||
"f:out-link",
|
||||
"f:sonar_issues.json",
|
||||
]
|
||||
);
|
||||
const sub = await listTree(WS, { subdir: "autocoupon-api" });
|
||||
assert.deepEqual(
|
||||
sub.entries.map((e) => e.path),
|
||||
["autocoupon-api/src"]
|
||||
);
|
||||
await assert.rejects(listTree(WS, { subdir: "../outside" }), { code: "EBADPATH" });
|
||||
await assert.rejects(listTree(WS, { subdir: "out-link" }), { code: "EBADPATH" });
|
||||
});
|
||||
|
||||
it("expands every level in one read, node_modules kept shut", async () => {
|
||||
const { levels, truncated } = await listTreeAll(WS);
|
||||
assert.equal(truncated, false);
|
||||
assert.deepEqual(
|
||||
levels["autocoupon-api/src"].map((e) => e.name),
|
||||
["main.py"]
|
||||
);
|
||||
assert.equal(levels.node_modules, undefined);
|
||||
assert.ok(levels[""].some((e) => e.path === "node_modules" && e.ignored));
|
||||
});
|
||||
|
||||
it("quick-open finds a file by name, never inside node_modules or out of the folder", async () => {
|
||||
assert.deepEqual((await listPaths(WS, { q: "main" })).paths, ["autocoupon-api/src/main.py"]);
|
||||
assert.deepEqual((await listPaths(WS, { q: "index.js" })).paths, []);
|
||||
assert.deepEqual((await listPaths(WS, { q: "secret" })).paths, []);
|
||||
});
|
||||
|
||||
it("opens a file for viewing without git", async () => {
|
||||
const f = await readFileAt(WS, "sonar_issues.json");
|
||||
assert.deepEqual(f.lines, ['{"ok":true}']);
|
||||
});
|
||||
|
||||
it("downloads a file, never a secret, a folder or one outside", async () => {
|
||||
const d = await openDownload(WS, "autocoupon-api/src/main.py");
|
||||
assert.equal(d.name, "main.py");
|
||||
assert.equal(d.size, 9);
|
||||
assert.equal(await readAll(d.stream), "print(1)\n");
|
||||
await assert.rejects(openDownload(WS, ".claude/settings.local.json"), { code: "ESECRET" });
|
||||
await assert.rejects(openDownload(WS, "../outside/secret.txt"), { code: "EBADPATH" });
|
||||
await assert.rejects(openDownload(WS, "out-link/secret.txt"), { code: "EBADPATH" });
|
||||
await assert.rejects(openDownload(WS, "autocoupon-api"), { code: "ENOFILE" });
|
||||
await assert.rejects(openDownload(WS, "missing.txt"), { code: "ENOFILE" });
|
||||
});
|
||||
|
||||
it("uploads a new file into an existing folder, and only creates", async () => {
|
||||
const body = Buffer.from("report\n");
|
||||
assert.deepEqual(await saveUpload(WS, "", "report.md", body), { path: "report.md" });
|
||||
assert.equal(fs.readFileSync(path.join(WS, "report.md"), "utf8"), "report\n");
|
||||
assert.deepEqual(await saveUpload(WS, "autocoupon-api", "notes.txt", body), {
|
||||
path: "autocoupon-api/notes.txt",
|
||||
});
|
||||
await assert.rejects(saveUpload(WS, "", "report.md", Buffer.from("again")), {
|
||||
code: "EEXISTS",
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(WS, "report.md"), "utf8"), "report\n");
|
||||
// A symlink planted at the name is "taken" too, never written through.
|
||||
fs.symlinkSync(path.join(OUTSIDE, "planted.txt"), path.join(WS, "planted.txt"));
|
||||
await assert.rejects(saveUpload(WS, "", "planted.txt", body), { code: "EEXISTS" });
|
||||
assert.equal(fs.existsSync(path.join(OUTSIDE, "planted.txt")), false);
|
||||
});
|
||||
|
||||
it("refuses an upload out of the folder, into .git, over a secret name or too big", async () => {
|
||||
const body = Buffer.from("x");
|
||||
await assert.rejects(saveUpload(WS, "../outside", "a.txt", body), { code: "EBADPATH" });
|
||||
await assert.rejects(saveUpload(WS, "out-link", "a.txt", body), { code: "EBADPATH" });
|
||||
await assert.rejects(saveUpload(WS, "", "../a.txt", body), { code: "EBADNAME" });
|
||||
await assert.rejects(saveUpload(WS, "", "", body), { code: "EBADNAME" });
|
||||
await assert.rejects(saveUpload(WS, ".git/hooks", "pre-commit", body), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(saveUpload(WS, "", ".git", body), { code: "EBADNAME" });
|
||||
await assert.rejects(saveUpload(WS, ".claude", "settings.local.json", body), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
await assert.rejects(saveUpload(WS, "no-such-dir", "a.txt", body), { code: "ENOFILE" });
|
||||
await assert.rejects(saveUpload(WS, "", "big.bin", Buffer.alloc(UPLOAD_MAX_BYTES + 1)), {
|
||||
code: "ETOOBIG",
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(OUTSIDE, "a.txt")), false);
|
||||
});
|
||||
});
|
||||
@@ -157,11 +157,23 @@ describe("git and files in a member's folder run as the member", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("never hands a member's git the credential store, nor a typed password", async () => {
|
||||
assert.deepEqual(await gitConfigFor(HOME), { args: [], env: {} });
|
||||
it("never hands a member's git the host's store, nor a typed password in argv or env", async () => {
|
||||
const cfg = await gitConfigFor(HOME);
|
||||
// Nothing saved in the member's own store: no helper of ours, no env at all.
|
||||
assert.deepEqual(cfg.args, []);
|
||||
assert.deepEqual(cfg.env, {});
|
||||
assert.equal(cfg.member, true);
|
||||
// A typed login is only for one of the repo's own remotes (git-identity.js
|
||||
// puts it in the member's store through write-as's stdin).
|
||||
await assert.rejects(
|
||||
gitConfigFor(HOME, { protocol: "https", host: "github.com", username: "u", password: "p" }),
|
||||
{ code: "EMEMBERCRED" }
|
||||
gitConfigFor(HOME, {
|
||||
protocol: "https",
|
||||
host: "github.com",
|
||||
username: "u",
|
||||
password: "p-secret",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
assert.ok(!JSON.stringify(sudoCalls()).includes("p-secret"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* capped per home; nobody reads the dot folders at the top of a member's home
|
||||
* (logins, pane tokens), and the home itself is no workspace; a disabled
|
||||
* member's session neither throws in a read nor stops the liveness reaper.
|
||||
* The Files tab's download and upload (issue #78) are held to the same: a
|
||||
* link out is refused both ways, and a member's own upload is written AS the
|
||||
* member, through write-as.
|
||||
* A fake passwd (every member = this test's uid) and server.conf make the
|
||||
* members provisioned; fake sudo runs their commands as this account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -167,6 +170,65 @@ describe("a workspace folder swapped for a link", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Files tab's download and upload (issue #78)", () => {
|
||||
it("are refused through a link out of the member's home", async () => {
|
||||
const d = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/download?path=bob.txt`);
|
||||
assert.equal(d.status, 403, JSON.stringify(d.body));
|
||||
assert.equal(d.body.error.code, "EFOREIGN");
|
||||
const { saveUpload } = require("../lib/lane-file-transfer");
|
||||
await assert.rejects(saveUpload(path.join(MIA, "tohost"), "", "x.txt", Buffer.from("x")), {
|
||||
code: "EFOREIGN",
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(HOSTDIR, "x.txt")), false);
|
||||
});
|
||||
|
||||
it("downloads mia's own file, and uploads into her folder as mia", async () => {
|
||||
const own = addLane(path.join(MIA, "proj"), "mia");
|
||||
fs.writeFileSync(path.join(MIA, "proj", "out.json"), '{"a":1}\n');
|
||||
const res = await fetch(`${BASE}/api/lanes/${own}/git/download?path=out.json`, {
|
||||
headers: { cookie: cookies.mia },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-disposition"), /attachment; filename="out.json"/);
|
||||
assert.equal(await res.text(), '{"a":1}\n');
|
||||
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
try {
|
||||
const up = await fetch(`${BASE}/api/lanes/${own}/git/upload?dir=`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookies.mia,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent("báo cáo.md"),
|
||||
},
|
||||
body: "# hi\n",
|
||||
});
|
||||
assert.equal(up.status, 201, await up.clone().text());
|
||||
assert.deepEqual(await up.json(), { path: "báo cáo.md" });
|
||||
assert.equal(fs.readFileSync(path.join(MIA, "proj", "báo cáo.md"), "utf8"), "# hi\n");
|
||||
// Through write-as, as ah-mia — the content on stdin, never in argv.
|
||||
const call1 = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||
assert.ok(call1, JSON.stringify(sudo.calls));
|
||||
assert.ok(call1.includes("ah-mia"));
|
||||
assert.ok(!call1.some((a) => String(a).includes("# hi")));
|
||||
// bob may not upload into mia's workspace.
|
||||
const bob = await fetch(`${BASE}/api/lanes/${own}/git/upload?dir=`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookies.bob,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": "b.txt",
|
||||
},
|
||||
body: "b",
|
||||
});
|
||||
assert.equal(bob.status, 404);
|
||||
} finally {
|
||||
runAs.__setExecImpl(fakeSudo().exec);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's git dir that names a path outside their home", () => {
|
||||
it("is neither read nor written by the host", () => {
|
||||
const { excludeFromGit } = require("../lib/git-exclude");
|
||||
|
||||
Reference in New Issue
Block a user