feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix
- Stop (a tab's or pane's X, Close tab) now archives the session it held (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in the tab, pane and session right-click menus) stops the pane but keeps the session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts. - Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS CodeCommit takes only RSA), every ssh git offers both, and a member's ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them. - Update dialog: Auto fix starts a Claude Code session on the install folder with the failed update's error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -267,16 +267,34 @@ describe("/api/me/git", () => {
|
||||
assert.equal(del.body.ssh, null);
|
||||
});
|
||||
|
||||
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
|
||||
await request("POST", "/api/me/git/ssh-key", {});
|
||||
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
|
||||
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.equal(rsa.body.ssh.type, "rsa");
|
||||
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||
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 replaced");
|
||||
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",
|
||||
});
|
||||
@@ -317,6 +335,64 @@ describe("/api/me/git", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("git-identity, member account (S3, stubbed)", () => {
|
||||
const runAs = require("../lib/run-as");
|
||||
const worktree = require("../lib/worktree");
|
||||
@@ -356,7 +432,12 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
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" });
|
||||
@@ -381,7 +462,11 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
args[i - 1],
|
||||
/^core\.sshCommand=ssh -i '.*\/\.config\/agenthub\/ssh\/id_ed25519'.*BatchMode=yes/
|
||||
);
|
||||
assert.match(args[i + 2], /^core\.sshCommand=ssh -i /);
|
||||
// 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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -173,6 +173,34 @@ describe("/api/run", () => {
|
||||
assert.deepEqual(body, { ok: true });
|
||||
});
|
||||
|
||||
it("DELETE /:id?archive=1 archives the run's session; a plain DELETE (Sleep) keeps it", async () => {
|
||||
const { db } = require("../db");
|
||||
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-stop", TMP);
|
||||
db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run("sess-sleep", TMP);
|
||||
const run = db.prepare(
|
||||
"INSERT OR REPLACE INTO dashboard_runs (id, session_id, cwd, status) VALUES (?, ?, ?, 'running')"
|
||||
);
|
||||
const archivedAt = (id) =>
|
||||
db.prepare("SELECT archived_at FROM sessions WHERE id = ?").get(id).archived_at;
|
||||
tmux.__setExecImpl(() => "");
|
||||
|
||||
run.run("agenthub-lane-9-1", "sess-sleep", TMP);
|
||||
const slept = await fetchJson("/api/run/agenthub-lane-9-1", { method: "DELETE" });
|
||||
assert.deepEqual(slept.body, { ok: true });
|
||||
assert.equal(archivedAt("sess-sleep"), null);
|
||||
|
||||
run.run("agenthub-lane-9-2", "sess-stop", TMP);
|
||||
const stopped = await fetchJson("/api/run/agenthub-lane-9-2?archive=1", { method: "DELETE" });
|
||||
assert.equal(stopped.status, 200);
|
||||
assert.deepEqual(stopped.body, { ok: true, archived: "sess-stop" });
|
||||
assert.ok(archivedAt("sess-stop"));
|
||||
|
||||
// A pane with no known session (a shell) archives nothing.
|
||||
run.run("agenthub-lane-9-3", null, TMP);
|
||||
const shell = await fetchJson("/api/run/agenthub-lane-9-3?archive=1", { method: "DELETE" });
|
||||
assert.deepEqual(shell.body, { ok: true, archived: null });
|
||||
});
|
||||
|
||||
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
|
||||
+132
-37
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* 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` or `ssh/id_rsa` (+ `.pub`) and `git-credentials`
|
||||
* `~/.config/agenthub/` — `ssh/id_ed25519` and/or `ssh/id_rsa` (+ `.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
|
||||
@@ -20,6 +20,10 @@
|
||||
* 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.
|
||||
* A plain `git` typed in a terminal reads none of that, so the account's own
|
||||
* `~/.gitconfig` may also carry our `core.sshCommand` (`setTerminalSsh`: on
|
||||
* by default for a member, a switch for the host; a user's own is never
|
||||
* overwritten).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -64,7 +68,8 @@ function accountForRequest(req) {
|
||||
/**
|
||||
* The key types offered: ed25519 (the default — GitHub, GitLab, Gitea…) and
|
||||
* RSA 4096 for hosts that refuse ed25519 (AWS IAM / CodeCommit takes only
|
||||
* `ssh-rsa`). One key per account at a time, in the file of its type.
|
||||
* `ssh-rsa`). An account may hold one of EACH, in the file of its type, and
|
||||
* every ssh git runs offers both (ed25519 first).
|
||||
*/
|
||||
const KEY_FILES = { ed25519: "id_ed25519", rsa: "id_rsa" };
|
||||
const KEY_PREFIX = { ed25519: "ssh-ed25519", rsa: "ssh-rsa" };
|
||||
@@ -208,38 +213,48 @@ function fingerprint(publicKey) {
|
||||
return `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
/** `{type, file}` of the account's key (ed25519 first), or null. */
|
||||
function currentKey(account) {
|
||||
/** `[{type, file, publicKey}]` of the account's keys, ed25519 first. */
|
||||
function currentKeys(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const keys = [];
|
||||
for (const type of Object.keys(KEY_FILES)) {
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
const publicKey = readText(account, `${file}.pub`);
|
||||
if (!publicKey || !publicKey.startsWith(`${KEY_PREFIX[type]} `)) continue;
|
||||
// The private half is only looked at, never read into this process.
|
||||
if (!isOwnFile(account, file)) continue;
|
||||
return { type, file, publicKey: publicKey.trim() };
|
||||
keys.push({ type, file, publicKey: publicKey.trim() });
|
||||
}
|
||||
return null;
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The private key file a clone or fetch uses, or null. */
|
||||
const keyFile = (account) => currentKey(account)?.file ?? null;
|
||||
/** The private key files a clone or fetch offers (ed25519 first), maybe none. */
|
||||
const keyFiles = (account) => currentKeys(account).map((k) => k.file);
|
||||
|
||||
/** `{type, publicKey, fingerprint}` of the account's key, or null — never the private half. */
|
||||
function sshKeyInfo(account) {
|
||||
const k = currentKey(account);
|
||||
return k ? { type: k.type, publicKey: k.publicKey, fingerprint: fingerprint(k.publicKey) } : null;
|
||||
/** ssh options naming `files` — `-i` each, in order. */
|
||||
const identityArgs = (files) => files.map((f) => ` -i ${shq(f)}`).join("");
|
||||
|
||||
/** `[{type, publicKey, fingerprint}]` of the account's keys — never a private half. */
|
||||
function sshKeysInfo(account) {
|
||||
return currentKeys(account).map((k) => ({
|
||||
type: k.type,
|
||||
publicKey: k.publicKey,
|
||||
fingerprint: fingerprint(k.publicKey),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The account's first key (ed25519 before rsa), or null — what `ssh` meant before two. */
|
||||
const sshKeyInfo = (account) => sshKeysInfo(account)[0] ?? null;
|
||||
|
||||
/**
|
||||
* Make the account's key (`type`: ed25519, the default, or rsa); an existing
|
||||
* one is replaced only with `replace` — and a key of the other type goes, so
|
||||
* there is one key per account.
|
||||
* Make the account's key of `type` (ed25519, the default, or rsa); an
|
||||
* existing one OF THAT TYPE is replaced only with `replace`. A key of the
|
||||
* other type is left alone — a host refusing one type keeps the other.
|
||||
*/
|
||||
async function generateSshKey(account, { replace = false, comment, type = "ed25519" } = {}) {
|
||||
if (!KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
if (sshKeyInfo(account) && !replace) {
|
||||
throw err("EEXISTS", "an SSH key already exists — replace it explicitly");
|
||||
if (currentKeys(account).some((k) => k.type === type) && !replace) {
|
||||
throw err("EEXISTS", `an ${type} SSH key already exists — replace it explicitly`);
|
||||
}
|
||||
const label =
|
||||
String(comment || "agenthub")
|
||||
@@ -251,22 +266,26 @@ async function generateSshKey(account, { replace = false, comment, type = "ed255
|
||||
const file = path.join(sshDir, KEY_FILES[type]);
|
||||
writeText(account, file, privateKey, { secret: true });
|
||||
writeText(account, `${file}.pub`, publicKey, { secret: false });
|
||||
for (const [other, name] of Object.entries(KEY_FILES)) {
|
||||
if (other === type) continue;
|
||||
const f = path.join(sshDir, name);
|
||||
if (isOwnFile(account, f) || readText(account, `${f}.pub`) != null) {
|
||||
removeFile(account, f);
|
||||
removeFile(account, `${f}.pub`);
|
||||
}
|
||||
}
|
||||
return sshKeyInfo(account);
|
||||
// A member's home is AgentHub's to set up: their terminal git uses the key
|
||||
// too, unless they wrote a core.sshCommand of their own. The host's is
|
||||
// its admin's choice (the Settings switch).
|
||||
if (account.kind === "member") await setTerminalSsh(account, true).catch(() => {});
|
||||
return sshKeysInfo(account).find((k) => k.type === type) ?? null;
|
||||
}
|
||||
|
||||
function removeSshKey(account) {
|
||||
const k = currentKey(account);
|
||||
if (!k) return false;
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
/**
|
||||
* Delete the account's key of `type`, or every key when `type` is omitted;
|
||||
* false when there was none. The terminal sshCommand goes with the last key.
|
||||
*/
|
||||
async function removeSshKey(account, type) {
|
||||
if (type != null && !KEY_FILES[type]) throw err("EBADKEYTYPE", "key type: ed25519 or rsa");
|
||||
const gone = currentKeys(account).filter((k) => type == null || k.type === type);
|
||||
if (!gone.length) return false;
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
}
|
||||
if (!currentKeys(account).length) await setTerminalSsh(account, false).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -386,6 +405,69 @@ async function setAuthor(account, { name, email }) {
|
||||
return getAuthor(account);
|
||||
}
|
||||
|
||||
// ── The key in the account's own terminal (`~/.gitconfig` core.sshCommand) ──
|
||||
|
||||
/**
|
||||
* The `core.sshCommand` that makes a plain `git` in the account's terminal
|
||||
* use the AgentHub key (issue #82: a clone typed in a pane offered only
|
||||
* `~/.ssh/id_*` and was refused). Both key files are named, so swapping the
|
||||
* key's type needs no rewrite; ssh skips a missing `IdentityFile` silently.
|
||||
* Naming one drops ssh's default list, so the defaults are named again after
|
||||
* ours — a key the user set up in `~/.ssh/` keeps working.
|
||||
*/
|
||||
function terminalSshCommand(account) {
|
||||
const { sshDir } = paths(account);
|
||||
const ours = Object.values(KEY_FILES).map((f) => `-o IdentityFile=${shq(path.join(sshDir, f))}`);
|
||||
const defaults = ["id_ed25519", "id_rsa", "id_ecdsa"].map((f) => `-o IdentityFile=~/.ssh/${f}`);
|
||||
return ["ssh", ...ours, ...defaults].join(" ");
|
||||
}
|
||||
|
||||
/** The account's global `core.sshCommand`, or "" when unset. */
|
||||
async function globalSshCommand(account) {
|
||||
const { git } = require("./worktree");
|
||||
return git(homeOf(account), ["config", "--global", "--get", "core.sshCommand"]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account's terminal git uses the AgentHub key: `on` (our
|
||||
* sshCommand), `off` (none set) or `foreign` (one the user wrote — never
|
||||
* touched).
|
||||
*/
|
||||
async function terminalSshState(account) {
|
||||
const cmd = await globalSshCommand(account);
|
||||
if (!cmd) return "off";
|
||||
return cmd === terminalSshCommand(account) ? "on" : "foreign";
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the AgentHub key on/off for the account's terminal git. On refuses
|
||||
* over a sshCommand the user wrote (`EFOREIGNSSH`); off removes only ours.
|
||||
* Neither value is a secret, so it may cross sudo's argv.
|
||||
*/
|
||||
async function setTerminalSsh(account, on) {
|
||||
const { git } = require("./worktree");
|
||||
const state = await terminalSshState(account);
|
||||
if (on) {
|
||||
if (state === "foreign") {
|
||||
throw err("EFOREIGNSSH", "your ~/.gitconfig already sets core.sshCommand — left as it is");
|
||||
}
|
||||
if (state === "off") {
|
||||
await git(homeOf(account), [
|
||||
"config",
|
||||
"--global",
|
||||
"core.sshCommand",
|
||||
terminalSshCommand(account),
|
||||
]);
|
||||
}
|
||||
} else if (state === "on") {
|
||||
await git(homeOf(account), ["config", "--global", "--unset", "core.sshCommand"]);
|
||||
}
|
||||
return terminalSshState(account);
|
||||
}
|
||||
|
||||
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
|
||||
|
||||
/**
|
||||
@@ -409,17 +491,18 @@ async function sshConfigFor(dir) {
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
if (own) return [];
|
||||
let account;
|
||||
try {
|
||||
account = accountForDir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const key = keyFile(account);
|
||||
// Our own terminal sshCommand (global) is not the repo's choice: it has no
|
||||
// BatchMode, so it would leave a dashboard fetch waiting on a prompt.
|
||||
if (own && own !== terminalSshCommand(account)) return [];
|
||||
return [
|
||||
"-c",
|
||||
`core.sshCommand=ssh${key ? ` -i ${shq(key)}` : ""} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
`core.sshCommand=ssh${identityArgs(keyFiles(account))} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -526,12 +609,20 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
||||
|
||||
if (remote.scheme === "ssh") {
|
||||
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
|
||||
const key = keyFile(account);
|
||||
const base = key ? `ssh -i ${shq(key)} -o IdentitiesOnly=yes` : "ssh";
|
||||
const keys = keyFiles(account);
|
||||
const base = keys.length ? `ssh${identityArgs(keys)} -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}`);
|
||||
if (keys.length) {
|
||||
// Persisted by FILE name, both types: a key made later is offered too
|
||||
// (ssh skips a missing IdentityFile silently, where `-i` would warn).
|
||||
const { sshDir } = paths(account);
|
||||
const all = Object.values(KEY_FILES)
|
||||
.map((f) => ` -o IdentityFile=${shq(path.join(sshDir, f))}`)
|
||||
.join("");
|
||||
persist.push("-c", `core.sshCommand=ssh${all} -o IdentitiesOnly=yes`);
|
||||
}
|
||||
} else if (account.kind === "member") {
|
||||
const k = hostKey("https", remote.host);
|
||||
const before = readStore(account, { strict: true });
|
||||
@@ -617,8 +708,12 @@ module.exports = {
|
||||
makeRsaKeyPair,
|
||||
fingerprint,
|
||||
sshKeyInfo,
|
||||
sshKeysInfo,
|
||||
generateSshKey,
|
||||
removeSshKey,
|
||||
terminalSshCommand,
|
||||
terminalSshState,
|
||||
setTerminalSsh,
|
||||
listHttps,
|
||||
saveHttps,
|
||||
removeHttps,
|
||||
|
||||
@@ -268,7 +268,7 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{account, writable, ssh: {publicKey, fingerprint} | null, https: [...], author: {name, email}}",
|
||||
"{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -276,18 +276,41 @@ const paths = {
|
||||
"/api/me/git/ssh-key": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Make (or with `replace`, replace) the SSH key",
|
||||
summary: "Make (or with `replace`, replace) the SSH key of `type`; the other type is kept",
|
||||
operationId: "generateSshKey",
|
||||
requestBody: json({ replace: { type: "boolean" } }),
|
||||
requestBody: json({
|
||||
replace: { type: "boolean" },
|
||||
type: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
}),
|
||||
responses: { 201: { description: "The identity." } },
|
||||
},
|
||||
delete: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Delete the SSH key",
|
||||
summary: "Delete the SSH key of ?type=ed25519|rsa, or every key",
|
||||
operationId: "deleteSshKey",
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["ed25519", "rsa"] },
|
||||
},
|
||||
],
|
||||
responses: { 200: { description: "The identity." } },
|
||||
},
|
||||
},
|
||||
"/api/me/git/ssh-terminal": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
summary: "Use the SSH key for git in the account's own terminal (core.sshCommand)",
|
||||
operationId: "setTerminalSsh",
|
||||
requestBody: json({ on: { type: "boolean" } }, ["on"]),
|
||||
responses: {
|
||||
200: { description: "The identity; `ssh.terminal` is on, off or foreign." },
|
||||
409: { description: "EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/git/https": {
|
||||
post: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -585,6 +585,12 @@ const schemas = {
|
||||
required: ["ok"],
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true], example: true },
|
||||
archived: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1188,6 +1194,14 @@ const paths = {
|
||||
description: "Run id (RunHandle.id).",
|
||||
example: "3f2c9a1e-7b4d-4e21-9b6a-1d2e3f4a5b6c",
|
||||
},
|
||||
{
|
||||
name: "archive",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"`1` (the UI's Stop) also archives the session the pane held, so it leaves its workspace's lists. Omitted (the UI's Sleep, the CLI) the session stays listed, to be resumed later.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @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 —
|
||||
* mounted at `/api/me/git`: the SSH keys a clone uses — ed25519 and/or rsa (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` /
|
||||
* `hasToken` only, never a password), whether the account's own terminal
|
||||
* git uses that key (`core.sshCommand`), 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
|
||||
@@ -27,6 +28,7 @@ const STATUS = {
|
||||
EBADAUTHOR: 400,
|
||||
ESTOREUNREADABLE: 409,
|
||||
EBADKEYTYPE: 400,
|
||||
EFOREIGNSSH: 409,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -36,10 +38,15 @@ function fail(res, e) {
|
||||
}
|
||||
|
||||
async function view(account) {
|
||||
const keys = identity.sshKeysInfo(account);
|
||||
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
|
||||
return {
|
||||
account: account.kind,
|
||||
writable: account.kind === "member" || account.writable !== false,
|
||||
ssh: identity.sshKeyInfo(account),
|
||||
// `ssh`: the first key, as before there could be two; `sshKeys`: all.
|
||||
ssh: keys[0] ? { ...keys[0], terminal } : null,
|
||||
sshKeys: keys,
|
||||
sshTerminal: terminal ?? null,
|
||||
https: identity.listHttps(account),
|
||||
author: await identity.getAuthor(account),
|
||||
};
|
||||
@@ -81,7 +88,8 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.removeSshKey(account)) {
|
||||
const type = req.query.type === undefined ? undefined : String(req.query.type);
|
||||
if (!(await identity.removeSshKey(account, type))) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
res.json(await view(account));
|
||||
@@ -90,6 +98,23 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a plain `git` in the account's own terminal uses the AgentHub key
|
||||
* (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`.
|
||||
*/
|
||||
router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
if (!identity.sshKeyInfo(account) && req.body?.on === true) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
|
||||
}
|
||||
await identity.setTerminalSsh(account, req.body?.on === true);
|
||||
res.json(await view(account));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/https", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
|
||||
@@ -626,9 +626,31 @@ router.delete("/:id", (req, res) => {
|
||||
if (!ok) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
// `?archive=1` is the UI's Stop: the session the pane held leaves its
|
||||
// workspace's lists as well. Sleep (and the CLI) omit it, so the session
|
||||
// stays listed to be resumed later. A pane with no known session (a shell,
|
||||
// a Codex pane whose session was never learned) archives nothing.
|
||||
if (req.query.archive === "1") {
|
||||
return res.json({ ok: true, archived: archiveRunSession(req, req.params.id) });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** Archive the session a run held; returns its id, or null when none. */
|
||||
function archiveRunSession(req, runId) {
|
||||
const sessionId = runs.getRun(runId)?.sessionId;
|
||||
if (!sessionId || !scope.canSeeSession(req, sessionId)) return null;
|
||||
const { db, stmts } = require("../db");
|
||||
const changed = db
|
||||
.prepare(
|
||||
"UPDATE sessions SET archived_at = COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?"
|
||||
)
|
||||
.run(sessionId).changes;
|
||||
if (!changed) return null;
|
||||
require("../websocket").broadcast("session_updated", stmts.getSession.get(sessionId));
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
// Shared with routes/lanes.js: lane actions spawn processes through the same
|
||||
// run-spawner, so they must sit behind the same loopback/same-origin check.
|
||||
|
||||
Reference in New Issue
Block a user