276cead826
AWS CodeCommit logs an SSH key in by its IAM SSH key ID (APKA…), so a plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/x` was refused as the Linux account name. The account can now save an SSH user per host — PUT/DELETE /api/me/git/ssh-user, listed as `sshUsers` — written as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in its own ~/.gitconfig (as the member under S3), so the plain URL works in a pane, in Clone and in the git bar. Only rules of exactly that shape are listed or removed; any other insteadOf is the user's and left alone. Settings → Git → Your git identity now shows the SSH keys one section per kind of host, each with only its own numbered steps: GitHub · GitLab · Gitea · Bitbucket (ED25519) and AWS CodeCommit (RSA: copy → upload in IAM → paste the key ID and pick the region, then a "Ready" clone line), plus a collapsed "Other SSH hosts (advanced)". The shared key-type picker is gone: each section has its own Generate/Replace/Delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
592 lines
24 KiB
JavaScript
592 lines
24 KiB
JavaScript
/**
|
|
* @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("POST /api/lanes/:id/sources/clone", () => {
|
|
it("clones beside the workspace folder and adds it as an extra folder", async () => {
|
|
const home = path.join(WORK, "app");
|
|
fs.mkdirSync(home);
|
|
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
|
|
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
|
|
const id = made.body.lane.id;
|
|
|
|
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
|
url: "https://forge.test/o/r.git",
|
|
name: "lib",
|
|
// Ignored: the clone always lands beside the workspace folder.
|
|
parent: path.join(ROOT, "elsewhere"),
|
|
});
|
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
const target = path.join(WORK, "lib");
|
|
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
|
|
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
|
|
const folders = r.body.sources.filter((s) => s.kind === "folder");
|
|
assert.deepEqual(
|
|
folders.map((f) => fs.realpathSync(f.path)),
|
|
[fs.realpathSync(target)]
|
|
);
|
|
|
|
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
|
url: "https://forge.test/o/r.git",
|
|
name: "lib",
|
|
});
|
|
assert.equal(again.status, 409);
|
|
assert.equal(again.body.error.code, "EEXISTS");
|
|
|
|
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
|
url: "https://private.test/o/r.git",
|
|
name: "private-lib",
|
|
});
|
|
assert.equal(login.status, 409);
|
|
assert.equal(login.body.error.code, "ECREDENTIALS");
|
|
assert.equal(login.body.error.auth.host, "private.test");
|
|
});
|
|
|
|
it("answers 404 for a workspace that does not exist", async () => {
|
|
const r = await request("POST", "/api/lanes/99999/sources/clone", {
|
|
url: "https://forge.test/o/r.git",
|
|
});
|
|
assert.equal(r.status, 404);
|
|
});
|
|
});
|
|
|
|
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("keeps an RSA 4096 key (AWS CodeCommit) BESIDE the ed25519 one", async () => {
|
|
const ed = await request("POST", "/api/me/git/ssh-key", {});
|
|
const rsa = await request("POST", "/api/me/git/ssh-key", { type: "rsa" });
|
|
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
|
|
assert.deepEqual(
|
|
rsa.body.sshKeys.map((k) => k.type),
|
|
["ed25519", "rsa"]
|
|
);
|
|
// `ssh` stays the first key, as for a client that knows only one.
|
|
assert.equal(rsa.body.ssh.publicKey, ed.body.ssh.publicKey);
|
|
assert.match(rsa.body.sshKeys[1].publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
|
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
|
|
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
|
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
|
|
assert.ok(fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was kept");
|
|
// Each type is replaced and deleted on its own.
|
|
assert.equal((await request("POST", "/api/me/git/ssh-key", { type: "rsa" })).status, 409);
|
|
const again = await request("POST", "/api/me/git/ssh-key", { type: "rsa", replace: true });
|
|
assert.notEqual(again.body.sshKeys[1].publicKey, rsa.body.sshKeys[1].publicKey);
|
|
assert.equal(again.body.sshKeys[0].publicKey, ed.body.ssh.publicKey);
|
|
const noEd = await request("DELETE", "/api/me/git/ssh-key?type=ed25519");
|
|
assert.deepEqual(
|
|
noEd.body.sshKeys.map((k) => k.type),
|
|
["rsa"]
|
|
);
|
|
assert.equal(noEd.body.ssh.type, "rsa");
|
|
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=ed25519")).status, 404);
|
|
assert.equal((await request("DELETE", "/api/me/git/ssh-key?type=dsa")).status, 400);
|
|
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
|
|
encoding: "utf8",
|
|
});
|
|
assert.match(bits, /^4096 .*\(RSA\)/);
|
|
assert.equal(
|
|
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
|
|
201
|
|
);
|
|
// An unknown type falls back to the default (the route only passes rsa through).
|
|
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
|
|
await request("DELETE", "/api/me/git/ssh-key");
|
|
});
|
|
|
|
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("/api/me/git/ssh-terminal (issue #82)", () => {
|
|
const gitGet = () => {
|
|
try {
|
|
return execFileSync("git", ["config", "--global", "--get", "core.sshCommand"], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
it("points the account's terminal git at the key, keeping the ~/.ssh defaults", async () => {
|
|
const made = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
|
assert.equal(made.status, 201);
|
|
// The host's terminal is an admin's switch: never turned on by itself.
|
|
assert.equal(made.body.ssh.terminal, "off");
|
|
const on = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
|
assert.equal(on.status, 200);
|
|
assert.equal(on.body.ssh.terminal, "on");
|
|
const cmd = gitGet();
|
|
assert.match(
|
|
cmd,
|
|
/^ssh -o IdentityFile='.*agenthub-ssh\/id_ed25519' -o IdentityFile='.*id_rsa'/
|
|
);
|
|
assert.match(cmd, /-o IdentityFile=~\/\.ssh\/id_ed25519/);
|
|
// ssh itself reads it: ours first, then the defaults.
|
|
const ids = execFileSync(
|
|
"sh",
|
|
["-c", `${cmd.replace(/^ssh /, "ssh -G -F /dev/null ")} x.test`],
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
)
|
|
.split("\n")
|
|
.filter((l) => l.startsWith("identityfile "));
|
|
assert.match(ids[0], /agenthub-ssh\/id_ed25519$/);
|
|
assert.ok(ids.some((l) => l.endsWith("/.ssh/id_ed25519")));
|
|
// The dashboard's own fetch still gets its BatchMode command over ours.
|
|
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
|
assert.equal(off.body.ssh.terminal, "off");
|
|
assert.equal(gitGet(), "");
|
|
});
|
|
|
|
it("never overwrites a core.sshCommand the user wrote", async () => {
|
|
execFileSync("git", ["config", "--global", "core.sshCommand", "ssh -i ~/mine"]);
|
|
try {
|
|
const r = await request("PUT", "/api/me/git/ssh-terminal", { on: true });
|
|
assert.equal(r.status, 409);
|
|
assert.equal(r.body.error.code, "EFOREIGNSSH");
|
|
const off = await request("PUT", "/api/me/git/ssh-terminal", { on: false });
|
|
assert.equal(off.body.ssh.terminal, "foreign");
|
|
assert.equal(gitGet(), "ssh -i ~/mine");
|
|
} finally {
|
|
execFileSync("git", ["config", "--global", "--unset", "core.sshCommand"]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("/api/me/git/ssh-user (CodeCommit SSH key ID)", () => {
|
|
const getUrl = (url) =>
|
|
execFileSync("git", ["ls-remote", "--get-url", url], { encoding: "utf8" }).trim();
|
|
const HOST = "git-codecommit.ap-southeast-1.amazonaws.com";
|
|
|
|
it("makes a plain ssh:// URL log in as the saved user, and only for that host", async () => {
|
|
const r = await request("PUT", "/api/me/git/ssh-user", {
|
|
host: ` ${HOST.toUpperCase()} `,
|
|
user: "APKAEXAMPLE1",
|
|
});
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE1" }]);
|
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE1@${HOST}/v1/repos/x`);
|
|
assert.equal(getUrl("ssh://other.test/x"), "ssh://other.test/x");
|
|
// A second save replaces, never stacks a second rewrite.
|
|
const again = await request("PUT", "/api/me/git/ssh-user", {
|
|
host: HOST,
|
|
user: "APKAEXAMPLE2",
|
|
});
|
|
assert.deepEqual(again.body.sshUsers, [{ host: HOST, user: "APKAEXAMPLE2" }]);
|
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://APKAEXAMPLE2@${HOST}/v1/repos/x`);
|
|
const del = await request("DELETE", `/api/me/git/ssh-user/${HOST}`);
|
|
assert.deepEqual(del.body.sshUsers, []);
|
|
assert.equal(getUrl(`ssh://${HOST}/v1/repos/x`), `ssh://${HOST}/v1/repos/x`);
|
|
assert.equal((await request("DELETE", `/api/me/git/ssh-user/${HOST}`)).status, 404);
|
|
});
|
|
|
|
it("refuses a bad host or user, and leaves the user's own insteadOf alone", async () => {
|
|
const own = "url.ssh://me@mine.test/.insteadOf";
|
|
execFileSync("git", ["config", "--global", own, "ssh://mine.test/"]);
|
|
try {
|
|
assert.equal(
|
|
(await request("PUT", "/api/me/git/ssh-user", { host: "-oProxy", user: "a" })).body.error
|
|
.code,
|
|
"EBADHOST"
|
|
);
|
|
const bad = await request("PUT", "/api/me/git/ssh-user", { host: HOST, user: "a@b" });
|
|
assert.equal(bad.status, 400);
|
|
assert.equal(bad.body.error.code, "EBADSSHUSER");
|
|
// Ours lists only our shape; a rule the user wrote for another host is kept.
|
|
const r = await request("GET", "/api/me/git");
|
|
assert.deepEqual(r.body.sshUsers, [{ host: "mine.test", user: "me" }]);
|
|
assert.equal((await request("DELETE", "/api/me/git/ssh-user/other.test")).status, 404);
|
|
assert.equal(getUrl("ssh://mine.test/x"), "ssh://me@mine.test/x");
|
|
} finally {
|
|
execFileSync("git", ["config", "--global", "--unset", own]);
|
|
}
|
|
});
|
|
});
|
|
|
|
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", async () => {
|
|
const account = identity.accountForDir(path.join(HOME, "proj"));
|
|
assert.equal(account.kind, "member");
|
|
calls = [];
|
|
await identity.generateSshKey(account);
|
|
// A member's terminal git uses the key from the start (issue #82).
|
|
const set = calls.find((c) => c.args[2] === "core.sshCommand");
|
|
assert.equal(set.cwd, HOME);
|
|
assert.match(set.args[3], /IdentityFile='.*\/users\/an\/\.config\/agenthub\/ssh\/id_ed25519'/);
|
|
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/
|
|
);
|
|
// Persisted by file name, both types: a key made later is offered too.
|
|
assert.match(
|
|
args[i + 2],
|
|
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
|
|
);
|
|
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);
|
|
});
|
|
});
|