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:
+5
-3
@@ -448,10 +448,10 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
|
||||
| Method | Path | Description |
|
||||
| ------- | ------------------- | ------------------------------------------------ |
|
||||
| `GET` | `/api/health` | Server health check (`status`, `version`, `timestamp`) |
|
||||
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity`, `awaiting=1` and `from`/`to` on `started_at` filter before the limit) |
|
||||
| `GET` | `/api/sessions` | List sessions (`status`, `limit`, `offset`; `tool=claude\|codex\|antigravity`, `awaiting=1` and `from`/`to` on `started_at` filter before the limit; `pinned_first=1` puts pinned sessions first, by `pinned_at`, before the limit) |
|
||||
| `GET` | `/api/sessions/:id` | Session detail (includes `agents` + `events`) |
|
||||
| `POST` | `/api/sessions` | Create session (idempotent by `id`) |
|
||||
| `PATCH` | `/api/sessions/:id` | Update session |
|
||||
| `PATCH` | `/api/sessions/:id` | Update session (`name`, `status`, `ended_at`, `metadata`, `archived`, `pinned` — booleans else `400 INVALID_INPUT`; a pin-only PATCH keeps `updated_at`) |
|
||||
| `GET` | `/api/sessions/:id/transcripts` | List the session's transcript files (main + sub-agents) |
|
||||
| `GET` | `/api/sessions/:id/transcript` | Cursor-paginated message stream for one transcript |
|
||||
| `GET` | `/api/agents` | List agents (`status`, `session_id`, pagination; `session_ids=a,b` — ≤ 200 — wins over the rest)|
|
||||
@@ -541,8 +541,10 @@ Request body shape:
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. In a folder that is not a git repo, `ls`, `tree` and `tree-all` read the disk instead (`walkFolder`: realpath-confined, no symlink followed, `.git` hidden, `node_modules` listed as ignored but never entered, 20 000 entries max → `truncated`). |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. |
|
||||
| `GET` | `/api/lanes/:id/git/download?path=` | One working file of any type as an `application/octet-stream` attachment (ASCII + `filename*=UTF-8''` name) under `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff`, `no-store` (`server/lib/lane-file-transfer.js`). The file read's guards, 512 MB cap. `403 ESECRET`/`EFOREIGN`, `400 EBADPATH`/`ENOFILE`/`ETOOBIG`. |
|
||||
| `POST` | `/api/lanes/:id/git/upload?dir=` | Same-origin guarded. Raw body, name in the URI-encoded `X-File-Name` header, `dir` workspace-relative (`""` = root); `201 {path}`. Creates only (`409 EEXISTS`, a symlink at the name included), into an existing folder (`400 ENOFILE`), never into `.git` (`400 EBADPATH`), one plain name ≤ 255 bytes and not `.git` (`400 EBADNAME`), never a secret name (`403 ESECRET`), 25 MB cap (`413 ETOOBIG`), `403 EFOREIGN`. Under S3 written as the folder's member via `write-as`. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
|
||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1353,6 +1353,14 @@ try {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN archived_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add `pinned_at` to sessions — a session the user pinned to the top
|
||||
// of its workspace's sidebar list (issue #80). Order only; NULL = not pinned.
|
||||
try {
|
||||
db.prepare("SELECT pinned_at FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||
}
|
||||
|
||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||
|
||||
@@ -108,6 +108,7 @@ const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const aiProxiesRouter = require("./routes/ai-proxies");
|
||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||
const gitIdentityRouter = require("./routes/git-identity");
|
||||
const aiToolsRouter = require("./routes/ai-tools");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
@@ -159,6 +160,8 @@ function createApp() {
|
||||
app.use("/api", scopeGuard);
|
||||
app.use("/api", auditGuard);
|
||||
app.use("/api/auth", authRouter);
|
||||
// Before /api/me: the user's own SSH key and HTTPS logins (git-identity.js).
|
||||
app.use("/api/me/git", gitIdentityRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/accounts", accountsRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
|
||||
* ours, because a stale token in the machine's helper would win over the one
|
||||
* just typed, and git's post-success `store` would copy a use-once token into
|
||||
* the OS user's own helper. The secret itself never reaches argv.
|
||||
* the OS user's own helper. The secret itself never reaches argv. A member's
|
||||
* folder (S3) gets the member's OWN store instead (`git-identity.js`), and an
|
||||
* ssh remote the account's AgentHub key with no prompt (`sshConfigFor`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -153,17 +155,17 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
* remote hosts — a key typed for one server is never sent to another.
|
||||
*/
|
||||
async function gitConfigFor(dir, once) {
|
||||
const identity = require("./git-identity");
|
||||
// An ssh remote: the account's own AgentHub key, and never a prompt.
|
||||
const ssh = await identity.sshConfigFor(dir);
|
||||
// S3: a member's git runs as the member (sudo), which can neither read this
|
||||
// host-owned store nor carry a typed password (sudo logs its command line).
|
||||
// Their own git login applies (`git credential`, `gh auth` in their terminal).
|
||||
if (require("./run-as").accountForPath(dir)) {
|
||||
if (once) {
|
||||
throw err(
|
||||
"EMEMBERCRED",
|
||||
"log in to this git host from your own terminal (git credential store or gh auth login)"
|
||||
);
|
||||
}
|
||||
return { args: [], env: {} };
|
||||
// host-owned store nor carry a typed password (sudo logs its command line):
|
||||
// their OWN store in their home answers instead (git-identity.js), and a
|
||||
// login typed for this call reaches it through write-as's stdin.
|
||||
const acct = require("./run-as").accountForPath(dir);
|
||||
if (acct) {
|
||||
const m = identity.memberHttpsConfig({ kind: "member", acct }, await remoteHosts(dir), once);
|
||||
return { args: [...ssh, ...m.args], env: {}, member: true, finish: m.finish, stored: m.stored };
|
||||
}
|
||||
const hosts = await remoteHosts(dir);
|
||||
const env = {};
|
||||
@@ -178,7 +180,7 @@ async function gitConfigFor(dir, once) {
|
||||
password: String(once.password || ""),
|
||||
});
|
||||
} else if (!hosts.some((h) => find(h.protocol, h.host))) {
|
||||
return { args: [], env: {} };
|
||||
return { args: ssh, env: {} };
|
||||
}
|
||||
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host
|
||||
// gets no machine helper either (see the file header); chain the machine's
|
||||
@@ -186,6 +188,7 @@ async function gitConfigFor(dir, once) {
|
||||
env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
|
||||
return {
|
||||
args: [
|
||||
...ssh,
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
|
||||
+138
-6
@@ -21,6 +21,12 @@
|
||||
* every path in and out is cwd-relative, status entries outside the cwd are
|
||||
* dropped, and every git call treats a path as a literal file name, never a
|
||||
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
|
||||
*
|
||||
* The file explorer also serves a workspace folder that is NOT a git repo
|
||||
* (issue #78): `listTree`, `listTreeAll` and `listPaths` then read the disk
|
||||
* (`walkFolder` — confined to the folder's real path, never following a
|
||||
* symlink, capped) instead of `git ls-files`, and the file reads above never
|
||||
* needed git for the working copy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -911,7 +917,11 @@ function contentSha(buf) {
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const { all, cut } = await lsFiles(dir, []);
|
||||
const { all, cut } = await lsFiles(dir, []).catch(async (err) => {
|
||||
if (await inRepo(dir)) throw err;
|
||||
const walk = await walkFolder(dir);
|
||||
return { all: walk.files, cut: walk.cut };
|
||||
});
|
||||
const needle = String(q || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
@@ -940,6 +950,87 @@ async function lsFiles(dir, args) {
|
||||
return { all: [...new Set(all.filter(Boolean))], cut };
|
||||
}
|
||||
|
||||
/** Whether `dir` is inside a git work tree (false for a plain folder). */
|
||||
async function inRepo(dir) {
|
||||
try {
|
||||
return (await git(dir, ["rev-parse", "--is-inside-work-tree"])).stdout.trim() === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Most entries one `walkFolder` visits before it stops and reports `cut`. */
|
||||
const MAX_WALK = 20_000;
|
||||
/** Folders a walk lists but never enters (a repo's tree shows them as ignored). */
|
||||
const WALK_SHUT = new Set(["node_modules"]);
|
||||
|
||||
/**
|
||||
* A plain folder's files and folders under `sub`, read off the disk, for a
|
||||
* workspace that is not a git repo: `depth` levels down (1 = `sub`'s own
|
||||
* entries), `.git` never listed, a `WALK_SHUT` folder listed but not entered,
|
||||
* a symlink listed as a file and never followed, the start confined to the
|
||||
* workspace's real path like `addIgnored`. Paths are workspace-relative.
|
||||
*/
|
||||
async function walkFolder(dir, sub = "", depth = Infinity) {
|
||||
const root = await laneRoot(dir);
|
||||
const start = await fs.promises.realpath(path.join(root, sub));
|
||||
const r = path.relative(root, start);
|
||||
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) {
|
||||
throw badRequest("EBADPATH", "path escapes the workspace");
|
||||
}
|
||||
const files = [];
|
||||
const dirs = [];
|
||||
const shut = new Set();
|
||||
let seen = 0;
|
||||
let cut = false;
|
||||
const queue = [[sub, 1]];
|
||||
while (queue.length && !cut) {
|
||||
const [rel, level] = queue.shift();
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await fs.promises.readdir(path.join(root, rel), { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (d.name === ".git") continue;
|
||||
if (++seen > MAX_WALK) {
|
||||
cut = true;
|
||||
break;
|
||||
}
|
||||
const p = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (!d.isDirectory()) files.push(p);
|
||||
else {
|
||||
dirs.push(p);
|
||||
if (WALK_SHUT.has(d.name)) shut.add(p);
|
||||
else if (level < depth) queue.push([p, level + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, dirs, shut, cut };
|
||||
}
|
||||
|
||||
/** One explorer level of a plain folder (`listTree` off the disk). */
|
||||
async function folderLevel(dir, sub) {
|
||||
const { files, dirs, shut, cut } = await walkFolder(dir, sub, 1);
|
||||
const prefix = sub ? `${sub}/` : "";
|
||||
const name = (p) => p.slice(prefix.length);
|
||||
const entries = [
|
||||
...dirs
|
||||
.map((p) => ({
|
||||
name: name(p),
|
||||
path: p,
|
||||
dir: true,
|
||||
...(shut.has(p) ? { ignored: true } : {}),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
...files
|
||||
.map((p) => ({ name: name(p), path: p, dir: false }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
return { entries: entries.slice(0, MAX_PATHS), truncated: cut || entries.length > MAX_PATHS };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
* ignore — the new file an agent just wrote is the one you want to open. */
|
||||
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
|
||||
@@ -1026,10 +1117,15 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
|
||||
const [{ all, cut }, st] = await Promise.all([
|
||||
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
|
||||
treeStatus(dir),
|
||||
]);
|
||||
let listed;
|
||||
try {
|
||||
listed = await lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS);
|
||||
} catch (err) {
|
||||
if (await inRepo(dir)) throw err;
|
||||
return { path: clean, ...(await folderLevel(dir, clean)) };
|
||||
}
|
||||
const { all, cut } = listed;
|
||||
const st = await treeStatus(dir);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
@@ -1171,7 +1267,15 @@ function onlyBlock(hunk, block, reverse) {
|
||||
* per folder, which on a large repo is thousands of git processes.
|
||||
*/
|
||||
async function listTreeAll(dir) {
|
||||
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
|
||||
let listed;
|
||||
try {
|
||||
listed = await lsFiles(dir, TREE_LS);
|
||||
} catch (err) {
|
||||
if (await inRepo(dir)) throw err;
|
||||
return plainTreeAll(dir);
|
||||
}
|
||||
const { all, cut } = listed;
|
||||
const st = await treeStatus(dir);
|
||||
const byDir = new Map();
|
||||
const level = (d) => {
|
||||
let l = byDir.get(d);
|
||||
@@ -1203,12 +1307,40 @@ async function listTreeAll(dir) {
|
||||
return { levels, truncated };
|
||||
}
|
||||
|
||||
/** `listTreeAll` for a plain folder: one capped walk, every level from it. */
|
||||
async function plainTreeAll(dir) {
|
||||
const { files, dirs, shut, cut } = await walkFolder(dir);
|
||||
const levels = { "": [] };
|
||||
const parentOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
||||
const nameOf = (p) => p.slice(p.lastIndexOf("/") + 1);
|
||||
// A shut folder (node_modules) gets no level of its own: it stays closed.
|
||||
for (const p of dirs) if (!shut.has(p)) levels[p] = [];
|
||||
for (const p of dirs) {
|
||||
levels[parentOf(p)]?.push({
|
||||
name: nameOf(p),
|
||||
path: p,
|
||||
dir: true,
|
||||
...(shut.has(p) ? { ignored: true } : {}),
|
||||
});
|
||||
}
|
||||
for (const p of files) levels[parentOf(p)]?.push({ name: nameOf(p), path: p, dir: false });
|
||||
let truncated = cut;
|
||||
for (const [d, entries] of Object.entries(levels)) {
|
||||
entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
|
||||
if (entries.length > MAX_PATHS) truncated = true;
|
||||
levels[d] = entries.slice(0, MAX_PATHS);
|
||||
}
|
||||
return { levels, truncated };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
inRepo,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
laneRoot,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* @file A user's OWN git identity — the SSH key and the HTTPS logins a clone
|
||||
* (and every later fetch/pull/push in the cloned repo) authenticates with —
|
||||
* and the clone itself (`cloneRepo`, behind `POST /api/lanes/clone`).
|
||||
*
|
||||
* Whose identity is decided by an ACCOUNT, never by the request:
|
||||
* - a member (S3, `run-as.js`): everything lives in their own home, under
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` (+ `.pub`) and `git-credentials`
|
||||
* (git's own `credential-store` format) — written only AS the member
|
||||
* (`write-as`, 0600 for the secrets), read only through `readFileSafe`.
|
||||
* Their git runs as them, so it reads these itself; no secret ever crosses
|
||||
* sudo's argv or env (sudo logs both).
|
||||
* - the host (local mode, an admin's folder outside the users root): the
|
||||
* shared store of `git-credentials.js` for HTTPS, and one key under
|
||||
* `<CLAUDE_HOME>/agenthub-ssh/` (0700/0600) for SSH.
|
||||
*
|
||||
* The key pair is made here (Node's ed25519) and written in OpenSSH's own
|
||||
* format, because a member's sudoers allows git but not `ssh-keygen`. Only the
|
||||
* PUBLIC half is ever returned. A clone persists the identity into the new
|
||||
* repo's own config (`git clone -c`): `core.sshCommand` naming the key, or a
|
||||
* `credential.helper=store --file=<the member's store>` — so an agent's push
|
||||
* from a pane uses the same login without touching the user's global config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
const runAs = require("./run-as");
|
||||
|
||||
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
|
||||
/** A clone may take a while on a big repo; past this it is killed. */
|
||||
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
||||
const HELPER = path.join(__dirname, "git-credential-helper.js");
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `{kind: "member", acct}` or `{kind: "host"}` for the folder `dir`. */
|
||||
function accountForDir(dir) {
|
||||
const acct = runAs.accountForPath(dir);
|
||||
return acct ? { kind: "member", acct } : { kind: "host" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity a signed-in user manages in Settings: their own Linux account
|
||||
* under S3, else the host's — which only an admin (or local mode) may change.
|
||||
*/
|
||||
function accountForRequest(req) {
|
||||
const user = req?.user;
|
||||
if (user) {
|
||||
const acct = runAs.accountForUser(user.username);
|
||||
if (acct) return { kind: "member", acct };
|
||||
}
|
||||
return { kind: "host", writable: !user || user.role === "admin" };
|
||||
}
|
||||
|
||||
function paths(account) {
|
||||
if (account.kind === "member") {
|
||||
const base = path.join(account.acct.home, ".config", "agenthub");
|
||||
return {
|
||||
base,
|
||||
sshDir: path.join(base, "ssh"),
|
||||
key: path.join(base, "ssh", "id_ed25519"),
|
||||
store: path.join(base, "git-credentials"),
|
||||
};
|
||||
}
|
||||
const sshDir = path.join(getClaudeHome(), "agenthub-ssh");
|
||||
return { base: sshDir, sshDir, key: path.join(sshDir, "id_ed25519"), store: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's text, or null when it is missing. `strict`: any OTHER failure
|
||||
* (unreadable, not the member's own file) throws — a store about to be
|
||||
* rewritten must never be read as empty, or the rewrite drops every login.
|
||||
*/
|
||||
function readText(account, file, { strict = false } = {}) {
|
||||
try {
|
||||
return account.kind === "member"
|
||||
? runAs.readFileSafe(file, "utf8", { max: 256 * 1024 })
|
||||
: fs.readFileSync(file, "utf8");
|
||||
} catch (e) {
|
||||
if (strict && e.code !== "ENOENT") {
|
||||
throw err("ESTOREUNREADABLE", `cannot read ${path.basename(file)}: ${e.code || e.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `file` is a regular file (a member's: one they own) — never read. */
|
||||
function isOwnFile(account, file) {
|
||||
try {
|
||||
const st = fs.lstatSync(file);
|
||||
return st.isFile() && (account.kind !== "member" || st.uid === account.acct.uid);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write `content` 0600 (`private`) or 0644, making the folder first. */
|
||||
function writeText(account, file, content, { secret }) {
|
||||
if (account.kind === "member") {
|
||||
runAs.mkdirAs(account.acct, path.dirname(file));
|
||||
runAs.writeAs(account.acct, file, secret ? "private" : "replace", content);
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, content, { mode: secret ? 0o600 : 0o644 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function removeFile(account, file) {
|
||||
if (account.kind === "member") runAs.removeAs(account.acct, file);
|
||||
else fs.rmSync(file, { force: true });
|
||||
}
|
||||
|
||||
// ── SSH key ────────────────────────────────────────────────────────────────
|
||||
|
||||
const u32 = (n) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n >>> 0);
|
||||
return b;
|
||||
};
|
||||
const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)]);
|
||||
|
||||
/**
|
||||
* A fresh ed25519 pair in OpenSSH's formats: `{privateKey, publicKey}` text,
|
||||
* unencrypted (`openssh-key-v1`, cipher none) — the dashboard has no terminal
|
||||
* to type a passphrase into.
|
||||
*/
|
||||
function makeKeyPair(comment) {
|
||||
const pair = crypto.generateKeyPairSync("ed25519");
|
||||
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
|
||||
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
|
||||
const type = Buffer.from("ssh-ed25519");
|
||||
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
|
||||
const check = crypto.randomBytes(4);
|
||||
let priv = Buffer.concat([
|
||||
check,
|
||||
check,
|
||||
sshString(type),
|
||||
sshString(pub),
|
||||
sshString(Buffer.concat([seed, pub])),
|
||||
sshString(Buffer.from(comment, "utf8")),
|
||||
]);
|
||||
const pad = [];
|
||||
for (let i = 1; (priv.length + pad.length) % 8 !== 0; i++) pad.push(i);
|
||||
priv = Buffer.concat([priv, Buffer.from(pad)]);
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("openssh-key-v1\0"),
|
||||
sshString(Buffer.from("none")),
|
||||
sshString(Buffer.from("none")),
|
||||
sshString(Buffer.alloc(0)),
|
||||
u32(1),
|
||||
sshString(pubBlob),
|
||||
sshString(priv),
|
||||
]);
|
||||
const b64 = body.toString("base64").replace(/.{70}/g, "$&\n");
|
||||
return {
|
||||
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.replace(/\n$/, "")}\n-----END OPENSSH PRIVATE KEY-----\n`,
|
||||
publicKey: `ssh-ed25519 ${pubBlob.toString("base64")} ${comment}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
|
||||
function fingerprint(publicKey) {
|
||||
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `{publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||
function sshKeyInfo(account) {
|
||||
const { key } = paths(account);
|
||||
const publicKey = readText(account, `${key}.pub`);
|
||||
if (!publicKey || !/^ssh-ed25519 [A-Za-z0-9+/=]+/.test(publicKey)) return null;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, key)) return null;
|
||||
return { publicKey: publicKey.trim(), fingerprint: fingerprint(publicKey) };
|
||||
}
|
||||
|
||||
/** Make the account's key; an existing one is replaced only with `replace`. */
|
||||
function generateSshKey(account, { replace = false, comment } = {}) {
|
||||
if (sshKeyInfo(account) && !replace) {
|
||||
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
||||
}
|
||||
const label = String(comment || "agenthub")
|
||||
.replace(/[^\w.@+-]/g, "")
|
||||
.slice(0, 64);
|
||||
const { privateKey, publicKey } = makeKeyPair(label || "agenthub");
|
||||
const { key } = paths(account);
|
||||
writeText(account, key, privateKey, { secret: true });
|
||||
writeText(account, `${key}.pub`, publicKey, { secret: false });
|
||||
return sshKeyInfo(account);
|
||||
}
|
||||
|
||||
function removeSshKey(account) {
|
||||
const { key } = paths(account);
|
||||
if (!sshKeyInfo(account)) return false;
|
||||
removeFile(account, key);
|
||||
removeFile(account, `${key}.pub`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── HTTPS logins ───────────────────────────────────────────────────────────
|
||||
|
||||
function hostKey(protocol, host) {
|
||||
const p = String(protocol || "").toLowerCase();
|
||||
const h = String(host || "").toLowerCase();
|
||||
if (p !== "https" || !HOST_RE.test(h)) throw err("EBADHOST", "an https host[:port] is required");
|
||||
return { protocol: p, host: h };
|
||||
}
|
||||
|
||||
/** The member's store, parsed: `[{protocol, host, username, password}]`. */
|
||||
function readStore(account, { strict = false } = {}) {
|
||||
const text = readText(account, paths(account).store, { strict }) || "";
|
||||
const rows = [];
|
||||
for (const line of text.split("\n")) {
|
||||
try {
|
||||
const u = new URL(line.trim());
|
||||
if (u.protocol !== "https:" || !u.password) continue;
|
||||
rows.push({
|
||||
protocol: "https",
|
||||
host: u.host.toLowerCase(),
|
||||
username: decodeURIComponent(u.username),
|
||||
password: decodeURIComponent(u.password),
|
||||
});
|
||||
} catch {
|
||||
/* not a credential line */
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function writeStore(account, rows) {
|
||||
const enc = (s) => encodeURIComponent(s);
|
||||
const text = rows
|
||||
.map((r) => `https://${enc(r.username)}:${enc(r.password)}@${r.host}\n`)
|
||||
.join("");
|
||||
writeText(account, paths(account).store, text, { secret: true });
|
||||
}
|
||||
|
||||
function listHttps(account) {
|
||||
if (account.kind === "host") return gitCredentials.list();
|
||||
return readStore(account).map(({ protocol, host, username }) => ({
|
||||
protocol,
|
||||
host,
|
||||
username,
|
||||
hasToken: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkCred(username, password) {
|
||||
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
|
||||
throw err("EBADCRED", "username is required");
|
||||
}
|
||||
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
|
||||
throw err("EBADCRED", "password or access token is required");
|
||||
}
|
||||
}
|
||||
|
||||
function saveHttps(account, { protocol = "https", host, username, password }) {
|
||||
if (account.kind === "host") return gitCredentials.save({ protocol, host, username, password });
|
||||
const k = hostKey(protocol, host);
|
||||
checkCred(username, password);
|
||||
const rows = readStore(account, { strict: true }).filter((r) => r.host !== k.host);
|
||||
rows.push({ ...k, username: username.trim(), password });
|
||||
writeStore(account, rows);
|
||||
}
|
||||
|
||||
function removeHttps(account, protocol, host) {
|
||||
if (account.kind === "host") return gitCredentials.remove(protocol, host);
|
||||
const k = hostKey(protocol, host);
|
||||
const rows = readStore(account, { strict: true });
|
||||
const kept = rows.filter((r) => r.host !== k.host);
|
||||
if (kept.length === rows.length) return false;
|
||||
writeStore(account, kept);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Commit author ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The folder a `git config --global` for this account runs in. */
|
||||
const homeOf = (account) => (account.kind === "member" ? account.acct.home : os.homedir());
|
||||
|
||||
/**
|
||||
* `{name, email}` of the account's global git config (`user.name` /
|
||||
* `user.email`, null when unset). As the member under S3: their own
|
||||
* `~/.gitconfig`, which their panes' commits use too.
|
||||
*/
|
||||
async function getAuthor(account) {
|
||||
const { git } = require("./worktree");
|
||||
const get = (k) =>
|
||||
git(homeOf(account), ["config", "--global", "--get", k]).then(
|
||||
(r) => r.stdout.trim() || null,
|
||||
() => null
|
||||
);
|
||||
return { name: await get("user.name"), email: await get("user.email") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account's commit author. Neither is a secret, so they may cross
|
||||
* sudo's argv; each is one line, a name up to 100 and an email up to 254
|
||||
* characters, the email `local@domain`.
|
||||
*/
|
||||
async function setAuthor(account, { name, email }) {
|
||||
const n = typeof name === "string" ? name.trim() : "";
|
||||
const e = typeof email === "string" ? email.trim() : "";
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ctl = /[\x00-\x1f\x7f]/;
|
||||
if (!n || n.length > 100 || ctl.test(n)) throw err("EBADAUTHOR", "a name of 1–100 characters");
|
||||
if (e.length > 254 || ctl.test(e) || !/^[^\s@]+@[^\s@]+$/.test(e)) {
|
||||
throw err("EBADAUTHOR", "an email like name@example.com");
|
||||
}
|
||||
const { git } = require("./worktree");
|
||||
await git(homeOf(account), ["config", "--global", "user.name", n]);
|
||||
await git(homeOf(account), ["config", "--global", "user.email", e]);
|
||||
return getAuthor(account);
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
* `-c core.sshCommand=…` for a repo with an ssh remote and no sshCommand of
|
||||
* its own: the account's AgentHub key when there is one (the default keys
|
||||
* are still tried — no IdentitiesOnly here), and never a prompt — no terminal
|
||||
* answers one. A repo that sets its own sshCommand keeps it.
|
||||
*/
|
||||
async function sshConfigFor(dir) {
|
||||
const { git } = require("./worktree");
|
||||
const remotes = await git(dir, ["remote", "-v"]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
// Anything that is not http(s) or a local path is ssh to git.
|
||||
const ssh = remotes
|
||||
.split("\n")
|
||||
.some((l) => /\t(ssh:\/\/|[^\s/:]+@[^\s:]+:)/i.test(l) && !/\t(https?|file):\/\//i.test(l));
|
||||
if (!ssh) return [];
|
||||
const own = await git(dir, ["config", "--get", "core.sshCommand"]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
if (own) return [];
|
||||
let account;
|
||||
try {
|
||||
account = accountForDir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's (S3) https login for a fetch/pull/push: their own store, given
|
||||
* to git as the only helper while it holds one of the repo's hosts. `once`
|
||||
* (typed in the git bar, for one of `hosts` only) goes INTO that store
|
||||
* through `write-as`'s stdin — the only way a secret reaches git running as
|
||||
* them — and `finish(ok)` takes it out again unless it worked and
|
||||
* `remember` was set. `stored(host)`: whether the store holds that host.
|
||||
*/
|
||||
function memberHttpsConfig(account, hosts, once) {
|
||||
const before = readStore(account, { strict: true });
|
||||
let finish = () => {};
|
||||
if (once) {
|
||||
const k = hostKey(once.protocol || "https", once.host);
|
||||
if (!hosts.some((h) => h.host === k.host)) {
|
||||
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
|
||||
}
|
||||
checkCred(once.username, once.password);
|
||||
saveHttps(account, { host: k.host, username: once.username, password: once.password });
|
||||
finish = (ok) => {
|
||||
if (ok && once.remember) return;
|
||||
try {
|
||||
writeStore(account, before);
|
||||
} catch {
|
||||
/* best effort: the store is the member's own */
|
||||
}
|
||||
};
|
||||
}
|
||||
const row = (host) => {
|
||||
const r = before.find((x) => x.host === String(host).toLowerCase());
|
||||
return r ? { username: r.username } : null;
|
||||
};
|
||||
const args =
|
||||
once || hosts.some((h) => h.protocol === "https" && row(h.host))
|
||||
? [
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
`credential.helper=store --file=${shq(paths(account).store)}`,
|
||||
]
|
||||
: [];
|
||||
return { args, finish, stored: (_protocol, host) => row(host) };
|
||||
}
|
||||
|
||||
// ── Clone ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `{scheme: "ssh"|"https", host}` of a URL `validateRemoteUrl` accepted. */
|
||||
function remoteOf(url) {
|
||||
if (url.startsWith("https://")) return { scheme: "https", host: new URL(url).host.toLowerCase() };
|
||||
if (url.startsWith("ssh://")) return { scheme: "ssh", host: new URL(url).host.toLowerCase() };
|
||||
return { scheme: "ssh", host: url.slice(url.indexOf("@") + 1, url.indexOf(":")).toLowerCase() };
|
||||
}
|
||||
|
||||
/** The folder name a clone of `url` gets by default — git's own rule, roughly. */
|
||||
function defaultName(url) {
|
||||
const tail =
|
||||
url
|
||||
.replace(/[/:]+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop() || "";
|
||||
return tail.replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
/** git's wording for "I needed an https login and did not get a good one". */
|
||||
function httpsFailure(stderr) {
|
||||
return /could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
|
||||
String(stderr || "")
|
||||
);
|
||||
}
|
||||
|
||||
/** The forge's "no such repository" — over ssh it ALSO says "Could not read
|
||||
* from remote repository", which is not a refused key. */
|
||||
function notFound(stderr) {
|
||||
return /Repository not found|does not appear to be a git repository|repository '.*' not found|project you were looking for could not be found/i.test(
|
||||
String(stderr || "")
|
||||
);
|
||||
}
|
||||
|
||||
/** git's wording for "the SSH login was refused or the host is unknown". */
|
||||
function sshFailure(stderr) {
|
||||
return /Permission denied \(publickey|Host key verification failed|Could not read from remote repository/i.test(
|
||||
String(stderr || "")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
|
||||
* that account's own identity. `once` is a login typed for this clone
|
||||
* (`{username, password, remember}`) — https only, for this URL's host only.
|
||||
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
|
||||
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
|
||||
* was refused, else the git error.
|
||||
*/
|
||||
async function cloneRepo({ url, parent, name, once = null }) {
|
||||
const { git } = require("./worktree");
|
||||
const account = accountForDir(parent);
|
||||
const remote = remoteOf(url);
|
||||
const pre = [];
|
||||
const persist = [];
|
||||
const env = {};
|
||||
let restore = null;
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const key = sshKeyInfo(account) ? paths(account).key : null;
|
||||
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
||||
// BatchMode: no terminal to answer a prompt; accept-new: first contact
|
||||
// records the host key, a CHANGED one still fails.
|
||||
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
|
||||
if (key) persist.push("-c", `core.sshCommand=${base}`);
|
||||
} else if (account.kind === "member") {
|
||||
const k = hostKey("https", remote.host);
|
||||
const before = readStore(account, { strict: true });
|
||||
if (once) {
|
||||
checkCred(once.username, once.password);
|
||||
saveHttps(account, { host: k.host, username: once.username, password: once.password });
|
||||
restore = () => writeStore(account, before);
|
||||
}
|
||||
if (once || before.some((r) => r.host === k.host)) {
|
||||
const helper = `store --file=${shq(paths(account).store)}`;
|
||||
pre.push("-c", "credential.helper=", "-c", `credential.helper=${helper}`);
|
||||
if (!once || once.remember) persist.push("-c", `credential.helper=${helper}`);
|
||||
}
|
||||
} else {
|
||||
const k = hostKey("https", remote.host);
|
||||
if (once) {
|
||||
checkCred(once.username, once.password);
|
||||
env.AGENTHUB_GIT_ONCE = JSON.stringify({
|
||||
...k,
|
||||
username: once.username,
|
||||
password: once.password,
|
||||
});
|
||||
}
|
||||
if (once || gitCredentials.find(k.protocol, k.host)) {
|
||||
env.AGENTHUB_GIT_CRED_FILE = gitCredentials.credentialsFile();
|
||||
pre.push(
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
|
||||
timeout: CLONE_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
const stderr = e.git?.stderr;
|
||||
if (remote.scheme === "https" && httpsFailure(stderr)) {
|
||||
throw Object.assign(err("ECREDENTIALS", "this repository needs a login"), {
|
||||
protocol: "https",
|
||||
host: remote.host,
|
||||
});
|
||||
}
|
||||
if (notFound(stderr)) {
|
||||
throw err("ENOREPO", `no repository at ${url} (or this login cannot see it)`);
|
||||
}
|
||||
if (remote.scheme === "ssh" && sshFailure(stderr)) {
|
||||
throw Object.assign(err("ESSHAUTH", "the SSH login was refused"), { host: remote.host });
|
||||
}
|
||||
throw err(
|
||||
"ECLONE",
|
||||
String(stderr || e.message)
|
||||
.trim()
|
||||
.slice(-800)
|
||||
);
|
||||
} finally {
|
||||
// A login typed without "remember" — or one that did not work — leaves
|
||||
// the member's store as it was.
|
||||
if (restore && (!ok || !once.remember)) {
|
||||
try {
|
||||
restore();
|
||||
} catch {
|
||||
/* best effort: the store is the member's own */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok && once?.remember && account.kind === "host") {
|
||||
gitCredentials.save({ protocol: "https", host: remote.host, ...once });
|
||||
}
|
||||
return { dir: path.join(parent, name), account: account.kind };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
accountForDir,
|
||||
accountForRequest,
|
||||
makeKeyPair,
|
||||
fingerprint,
|
||||
sshKeyInfo,
|
||||
generateSshKey,
|
||||
removeSshKey,
|
||||
listHttps,
|
||||
saveHttps,
|
||||
removeHttps,
|
||||
remoteOf,
|
||||
defaultName,
|
||||
cloneRepo,
|
||||
getAuthor,
|
||||
setAuthor,
|
||||
sshConfigFor,
|
||||
memberHttpsConfig,
|
||||
CLONE_TIMEOUT_MS,
|
||||
};
|
||||
+17
-3
@@ -277,12 +277,15 @@ async function resolvePaths(dir, body) {
|
||||
async function networkGit(dir, args, body = {}) {
|
||||
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
|
||||
const cfg = await gitCredentials.gitConfigFor(dir, typed);
|
||||
let ok = false;
|
||||
try {
|
||||
const out = await git(dir, [...cfg.args, ...args], {
|
||||
timeout: NETWORK_TIMEOUT_MS,
|
||||
env: cfg.env,
|
||||
});
|
||||
if (typed?.remember) gitCredentials.save(typed);
|
||||
ok = true;
|
||||
// A member's typed login is already in their own store (finish keeps it).
|
||||
if (typed?.remember && !cfg.member) gitCredentials.save(typed);
|
||||
return out;
|
||||
} catch (e) {
|
||||
const auth = gitCredentials.authFailure(e.git?.stderr);
|
||||
@@ -290,7 +293,9 @@ async function networkGit(dir, args, body = {}) {
|
||||
const remote = (await gitCredentials.remoteHosts(dir)).find(
|
||||
(h) => h.protocol === auth.protocol && h.host === auth.host
|
||||
);
|
||||
const stored = gitCredentials.find(auth.protocol, auth.host);
|
||||
const stored = cfg.member
|
||||
? cfg.stored(auth.protocol, auth.host)
|
||||
: gitCredentials.find(auth.protocol, auth.host);
|
||||
const wrong = Boolean(typed) || Boolean(stored);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
@@ -307,6 +312,8 @@ async function networkGit(dir, args, body = {}) {
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
cfg.finish?.(ok);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +557,14 @@ async function mergeAction(dir, action, body) {
|
||||
// of a local or remote-tracking branch that exists, nothing else.
|
||||
const ref = await branchRef(dir, branch);
|
||||
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
|
||||
return startOperation(dir, action, ["merge", "--no-edit", ref], branch);
|
||||
// git names the merge after the ref it was GIVEN, so the full refname
|
||||
// above made every subject "Merge branch 'refs/heads/x'". The message
|
||||
// git's own `git merge x` writes, as one argv element.
|
||||
const current = (await git(dir, ["branch", "--show-current"])).stdout.trim();
|
||||
const into = current && !["main", "master"].includes(current) ? ` into ${current}` : "";
|
||||
const kind = ref.startsWith("refs/remotes/") ? "remote-tracking branch" : "branch";
|
||||
const message = `Merge ${kind} '${branch}'${into}`;
|
||||
return startOperation(dir, action, ["merge", "--no-edit", "-m", message, ref], branch);
|
||||
}
|
||||
|
||||
if (action === "resolve") {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file Downloads one file from a workspace folder, and uploads one file into
|
||||
* it, for the Files tab (issue #78: a member on a server-mode install has no
|
||||
* SSH to the host, so the dashboard is their only way to move a file in or
|
||||
* out). Both work in a folder that is not a git repo too.
|
||||
*
|
||||
* The guards are the READ path's own (git-diff.js): a lexical `lanePath`, a
|
||||
* realpath confined to the workspace (no `..`, no symlink out), and no
|
||||
* `settings.local.json` or member dot-dir entry, by name or by what it
|
||||
* resolves to. A download is opened through `openSafe` (a member's file: a
|
||||
* regular file they own) and streamed, up to DOWNLOAD_MAX_BYTES.
|
||||
*
|
||||
* An upload only CREATES: it refuses a name that already exists (EEXISTS), so
|
||||
* it can never replace a file an agent wrote — the editor's `baseSha` rule
|
||||
* for the one write that does replace. It writes into an existing folder of
|
||||
* the workspace (never makes one), never into or under `.git` (a hook there
|
||||
* runs the next time the dashboard runs git), at most UPLOAD_MAX_BYTES, and —
|
||||
* under S3 — as the folder's member through `write-as` (the host never writes
|
||||
* inside a member's home itself); elsewhere with an exclusive create, which
|
||||
* also refuses to follow a symlink planted at the name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff");
|
||||
|
||||
/** The largest file one upload may carry (it rides one request body). */
|
||||
const UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
|
||||
/** The largest file one download streams. */
|
||||
const DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
const underGit = (rel) => rel.split("/").includes(".git");
|
||||
|
||||
/**
|
||||
* Open `requested` in workspace `dir` for a download. Returns its base name,
|
||||
* size and a read stream. Throws EBADPATH, ESECRET, ENOFILE or ETOOBIG.
|
||||
*/
|
||||
async function openDownload(dir, requested) {
|
||||
const file = lanePath(requested);
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw bad("ESECRET", "that file holds secrets and is not downloadable here");
|
||||
}
|
||||
const abs = await resolveInLane(dir, file);
|
||||
const runAs = require("./run-as");
|
||||
let fh;
|
||||
try {
|
||||
fh = await runAs.openSafeAsync(abs);
|
||||
} catch {
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
const st = await fh.stat();
|
||||
if (!st.isFile()) {
|
||||
await fh.close();
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (st.size > DOWNLOAD_MAX_BYTES) {
|
||||
await fh.close();
|
||||
throw bad("ETOOBIG", "file is larger than 512 MB");
|
||||
}
|
||||
// Never past the size fstat gave: a file growing under the read is cut.
|
||||
const stream = fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) });
|
||||
return { name: path.basename(file), size: st.size, stream };
|
||||
}
|
||||
|
||||
/** A file name an upload may create: one path segment, nothing special. */
|
||||
function uploadName(name) {
|
||||
const n = typeof name === "string" ? name.trim() : "";
|
||||
if (
|
||||
!n ||
|
||||
n === "." ||
|
||||
n === ".." ||
|
||||
n === ".git" ||
|
||||
/[/\\\0]/.test(n) ||
|
||||
Buffer.byteLength(n) > 255
|
||||
) {
|
||||
throw bad("EBADNAME", "the file name must be a single, plain file name");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `name` in the folder `folder` ("" = the workspace root) of workspace
|
||||
* `dir` with `content`. Returns its workspace-relative path. Throws EBADPATH,
|
||||
* EBADNAME, ESECRET, ENOFILE (no such folder), EEXISTS or ETOOBIG.
|
||||
*/
|
||||
async function saveUpload(dir, folder, name, content) {
|
||||
const base = uploadName(name);
|
||||
const sub = folder ? lanePath(folder) : "";
|
||||
const rel = sub ? `${sub}/${base}` : base;
|
||||
if (underGit(rel)) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
if (!Buffer.isBuffer(content)) throw bad("EBADCONTENT", "the upload carried no file");
|
||||
if (content.length > UPLOAD_MAX_BYTES) throw bad("ETOOBIG", "a file may be at most 25 MB");
|
||||
if (await isSecretOnDisk(dir, rel)) {
|
||||
throw bad("ESECRET", "that name is reserved for secrets and is not writable here");
|
||||
}
|
||||
// The folder must exist inside the workspace, by its REAL path.
|
||||
const root = await laneRoot(dir);
|
||||
let target;
|
||||
if (sub) {
|
||||
let real;
|
||||
try {
|
||||
real = await resolveInLane(dir, sub);
|
||||
} catch (err) {
|
||||
throw err.code === "ENOFILE" ? bad("ENOFILE", "no such folder in the workspace") : err;
|
||||
}
|
||||
const st = await fs.promises.stat(real).catch(() => null);
|
||||
if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace");
|
||||
target = path.join(real, base);
|
||||
} else {
|
||||
target = path.join(root, base);
|
||||
}
|
||||
// Re-checked on the resolved folder: a folder linked to a secret's place.
|
||||
if (await isSecretOnDisk(root, path.relative(root, target))) {
|
||||
throw bad("ESECRET", "that name is reserved for secrets and is not writable here");
|
||||
}
|
||||
const exists = () => bad("EEXISTS", `${rel} already exists — rename the file or remove that one`);
|
||||
const taken = await fs.promises.lstat(target).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (taken) throw exists();
|
||||
const runAs = require("./run-as");
|
||||
const acct = runAs.accountForPath(target);
|
||||
if (acct) {
|
||||
// As the member (S3): write-as's `replace` is an atomic new file. The
|
||||
// existence check above is the member's own race to lose, in their home.
|
||||
runAs.writeAs(acct, target, "replace", content);
|
||||
} else {
|
||||
try {
|
||||
// `wx`: O_CREAT|O_EXCL — fails on any existing name, a symlink included.
|
||||
await fs.promises.writeFile(target, content, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") throw exists();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return { path: rel };
|
||||
}
|
||||
|
||||
module.exports = { openDownload, saveUpload, UPLOAD_MAX_BYTES, DOWNLOAD_MAX_BYTES };
|
||||
+6
-2
@@ -36,7 +36,8 @@ const FEATURED_HOST = "https://codex-pets.net";
|
||||
const FEATURED = [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit" },
|
||||
{ id: "border-collie", displayName: "Border Collie", by: "dev-minsoo" },
|
||||
{ id: "11", displayName: "11", by: "ct" },
|
||||
// codex-pets.net names it "11"; it is a silver-shaded British Shorthair.
|
||||
{ id: "11", displayName: "British Shorthair", by: "ct" },
|
||||
];
|
||||
const ID_RE = /^(?:p-[0-9a-f]{12}|cp-[a-z0-9-]{1,40})$/;
|
||||
const IMAGE_TYPES = {
|
||||
@@ -127,9 +128,12 @@ function readMeta(id) {
|
||||
}
|
||||
|
||||
function view(id, meta) {
|
||||
// A featured pet shows the name it has in FEATURED now, so a rename there
|
||||
// reaches pets installed before it.
|
||||
const featured = meta.featuredId && FEATURED.find((f) => f.id === meta.featuredId);
|
||||
return {
|
||||
id,
|
||||
displayName: meta.displayName,
|
||||
displayName: featured ? featured.displayName : meta.displayName,
|
||||
description: meta.description || "",
|
||||
kind: meta.kind,
|
||||
source: meta.source,
|
||||
|
||||
@@ -128,6 +128,7 @@ function canSeeRunRow(req, id) {
|
||||
const LANE_WORDS = new Set([
|
||||
"branches",
|
||||
"browse",
|
||||
"clone",
|
||||
"ensure",
|
||||
"gc",
|
||||
"pick-path",
|
||||
@@ -213,9 +214,38 @@ function scopeGuard(req, res, next) {
|
||||
if (bad) return notFound();
|
||||
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
|
||||
if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
|
||||
// S3: a WRITE to a workspace re-checks that its owner may still own that
|
||||
// folder, by the LIVE role — an admin demoted since keeps owning their
|
||||
// host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a
|
||||
// file write there would run as the HOST account. An owner that fits (every
|
||||
// workspace was checked when it was made) never notices; reads stay open.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
const m = /^\/(?:lanes|workspaces)\/(\d+)(?:\/|$)/i.exec(p);
|
||||
const ids = [...(m ? [m[1]] : []), ...lanes];
|
||||
if (ids.some((id) => !ownerStillFits(Number(id)))) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: "EOWNERFOLDER",
|
||||
message: "this workspace runs as the host account: only an admin may change it",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/** Whether a workspace's owner may still own its folder (S3, live role). */
|
||||
function ownerStillFits(laneId) {
|
||||
const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
if (!lane) return true;
|
||||
try {
|
||||
require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket broadcasts ───────────────────────────────────────────────────
|
||||
|
||||
const STAFF = { staffOnly: true };
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* for the built-in Swagger and ReDoc surfaces, plus the read-only
|
||||
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
|
||||
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
|
||||
* Workspace page opens a directory with.
|
||||
* Workspace page opens a directory with, and the Files tab's reads (tree,
|
||||
* quick-open, download — also in a folder that is not a git repo) and upload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -688,7 +689,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Tracked paths matching a substring (quick-open)",
|
||||
description:
|
||||
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.",
|
||||
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, no same-origin guard.",
|
||||
operationId: "listLanePaths",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -705,7 +706,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One directory level of tracked files",
|
||||
description:
|
||||
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.",
|
||||
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTree",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -723,7 +724,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Every level of the tracked tree in one read",
|
||||
description:
|
||||
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
|
||||
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTreeAll",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
@@ -732,6 +733,66 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/download": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Download one working file of the workspace",
|
||||
description:
|
||||
"The Files tab's Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member's file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''…`) with `Content-Security-Policy: default-src 'none'; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard's origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.",
|
||||
operationId: "downloadLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file's bytes, as an attachment." },
|
||||
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
|
||||
403: {
|
||||
description:
|
||||
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/upload": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Upload one new file into a folder of the workspace",
|
||||
description:
|
||||
"The Files tab's Upload. The body is the file's raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder (\"\" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member's home dot folder), at most 25 MB. Under S3 the file is written as the folder's member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.",
|
||||
operationId: "uploadLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{
|
||||
name: "X-File-Name",
|
||||
in: "header",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The new file's name, URI-encoded: one plain segment of at most 255 bytes.",
|
||||
},
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/octet-stream": { schema: { type: "string", format: "binary" } },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: "{path} — the workspace-relative path written." },
|
||||
400: { description: "EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT." },
|
||||
403: { description: "ESECRET, EFOREIGN, or EBADORIGIN (cross-origin)." },
|
||||
404: { description: "Lane not found." },
|
||||
409: { description: "EEXISTS — a file of that name is already there." },
|
||||
413: { description: "ETOOBIG — over 25 MB." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
@@ -170,6 +170,14 @@ const paths = {
|
||||
"Inclusive upper bound on `started_at` (ISO 8601). An unparseable value is ignored. Applied before `limit`/`offset`.",
|
||||
example: "2026-10-05T23:59:59.999Z",
|
||||
},
|
||||
{
|
||||
name: "pinned_first",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"`1` = pinned sessions first (by `pinned_at`, oldest pin first), then the normal order. Applied before `limit`/`offset`, so a pinned old session stays on the first page. Omitted = order unchanged.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
$ref: "#/components/parameters/SourcesQuery",
|
||||
example: "local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||||
@@ -300,7 +308,7 @@ const paths = {
|
||||
tags: ["Sessions"],
|
||||
summary: "Update session",
|
||||
description:
|
||||
"Partially updates a session by `id`. Only `name`, `status`, `ended_at`, and `metadata` are accepted; any field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
"Partially updates a session by `id`. `name`, `status`, `ended_at`, `metadata`, `archived` and `pinned` are accepted (`archived`/`pinned` must be booleans, else 400 `INVALID_INPUT`; they set or clear `archived_at`/`pinned_at`, and a pin-only PATCH leaves `updated_at` unchanged); any other field omitted from the body is passed as null and the underlying UPDATE uses COALESCE, so a null leaves the existing column value unchanged (partial-update semantics) — you cannot clear a field to null through this endpoint. `metadata` is supplied as a JSON object but stored and returned as a JSON-encoded string. A successful update re-reads the row and broadcasts a `session_updated` websocket frame. Returns 404 with code `NOT_FOUND` when the session does not exist.",
|
||||
operationId: "updateSession",
|
||||
parameters: [
|
||||
{
|
||||
|
||||
@@ -638,6 +638,15 @@ function createOpenApiSpec() {
|
||||
status: { type: "string", enum: ["active", "completed", "error", "abandoned"] },
|
||||
ended_at: { type: "string", format: "date-time" },
|
||||
metadata: { type: "object", additionalProperties: true },
|
||||
archived: {
|
||||
type: "boolean",
|
||||
description: "Sets or clears `archived_at` (hidden from its workspace's lists).",
|
||||
},
|
||||
pinned: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Sets or clears `pinned_at` (top of its workspace's sidebar list). Pinning again keeps the first pin time; a pin-only PATCH leaves `updated_at` unchanged.",
|
||||
},
|
||||
},
|
||||
},
|
||||
SessionUpdateResponse: {
|
||||
|
||||
@@ -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